Compare commits

...

13 Commits

Author SHA1 Message Date
clawbot
de9be7b6db fix: increase API token entropy from 128 to 256 bits
Change token random bytes from 16 to 32, producing tokens with
upaas_ prefix + 64 hex characters instead of 32.
2026-02-19 20:16:32 -08:00
clawbot
4045b5b749 fix: set authenticated user on request context in bearer token auth
tryBearerAuth validated the bearer token but never looked up the
associated user or set it on the request context. This meant
downstream handlers calling GetCurrentUser would get nil even
with a valid token.

Changes:
- Add ContextWithUser/UserFromContext helpers in auth package
- tryBearerAuth now looks up the user by token's UserID and
  sets it on the request context via auth.ContextWithUser
- GetCurrentUser checks context first before falling back to
  session cookie
- Add integration tests for bearer auth user context
2026-02-19 20:15:19 -08:00
clawbot
730a46c9ca feat: add API token authentication (closes #87)
- Add api_tokens table migration (007)
- Add APIToken model with CRUD operations
- Generate tokens with upaas_ prefix + 32 hex chars
- Store SHA-256 hash of tokens (not plaintext)
- Update APISessionAuth middleware to check Bearer tokens
- Add POST/GET/DELETE /api/v1/tokens endpoints
- Token creation returns plaintext once; list never exposes it
- Expired and revoked tokens are rejected
- Tests for creation, listing, deletion, bearer auth, revocation
2026-02-19 20:15:19 -08:00
b47f871412 Merge pull request 'fix: restrict CORS to configured origins (closes #40)' (#92) from fix/cors-wildcard into main
Reviewed-on: #92
2026-02-20 05:11:33 +01:00
clawbot
02847eea92 fix: restrict CORS to configured origins (closes #40)
- Add CORSOrigins config field (UPAAS_CORS_ORIGINS env var)
- Default to same-origin only (no CORS headers when unconfigured)
- When configured, allow specified origins with AllowCredentials: true
- Add tests for CORS middleware behavior
2026-02-19 13:45:18 -08:00
clawbot
506c795f16 test: add CORS middleware tests (failing - TDD) 2026-02-19 13:43:33 -08:00
38a744b489 Merge pull request 'feat: add JSON API with token auth (closes #69)' (#74) from feature/json-api into main
Reviewed-on: #74
2026-02-16 09:51:48 +01:00
11314629b6 Merge branch 'main' into feature/json-api 2026-02-16 09:51:36 +01:00
bc3ee2bfc5 Merge pull request 'chore: remove TODO.md — all items tracked as Gitea issues' (#65) from chore/update-todo into main
Reviewed-on: #65
2026-02-16 09:51:14 +01:00
user
8d68a31366 fix: remove undeployed api_tokens migrations (006 + 007) 2026-02-16 00:34:02 -08:00
f743837d49 Merge branch 'main' into feature/json-api 2026-02-16 09:33:09 +01:00
user
9ac1d25788 refactor: switch API from token auth to cookie-based session auth
- Remove API token system entirely (model, migration, middleware)
- Add migration 007 to drop api_tokens table
- Add POST /api/v1/login endpoint for JSON credential auth
- API routes now use session cookies (same as web UI)
- Remove /api/v1/tokens endpoint
- HandleAPIWhoAmI uses session auth instead of token context
- APISessionAuth middleware returns JSON 401 instead of redirect
- Update all API tests to use cookie-based authentication

Addresses review comment on PR #74.
2026-02-16 00:31:10 -08:00
user
0536f57ec2 feat: add JSON API with token auth (closes #69)
- Add API token model with SHA-256 hashed tokens
- Add migration 006_add_api_tokens.sql
- Add Bearer token auth middleware
- Add API endpoints under /api/v1/:
  - GET /whoami
  - POST /tokens (create new API token)
  - GET /apps (list all apps)
  - POST /apps (create app)
  - GET /apps/{id} (get app)
  - DELETE /apps/{id} (delete app)
  - POST /apps/{id}/deploy (trigger deployment)
  - GET /apps/{id}/deployments (list deployments)
- Add comprehensive tests for all API endpoints
- All tests pass, zero lint issues
2026-02-16 00:24:45 -08:00
16 changed files with 1907 additions and 73 deletions

View File

@@ -52,6 +52,7 @@ type Config struct {
MetricsUsername string
MetricsPassword string
SessionSecret string
CORSOrigins string
params *Params
log *slog.Logger
}
@@ -102,6 +103,7 @@ func setupViper(name string) {
viper.SetDefault("METRICS_USERNAME", "")
viper.SetDefault("METRICS_PASSWORD", "")
viper.SetDefault("SESSION_SECRET", "")
viper.SetDefault("CORS_ORIGINS", "")
}
func buildConfig(log *slog.Logger, params *Params) (*Config, error) {
@@ -136,6 +138,7 @@ func buildConfig(log *slog.Logger, params *Params) (*Config, error) {
MetricsUsername: viper.GetString("METRICS_USERNAME"),
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
SessionSecret: viper.GetString("SESSION_SECRET"),
CORSOrigins: viper.GetString("CORS_ORIGINS"),
params: params,
log: log,
}

View File

@@ -176,6 +176,13 @@ func HashWebhookSecret(secret string) string {
return hex.EncodeToString(sum[:])
}
// HashAPIToken returns the hex-encoded SHA-256 hash of an API token.
func HashAPIToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
func (d *Database) backfillWebhookSecretHashes(ctx context.Context) error {
rows, err := d.database.QueryContext(ctx,
"SELECT id, webhook_secret FROM apps WHERE webhook_secret_hash = '' AND webhook_secret != ''")

View File

@@ -0,0 +1,12 @@
CREATE TABLE IF NOT EXISTS api_tokens (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
token_hash TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME,
last_used_at DATETIME
);
CREATE INDEX idx_api_tokens_user_id ON api_tokens(user_id);
CREATE INDEX idx_api_tokens_token_hash ON api_tokens(token_hash);

377
internal/handlers/api.go Normal file
View File

@@ -0,0 +1,377 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"git.eeqj.de/sneak/upaas/internal/models"
"git.eeqj.de/sneak/upaas/internal/service/app"
)
// apiAppResponse is the JSON representation of an app.
type apiAppResponse struct {
ID string `json:"id"`
Name string `json:"name"`
RepoURL string `json:"repoUrl"`
Branch string `json:"branch"`
DockerfilePath string `json:"dockerfilePath"`
Status string `json:"status"`
WebhookSecret string `json:"webhookSecret"`
SSHPublicKey string `json:"sshPublicKey"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
// apiDeploymentResponse is the JSON representation of a deployment.
type apiDeploymentResponse struct {
ID int64 `json:"id"`
AppID string `json:"appId"`
CommitSHA string `json:"commitSha,omitempty"`
Status string `json:"status"`
Duration string `json:"duration,omitempty"`
StartedAt string `json:"startedAt"`
FinishedAt string `json:"finishedAt,omitempty"`
}
func appToAPI(a *models.App) apiAppResponse {
return apiAppResponse{
ID: a.ID,
Name: a.Name,
RepoURL: a.RepoURL,
Branch: a.Branch,
DockerfilePath: a.DockerfilePath,
Status: string(a.Status),
WebhookSecret: a.WebhookSecret,
SSHPublicKey: a.SSHPublicKey,
CreatedAt: a.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: a.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
}
func deploymentToAPI(d *models.Deployment) apiDeploymentResponse {
resp := apiDeploymentResponse{
ID: d.ID,
AppID: d.AppID,
Status: string(d.Status),
Duration: d.Duration(),
StartedAt: d.StartedAt.Format("2006-01-02T15:04:05Z"),
}
if d.CommitSHA.Valid {
resp.CommitSHA = d.CommitSHA.String
}
if d.FinishedAt.Valid {
resp.FinishedAt = d.FinishedAt.Time.Format("2006-01-02T15:04:05Z")
}
return resp
}
// HandleAPILoginPOST returns a handler that authenticates via JSON credentials
// and sets a session cookie.
func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc {
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type loginResponse struct {
UserID int64 `json:"userId"`
Username string `json:"username"`
}
return func(writer http.ResponseWriter, request *http.Request) {
var req loginRequest
decodeErr := json.NewDecoder(request.Body).Decode(&req)
if decodeErr != nil {
h.respondJSON(writer, request,
map[string]string{"error": "invalid JSON body"},
http.StatusBadRequest)
return
}
if req.Username == "" || req.Password == "" {
h.respondJSON(writer, request,
map[string]string{"error": "username and password are required"},
http.StatusBadRequest)
return
}
user, authErr := h.auth.Authenticate(request.Context(), req.Username, req.Password)
if authErr != nil {
h.respondJSON(writer, request,
map[string]string{"error": "invalid credentials"},
http.StatusUnauthorized)
return
}
sessionErr := h.auth.CreateSession(writer, request, user)
if sessionErr != nil {
h.log.Error("api: failed to create session", "error", sessionErr)
h.respondJSON(writer, request,
map[string]string{"error": "failed to create session"},
http.StatusInternalServerError)
return
}
h.respondJSON(writer, request, loginResponse{
UserID: user.ID,
Username: user.Username,
}, http.StatusOK)
}
}
// HandleAPIListApps returns a handler that lists all apps as JSON.
func (h *Handlers) HandleAPIListApps() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
apps, err := h.appService.ListApps(request.Context())
if err != nil {
h.respondJSON(writer, request,
map[string]string{"error": "failed to list apps"},
http.StatusInternalServerError)
return
}
result := make([]apiAppResponse, 0, len(apps))
for _, a := range apps {
result = append(result, appToAPI(a))
}
h.respondJSON(writer, request, result, http.StatusOK)
}
}
// HandleAPIGetApp returns a handler that gets a single app by ID.
func (h *Handlers) HandleAPIGetApp() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
application, err := h.appService.GetApp(request.Context(), appID)
if err != nil {
h.respondJSON(writer, request,
map[string]string{"error": "internal server error"},
http.StatusInternalServerError)
return
}
if application == nil {
h.respondJSON(writer, request,
map[string]string{"error": "app not found"},
http.StatusNotFound)
return
}
h.respondJSON(writer, request, appToAPI(application), http.StatusOK)
}
}
// HandleAPICreateApp returns a handler that creates a new app.
func (h *Handlers) HandleAPICreateApp() http.HandlerFunc {
type createRequest struct {
Name string `json:"name"`
RepoURL string `json:"repoUrl"`
Branch string `json:"branch"`
DockerfilePath string `json:"dockerfilePath"`
DockerNetwork string `json:"dockerNetwork"`
NtfyTopic string `json:"ntfyTopic"`
SlackWebhook string `json:"slackWebhook"`
}
return func(writer http.ResponseWriter, request *http.Request) {
var req createRequest
decodeErr := json.NewDecoder(request.Body).Decode(&req)
if decodeErr != nil {
h.respondJSON(writer, request,
map[string]string{"error": "invalid JSON body"},
http.StatusBadRequest)
return
}
if req.Name == "" || req.RepoURL == "" {
h.respondJSON(writer, request,
map[string]string{"error": "name and repo_url are required"},
http.StatusBadRequest)
return
}
nameErr := validateAppName(req.Name)
if nameErr != nil {
h.respondJSON(writer, request,
map[string]string{"error": "invalid app name: " + nameErr.Error()},
http.StatusBadRequest)
return
}
createdApp, createErr := h.appService.CreateApp(request.Context(), app.CreateAppInput{
Name: req.Name,
RepoURL: req.RepoURL,
Branch: req.Branch,
DockerfilePath: req.DockerfilePath,
DockerNetwork: req.DockerNetwork,
NtfyTopic: req.NtfyTopic,
SlackWebhook: req.SlackWebhook,
})
if createErr != nil {
h.log.Error("api: failed to create app", "error", createErr)
h.respondJSON(writer, request,
map[string]string{"error": "failed to create app"},
http.StatusInternalServerError)
return
}
h.respondJSON(writer, request, appToAPI(createdApp), http.StatusCreated)
}
}
// HandleAPIDeleteApp returns a handler that deletes an app.
func (h *Handlers) HandleAPIDeleteApp() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
application, err := h.appService.GetApp(request.Context(), appID)
if err != nil {
h.respondJSON(writer, request,
map[string]string{"error": "internal server error"},
http.StatusInternalServerError)
return
}
if application == nil {
h.respondJSON(writer, request,
map[string]string{"error": "app not found"},
http.StatusNotFound)
return
}
deleteErr := h.appService.DeleteApp(request.Context(), application)
if deleteErr != nil {
h.log.Error("api: failed to delete app", "error", deleteErr)
h.respondJSON(writer, request,
map[string]string{"error": "failed to delete app"},
http.StatusInternalServerError)
return
}
h.respondJSON(writer, request,
map[string]string{"status": "deleted"}, http.StatusOK)
}
}
// deploymentsPageLimit is the default number of deployments per page.
const deploymentsPageLimit = 20
// HandleAPIListDeployments returns a handler that lists deployments for an app.
func (h *Handlers) HandleAPIListDeployments() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
application, err := h.appService.GetApp(request.Context(), appID)
if err != nil || application == nil {
h.respondJSON(writer, request,
map[string]string{"error": "app not found"},
http.StatusNotFound)
return
}
limit := deploymentsPageLimit
if l := request.URL.Query().Get("limit"); l != "" {
parsed, parseErr := strconv.Atoi(l)
if parseErr == nil && parsed > 0 {
limit = parsed
}
}
deployments, deployErr := application.GetDeployments(
request.Context(), limit,
)
if deployErr != nil {
h.respondJSON(writer, request,
map[string]string{"error": "failed to list deployments"},
http.StatusInternalServerError)
return
}
result := make([]apiDeploymentResponse, 0, len(deployments))
for _, d := range deployments {
result = append(result, deploymentToAPI(d))
}
h.respondJSON(writer, request, result, http.StatusOK)
}
}
// HandleAPITriggerDeploy returns a handler that triggers a deployment for an app.
func (h *Handlers) HandleAPITriggerDeploy() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
application, err := h.appService.GetApp(request.Context(), appID)
if err != nil || application == nil {
h.respondJSON(writer, request,
map[string]string{"error": "app not found"},
http.StatusNotFound)
return
}
deployErr := h.deploy.Deploy(request.Context(), application, nil, true)
if deployErr != nil {
h.log.Error("api: failed to trigger deploy", "error", deployErr)
h.respondJSON(writer, request,
map[string]string{"error": deployErr.Error()},
http.StatusConflict)
return
}
h.respondJSON(writer, request,
map[string]string{"status": "deploying"}, http.StatusAccepted)
}
}
// HandleAPIWhoAmI returns a handler that shows the current authenticated user.
func (h *Handlers) HandleAPIWhoAmI() http.HandlerFunc {
type whoAmIResponse struct {
UserID int64 `json:"userId"`
Username string `json:"username"`
}
return func(writer http.ResponseWriter, request *http.Request) {
user, err := h.auth.GetCurrentUser(request.Context(), request)
if err != nil || user == nil {
h.respondJSON(writer, request,
map[string]string{"error": "unauthorized"},
http.StatusUnauthorized)
return
}
h.respondJSON(writer, request, whoAmIResponse{
UserID: user.ID,
Username: user.Username,
}, http.StatusOK)
}
}

View File

@@ -0,0 +1,299 @@
package handlers_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// apiRouter builds a chi router with the API routes using session auth middleware.
func apiRouter(tc *testContext) http.Handler {
r := chi.NewRouter()
r.Route("/api/v1", func(apiR chi.Router) {
apiR.Post("/login", tc.handlers.HandleAPILoginPOST())
apiR.Group(func(apiR chi.Router) {
apiR.Use(tc.middleware.APISessionAuth())
apiR.Get("/whoami", tc.handlers.HandleAPIWhoAmI())
apiR.Get("/apps", tc.handlers.HandleAPIListApps())
apiR.Post("/apps", tc.handlers.HandleAPICreateApp())
apiR.Get("/apps/{id}", tc.handlers.HandleAPIGetApp())
apiR.Delete("/apps/{id}", tc.handlers.HandleAPIDeleteApp())
apiR.Post("/apps/{id}/deploy", tc.handlers.HandleAPITriggerDeploy())
apiR.Get("/apps/{id}/deployments", tc.handlers.HandleAPIListDeployments())
})
})
return r
}
// setupAPITest creates a test context with a user and returns session cookies.
func setupAPITest(t *testing.T) (*testContext, []*http.Cookie) {
t.Helper()
tc := setupTestHandlers(t)
// Create a user.
_, err := tc.authSvc.CreateUser(t.Context(), "admin", "password123")
require.NoError(t, err)
// Login via the API to get session cookies.
r := apiRouter(tc)
loginBody := `{"username":"admin","password":"password123"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(loginBody))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Code)
cookies := rr.Result().Cookies()
require.NotEmpty(t, cookies, "login should return session cookies")
return tc, cookies
}
// apiRequest makes an authenticated API request using session cookies.
func apiRequest(
t *testing.T,
tc *testContext,
cookies []*http.Cookie,
method, path string,
body string,
) *httptest.ResponseRecorder {
t.Helper()
var req *http.Request
if body != "" {
req = httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(method, path, nil)
}
for _, c := range cookies {
req.AddCookie(c)
}
rr := httptest.NewRecorder()
r := apiRouter(tc)
r.ServeHTTP(rr, req)
return rr
}
func TestAPILoginSuccess(t *testing.T) {
t.Parallel()
tc := setupTestHandlers(t)
_, err := tc.authSvc.CreateUser(t.Context(), "admin", "password123")
require.NoError(t, err)
r := apiRouter(tc)
body := `{"username":"admin","password":"password123"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
var resp map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
assert.Equal(t, "admin", resp["username"])
// Should have a Set-Cookie header.
assert.NotEmpty(t, rr.Result().Cookies())
}
func TestAPILoginInvalidCredentials(t *testing.T) {
t.Parallel()
tc := setupTestHandlers(t)
_, err := tc.authSvc.CreateUser(t.Context(), "admin", "password123")
require.NoError(t, err)
r := apiRouter(tc)
body := `{"username":"admin","password":"wrong"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
assert.Equal(t, http.StatusUnauthorized, rr.Code)
}
func TestAPILoginMissingFields(t *testing.T) {
t.Parallel()
tc := setupTestHandlers(t)
r := apiRouter(tc)
body := `{"username":"","password":""}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
assert.Equal(t, http.StatusBadRequest, rr.Code)
}
func TestAPIRejectsUnauthenticated(t *testing.T) {
t.Parallel()
tc := setupTestHandlers(t)
r := apiRouter(tc)
req := httptest.NewRequest(http.MethodGet, "/api/v1/apps", nil)
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
assert.Equal(t, http.StatusUnauthorized, rr.Code)
}
func TestAPIWhoAmI(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
rr := apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/whoami", "")
assert.Equal(t, http.StatusOK, rr.Code)
var resp map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
assert.Equal(t, "admin", resp["username"])
}
func TestAPIListAppsEmpty(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
rr := apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/apps", "")
assert.Equal(t, http.StatusOK, rr.Code)
var apps []any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &apps))
assert.Empty(t, apps)
}
func TestAPICreateApp(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
body := `{"name":"test-app","repoUrl":"https://github.com/example/repo"}`
rr := apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps", body)
assert.Equal(t, http.StatusCreated, rr.Code)
var app map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &app))
assert.Equal(t, "test-app", app["name"])
assert.Equal(t, "pending", app["status"])
}
func TestAPICreateAppValidation(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
body := `{"name":"","repoUrl":""}`
rr := apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps", body)
assert.Equal(t, http.StatusBadRequest, rr.Code)
}
func TestAPIGetApp(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
body := `{"name":"my-app","repoUrl":"https://github.com/example/repo"}`
rr := apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps", body)
require.Equal(t, http.StatusCreated, rr.Code)
var created map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &created))
appID, ok := created["id"].(string)
require.True(t, ok)
rr = apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/apps/"+appID, "")
assert.Equal(t, http.StatusOK, rr.Code)
var app map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &app))
assert.Equal(t, "my-app", app["name"])
}
func TestAPIGetAppNotFound(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
rr := apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/apps/nonexistent", "")
assert.Equal(t, http.StatusNotFound, rr.Code)
}
func TestAPIDeleteApp(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
body := `{"name":"delete-me","repoUrl":"https://github.com/example/repo"}`
rr := apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps", body)
require.Equal(t, http.StatusCreated, rr.Code)
var created map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &created))
appID, ok := created["id"].(string)
require.True(t, ok)
rr = apiRequest(t, tc, cookies, http.MethodDelete, "/api/v1/apps/"+appID, "")
assert.Equal(t, http.StatusOK, rr.Code)
rr = apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/apps/"+appID, "")
assert.Equal(t, http.StatusNotFound, rr.Code)
}
func TestAPIListDeployments(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
body := `{"name":"deploy-app","repoUrl":"https://github.com/example/repo"}`
rr := apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps", body)
require.Equal(t, http.StatusCreated, rr.Code)
var created map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &created))
appID, ok := created["id"].(string)
require.True(t, ok)
rr = apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/apps/"+appID+"/deployments", "")
assert.Equal(t, http.StatusOK, rr.Code)
var deployments []any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &deployments))
assert.Empty(t, deployments)
}

View File

@@ -0,0 +1,293 @@
package handlers_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// tokenRouter builds a chi router with token + app API routes.
func tokenRouter(tc *testContext) http.Handler {
r := chi.NewRouter()
r.Route("/api/v1", func(apiR chi.Router) {
apiR.Post("/login", tc.handlers.HandleAPILoginPOST())
apiR.Group(func(apiR chi.Router) {
apiR.Use(tc.middleware.APISessionAuth())
apiR.Get("/whoami", tc.handlers.HandleAPIWhoAmI())
apiR.Post("/tokens", tc.handlers.HandleAPICreateToken())
apiR.Get("/tokens", tc.handlers.HandleAPIListTokens())
apiR.Delete(
"/tokens/{tokenID}",
tc.handlers.HandleAPIDeleteToken(),
)
apiR.Get("/apps", tc.handlers.HandleAPIListApps())
})
})
return r
}
func TestAPICreateToken(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
r := tokenRouter(tc)
body := `{"name":"my-ci-token"}`
req := httptest.NewRequest(
http.MethodPost, "/api/v1/tokens",
strings.NewReader(body),
)
req.Header.Set("Content-Type", "application/json")
for _, c := range cookies {
req.AddCookie(c)
}
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
assert.Equal(t, http.StatusCreated, rr.Code)
var resp map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
assert.Equal(t, "my-ci-token", resp["name"])
assert.Contains(t, resp["token"], "upaas_")
assert.NotEmpty(t, resp["id"])
}
func TestAPICreateTokenMissingName(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
r := tokenRouter(tc)
body := `{"name":""}`
req := httptest.NewRequest(
http.MethodPost, "/api/v1/tokens",
strings.NewReader(body),
)
req.Header.Set("Content-Type", "application/json")
for _, c := range cookies {
req.AddCookie(c)
}
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
assert.Equal(t, http.StatusBadRequest, rr.Code)
}
func TestAPIListTokens(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
r := tokenRouter(tc)
// Create two tokens.
for _, name := range []string{"token-a", "token-b"} {
body := `{"name":"` + name + `"}`
req := httptest.NewRequest(
http.MethodPost, "/api/v1/tokens",
strings.NewReader(body),
)
req.Header.Set("Content-Type", "application/json")
for _, c := range cookies {
req.AddCookie(c)
}
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
require.Equal(t, http.StatusCreated, rr.Code)
}
// List tokens.
req := httptest.NewRequest(http.MethodGet, "/api/v1/tokens", nil)
for _, c := range cookies {
req.AddCookie(c)
}
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
var tokens []map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &tokens))
assert.Len(t, tokens, 2)
// Plaintext token must NOT appear in list.
for _, tok := range tokens {
assert.Nil(t, tok["token"])
}
}
func TestAPIDeleteToken(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
r := tokenRouter(tc)
// Create a token.
body := `{"name":"delete-me"}`
req := httptest.NewRequest(
http.MethodPost, "/api/v1/tokens",
strings.NewReader(body),
)
req.Header.Set("Content-Type", "application/json")
for _, c := range cookies {
req.AddCookie(c)
}
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
require.Equal(t, http.StatusCreated, rr.Code)
var created map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &created))
tokenID, ok := created["id"].(string)
require.True(t, ok)
// Delete it.
req = httptest.NewRequest(
http.MethodDelete, "/api/v1/tokens/"+tokenID, nil,
)
for _, c := range cookies {
req.AddCookie(c)
}
rr = httptest.NewRecorder()
r.ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
// List should be empty.
req = httptest.NewRequest(http.MethodGet, "/api/v1/tokens", nil)
for _, c := range cookies {
req.AddCookie(c)
}
rr = httptest.NewRecorder()
r.ServeHTTP(rr, req)
var tokens []map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &tokens))
assert.Empty(t, tokens)
}
func TestAPIBearerTokenAuth(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
r := tokenRouter(tc)
// Create a token via session auth.
body := `{"name":"bearer-test"}`
req := httptest.NewRequest(
http.MethodPost, "/api/v1/tokens",
strings.NewReader(body),
)
req.Header.Set("Content-Type", "application/json")
for _, c := range cookies {
req.AddCookie(c)
}
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
require.Equal(t, http.StatusCreated, rr.Code)
var created map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &created))
plaintext, ok := created["token"].(string)
require.True(t, ok)
// Use Bearer token to access an authenticated endpoint.
req = httptest.NewRequest(http.MethodGet, "/api/v1/apps", nil)
req.Header.Set("Authorization", "Bearer "+plaintext)
rr = httptest.NewRecorder()
r.ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
}
func TestAPIBearerTokenInvalid(t *testing.T) {
t.Parallel()
tc := setupTestHandlers(t)
r := tokenRouter(tc)
req := httptest.NewRequest(http.MethodGet, "/api/v1/apps", nil)
req.Header.Set("Authorization", "Bearer upaas_invalidtoken1234567890ab")
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
assert.Equal(t, http.StatusUnauthorized, rr.Code)
}
func TestAPIBearerTokenRevoked(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
r := tokenRouter(tc)
// Create then delete a token.
body := `{"name":"revoke-test"}`
req := httptest.NewRequest(
http.MethodPost, "/api/v1/tokens",
strings.NewReader(body),
)
req.Header.Set("Content-Type", "application/json")
for _, c := range cookies {
req.AddCookie(c)
}
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
require.Equal(t, http.StatusCreated, rr.Code)
var created map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &created))
plaintext, ok := created["token"].(string)
require.True(t, ok)
tokenID, ok := created["id"].(string)
require.True(t, ok)
// Delete (revoke) the token.
req = httptest.NewRequest(
http.MethodDelete, "/api/v1/tokens/"+tokenID, nil,
)
for _, c := range cookies {
req.AddCookie(c)
}
rr = httptest.NewRecorder()
r.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Code)
// Try to use the revoked token.
req = httptest.NewRequest(http.MethodGet, "/api/v1/apps", nil)
req.Header.Set("Authorization", "Bearer "+plaintext)
rr = httptest.NewRecorder()
r.ServeHTTP(rr, req)
assert.Equal(t, http.StatusUnauthorized, rr.Code)
}

View File

@@ -0,0 +1,220 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"net/http"
"github.com/go-chi/chi/v5"
"git.eeqj.de/sneak/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/models"
)
// apiTokenResponse is the JSON representation of an API token.
type apiTokenResponse struct {
ID string `json:"id"`
Name string `json:"name"`
CreatedAt string `json:"createdAt"`
ExpiresAt *string `json:"expiresAt,omitempty"`
LastUsedAt *string `json:"lastUsedAt,omitempty"`
}
// apiTokenCreateResponse includes the plaintext token (shown once).
type apiTokenCreateResponse struct {
apiTokenResponse
Token string `json:"token"`
}
func tokenToAPI(t *models.APIToken) apiTokenResponse {
resp := apiTokenResponse{
ID: t.ID,
Name: t.Name,
CreatedAt: t.CreatedAt.Format("2006-01-02T15:04:05Z"),
}
if t.ExpiresAt.Valid {
s := t.ExpiresAt.Time.Format("2006-01-02T15:04:05Z")
resp.ExpiresAt = &s
}
if t.LastUsedAt.Valid {
s := t.LastUsedAt.Time.Format("2006-01-02T15:04:05Z")
resp.LastUsedAt = &s
}
return resp
}
// createTokenRequest is the JSON body for token creation.
type createTokenRequest struct {
Name string `json:"name"`
}
// createAndSaveToken generates a token, saves it, and returns
// the plaintext and model.
func (h *Handlers) createAndSaveToken(
ctx context.Context,
userID int64,
name string,
) (string, *models.APIToken, error) {
plaintext, err := models.GenerateToken()
if err != nil {
return "", nil, fmt.Errorf("generating: %w", err)
}
token := models.NewAPIToken(h.db)
token.UserID = userID
token.Name = name
token.TokenHash = database.HashAPIToken(plaintext)
saveErr := token.Save(ctx)
if saveErr != nil {
return "", nil, fmt.Errorf("saving: %w", saveErr)
}
return plaintext, token, nil
}
// HandleAPICreateToken returns a handler that creates an API token.
func (h *Handlers) HandleAPICreateToken() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
user, err := h.auth.GetCurrentUser(
request.Context(), request,
)
if err != nil || user == nil {
h.respondJSON(writer, request,
map[string]string{"error": "unauthorized"},
http.StatusUnauthorized)
return
}
var req createTokenRequest
decodeErr := json.NewDecoder(request.Body).Decode(&req)
if decodeErr != nil {
h.respondJSON(writer, request,
map[string]string{"error": "invalid JSON body"},
http.StatusBadRequest)
return
}
if req.Name == "" {
h.respondJSON(writer, request,
map[string]string{"error": "name is required"},
http.StatusBadRequest)
return
}
plaintext, token, createErr := h.createAndSaveToken(
request.Context(), user.ID, req.Name,
)
if createErr != nil {
h.log.Error("api: token creation failed",
"error", createErr)
h.respondJSON(writer, request,
map[string]string{"error": "internal error"},
http.StatusInternalServerError)
return
}
h.respondJSON(writer, request, apiTokenCreateResponse{
apiTokenResponse: tokenToAPI(token),
Token: plaintext,
}, http.StatusCreated)
}
}
// HandleAPIListTokens returns a handler that lists API tokens.
func (h *Handlers) HandleAPIListTokens() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
user, err := h.auth.GetCurrentUser(
request.Context(), request,
)
if err != nil || user == nil {
h.respondJSON(writer, request,
map[string]string{"error": "unauthorized"},
http.StatusUnauthorized)
return
}
tokens, listErr := models.ListAPITokensByUser(
request.Context(), h.db, user.ID,
)
if listErr != nil {
h.log.Error("api: failed to list tokens",
"error", listErr)
h.respondJSON(writer, request,
map[string]string{"error": "internal error"},
http.StatusInternalServerError)
return
}
result := make([]apiTokenResponse, 0, len(tokens))
for _, t := range tokens {
result = append(result, tokenToAPI(t))
}
h.respondJSON(writer, request, result, http.StatusOK)
}
}
// HandleAPIDeleteToken returns a handler that revokes an API token.
func (h *Handlers) HandleAPIDeleteToken() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
user, err := h.auth.GetCurrentUser(
request.Context(), request,
)
if err != nil || user == nil {
h.respondJSON(writer, request,
map[string]string{"error": "unauthorized"},
http.StatusUnauthorized)
return
}
tokenID := chi.URLParam(request, "tokenID")
token, findErr := models.FindAPIToken(
request.Context(), h.db, tokenID,
)
if findErr != nil {
h.respondJSON(writer, request,
map[string]string{"error": "internal error"},
http.StatusInternalServerError)
return
}
if token == nil || token.UserID != user.ID {
h.respondJSON(writer, request,
map[string]string{"error": "token not found"},
http.StatusNotFound)
return
}
deleteErr := token.Delete(request.Context())
if deleteErr != nil {
h.log.Error("api: failed to delete token",
"error", deleteErr)
h.respondJSON(writer, request,
map[string]string{"error": "internal error"},
http.StatusInternalServerError)
return
}
h.respondJSON(writer, request,
map[string]string{"status": "deleted"},
http.StatusOK)
}
}

View File

@@ -24,6 +24,7 @@ import (
"git.eeqj.de/sneak/upaas/internal/handlers"
"git.eeqj.de/sneak/upaas/internal/healthcheck"
"git.eeqj.de/sneak/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/middleware"
"git.eeqj.de/sneak/upaas/internal/service/app"
"git.eeqj.de/sneak/upaas/internal/service/auth"
"git.eeqj.de/sneak/upaas/internal/service/deploy"
@@ -32,10 +33,11 @@ import (
)
type testContext struct {
handlers *handlers.Handlers
database *database.Database
authSvc *auth.Service
appSvc *app.Service
handlers *handlers.Handlers
database *database.Database
authSvc *auth.Service
appSvc *app.Service
middleware *middleware.Middleware
}
func createTestConfig(t *testing.T) *config.Config {
@@ -166,11 +168,21 @@ func setupTestHandlers(t *testing.T) *testContext {
)
require.NoError(t, handlerErr)
mw, mwErr := middleware.New(fx.Lifecycle(nil), middleware.Params{
Logger: logInstance,
Globals: globalInstance,
Config: cfg,
Auth: authSvc,
Database: dbInstance,
})
require.NoError(t, mwErr)
return &testContext{
handlers: handlersInstance,
database: dbInstance,
authSvc: authSvc,
appSvc: appSvc,
handlers: handlersInstance,
database: dbInstance,
authSvc: authSvc,
appSvc: appSvc,
middleware: mw,
}
}

View File

@@ -0,0 +1,160 @@
package middleware_test
import (
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/globals"
"git.eeqj.de/sneak/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/middleware"
"git.eeqj.de/sneak/upaas/internal/models"
"git.eeqj.de/sneak/upaas/internal/service/auth"
)
// setupMiddleware creates a Middleware with a real SQLite database for
// integration testing.
func setupMiddleware(t *testing.T) (*middleware.Middleware, *auth.Service, *database.Database) {
t.Helper()
tmpDir := t.TempDir()
globals.SetAppname("upaas-test")
globals.SetVersion("test")
globalsInst, err := globals.New(fx.Lifecycle(nil))
require.NoError(t, err)
loggerInst, err := logger.New(
fx.Lifecycle(nil),
logger.Params{Globals: globalsInst},
)
require.NoError(t, err)
cfg := &config.Config{
Port: 8080,
DataDir: tmpDir,
SessionSecret: "test-secret-key-at-least-32-chars!!",
}
_ = filepath.Join(tmpDir, "upaas.db")
dbInst, err := database.New(fx.Lifecycle(nil), database.Params{
Logger: loggerInst,
Config: cfg,
})
require.NoError(t, err)
authSvc, err := auth.New(fx.Lifecycle(nil), auth.ServiceParams{
Logger: loggerInst,
Config: cfg,
Database: dbInst,
})
require.NoError(t, err)
mw, err := middleware.New(fx.Lifecycle(nil), middleware.Params{
Logger: loggerInst,
Globals: globalsInst,
Config: cfg,
Auth: authSvc,
Database: dbInst,
})
require.NoError(t, err)
return mw, authSvc, dbInst
}
func TestAPISessionAuth_BearerTokenSetsUserContext(t *testing.T) {
t.Parallel()
mw, authSvc, dbInst := setupMiddleware(t)
ctx := t.Context()
// Create a user.
user, err := authSvc.CreateUser(ctx, "testuser", "password123")
require.NoError(t, err)
require.NotNil(t, user)
// Create an API token for the user.
rawToken, err := models.GenerateToken()
require.NoError(t, err)
tokenHash := database.HashAPIToken(rawToken)
apiToken := models.NewAPIToken(dbInst)
apiToken.UserID = user.ID
apiToken.Name = "test-token"
apiToken.TokenHash = tokenHash
err = apiToken.Save(ctx)
require.NoError(t, err)
// Build a handler behind APISessionAuth that checks user context.
var gotUser *models.User
var getUserErr error
handler := mw.APISessionAuth()(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
gotUser, getUserErr = authSvc.GetCurrentUser(r.Context(), r)
w.WriteHeader(http.StatusOK)
},
))
// Make request with bearer token.
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
req.Header.Set("Authorization", "Bearer "+rawToken)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
require.NoError(t, getUserErr)
require.NotNil(t, gotUser, "GetCurrentUser should return the user for bearer auth")
assert.Equal(t, user.ID, gotUser.ID)
assert.Equal(t, "testuser", gotUser.Username)
}
func TestAPISessionAuth_NoBearerTokenReturns401(t *testing.T) {
t.Parallel()
mw, _, _ := setupMiddleware(t)
handler := mw.APISessionAuth()(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
},
))
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusUnauthorized, rec.Code)
}
func TestAPISessionAuth_InvalidBearerTokenReturns401(t *testing.T) {
t.Parallel()
mw, _, _ := setupMiddleware(t)
handler := mw.APISessionAuth()(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
},
))
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
req.Header.Set("Authorization", "Bearer invalid-token")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusUnauthorized, rec.Code)
}

View File

@@ -0,0 +1,81 @@
package middleware //nolint:testpackage // tests internal CORS behavior
import (
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"git.eeqj.de/sneak/upaas/internal/config"
)
//nolint:gosec // test credentials
func newCORSTestMiddleware(corsOrigins string) *Middleware {
return &Middleware{
log: slog.Default(),
params: &Params{
Config: &config.Config{
CORSOrigins: corsOrigins,
SessionSecret: "test-secret-32-bytes-long-enough",
},
},
}
}
func TestCORS_NoOriginsConfigured_NoCORSHeaders(t *testing.T) {
t.Parallel()
m := newCORSTestMiddleware("")
handler := m.CORS()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Origin", "https://evil.com")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Empty(t, rec.Header().Get("Access-Control-Allow-Origin"),
"expected no CORS headers when no origins configured")
}
func TestCORS_OriginsConfigured_AllowsMatchingOrigin(t *testing.T) {
t.Parallel()
m := newCORSTestMiddleware("https://app.example.com,https://other.example.com")
handler := m.CORS()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Origin", "https://app.example.com")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, "https://app.example.com",
rec.Header().Get("Access-Control-Allow-Origin"))
assert.Equal(t, "true",
rec.Header().Get("Access-Control-Allow-Credentials"))
}
func TestCORS_OriginsConfigured_RejectsNonMatchingOrigin(t *testing.T) {
t.Parallel()
m := newCORSTestMiddleware("https://app.example.com")
handler := m.CORS()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Origin", "https://evil.com")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Empty(t, rec.Header().Get("Access-Control-Allow-Origin"),
"expected no CORS headers for non-matching origin")
}

View File

@@ -19,8 +19,10 @@ import (
"golang.org/x/time/rate"
"git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/globals"
"git.eeqj.de/sneak/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/models"
"git.eeqj.de/sneak/upaas/internal/service/auth"
)
@@ -31,10 +33,11 @@ const corsMaxAge = 300
type Params struct {
fx.In
Logger *logger.Logger
Globals *globals.Globals
Config *config.Config
Auth *auth.Service
Logger *logger.Logger
Globals *globals.Globals
Config *config.Config
Auth *auth.Service
Database *database.Database
}
// Middleware provides HTTP middleware.
@@ -177,17 +180,48 @@ func realIP(r *http.Request) string {
}
// CORS returns CORS middleware.
// When UPAAS_CORS_ORIGINS is empty (default), no CORS headers are sent
// (same-origin only). When configured, only the specified origins are
// allowed and credentials (cookies) are permitted.
func (m *Middleware) CORS() func(http.Handler) http.Handler {
origins := parseCORSOrigins(m.params.Config.CORSOrigins)
// No origins configured — no CORS headers (same-origin policy).
if len(origins) == 0 {
return func(next http.Handler) http.Handler {
return next
}
}
return cors.Handler(cors.Options{
AllowedOrigins: []string{"*"},
AllowedOrigins: origins,
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
ExposedHeaders: []string{"Link"},
AllowCredentials: false,
AllowCredentials: true,
MaxAge: corsMaxAge,
})
}
// parseCORSOrigins splits a comma-separated origin string into a slice,
// trimming whitespace. Returns nil if the input is empty.
func parseCORSOrigins(raw string) []string {
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
origins := make([]string, 0, len(parts))
for _, p := range parts {
if o := strings.TrimSpace(p); o != "" {
origins = append(origins, o)
}
}
return origins
}
// MetricsAuth returns basic auth middleware for metrics endpoint.
func (m *Middleware) MetricsAuth() func(http.Handler) http.Handler {
if m.params.Config.MetricsUsername == "" {
@@ -235,9 +269,9 @@ func (m *Middleware) CSRF() func(http.Handler) http.Handler {
// loginRateLimit configures the login rate limiter.
const (
loginRateLimit = rate.Limit(5.0 / 60.0) // 5 requests per 60 seconds
loginBurst = 5 // allow burst of 5
limiterExpiry = 10 * time.Minute // evict entries not seen in 10 minutes
limiterCleanupEvery = 1 * time.Minute // sweep interval
loginBurst = 5 // allow burst of 5
limiterExpiry = 10 * time.Minute // evict entries not seen in 10 minutes
limiterCleanupEvery = 1 * time.Minute // sweep interval
)
// ipLimiterEntry stores a rate limiter with its last-seen timestamp.
@@ -249,8 +283,8 @@ type ipLimiterEntry struct {
// ipLimiter tracks per-IP rate limiters for login attempts with automatic
// eviction of stale entries to prevent unbounded memory growth.
type ipLimiter struct {
mu sync.Mutex
limiters map[string]*ipLimiterEntry
mu sync.Mutex
limiters map[string]*ipLimiterEntry
lastSweep time.Time
}
@@ -339,6 +373,45 @@ func (m *Middleware) LoginRateLimit() func(http.Handler) http.Handler {
}
}
// bearerPrefix is the expected prefix for Authorization headers.
const bearerPrefix = "Bearer "
// APISessionAuth returns middleware that requires authentication
// for API routes. It checks Bearer token first, then falls back
// to session cookie. Returns JSON 401 on failure.
func (m *Middleware) APISessionAuth() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(
writer http.ResponseWriter,
request *http.Request,
) {
// Try Bearer token first.
if authedReq, ok := m.tryBearerAuth(request); ok {
next.ServeHTTP(writer, authedReq)
return
}
// Fall back to session cookie.
user, err := m.params.Auth.GetCurrentUser(
request.Context(), request,
)
if err != nil || user == nil {
writer.Header().Set("Content-Type", "application/json")
http.Error(
writer,
`{"error":"unauthorized"}`,
http.StatusUnauthorized,
)
return
}
next.ServeHTTP(writer, request)
})
}
}
// SetupRequired returns middleware that redirects to setup if no user exists.
func (m *Middleware) SetupRequired() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
@@ -382,3 +455,49 @@ func (m *Middleware) SetupRequired() func(http.Handler) http.Handler {
})
}
}
// tryBearerAuth checks for a valid Bearer token in the
// Authorization header. On success it returns a new request
// with the authenticated user set on the context.
func (m *Middleware) tryBearerAuth(
request *http.Request,
) (*http.Request, bool) {
authHeader := request.Header.Get("Authorization")
if !strings.HasPrefix(authHeader, bearerPrefix) {
return request, false
}
rawToken := strings.TrimPrefix(authHeader, bearerPrefix)
if rawToken == "" {
return request, false
}
tokenHash := database.HashAPIToken(rawToken)
apiToken, err := models.FindAPITokenByHash(
request.Context(), m.params.Database, tokenHash,
)
if err != nil || apiToken == nil {
return request, false
}
if apiToken.IsExpired() {
return request, false
}
// Look up the user associated with the token.
user, err := models.FindUser(
request.Context(), m.params.Database, apiToken.UserID,
)
if err != nil || user == nil {
return request, false
}
// Update last_used_at (best effort).
_ = apiToken.TouchLastUsed(request.Context())
// Set the authenticated user on the request context.
ctx := auth.ContextWithUser(request.Context(), user)
return request.WithContext(ctx), true
}

View File

@@ -0,0 +1,206 @@
package models
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"time"
"github.com/oklog/ulid/v2"
"git.eeqj.de/sneak/upaas/internal/database"
)
// tokenRandomBytes is the number of random bytes for token generation.
const tokenRandomBytes = 32
// tokenPrefix is prepended to generated API tokens.
const tokenPrefix = "upaas_"
// APIToken represents an API authentication token.
type APIToken struct {
db *database.Database
ID string
UserID int64
Name string
TokenHash string
CreatedAt time.Time
ExpiresAt sql.NullTime
LastUsedAt sql.NullTime
}
// NewAPIToken creates a new APIToken with a database reference.
func NewAPIToken(db *database.Database) *APIToken {
return &APIToken{db: db}
}
// GenerateToken generates a random API token string.
func GenerateToken() (string, error) {
b := make([]byte, tokenRandomBytes)
_, err := rand.Read(b)
if err != nil {
return "", fmt.Errorf("generating token: %w", err)
}
return tokenPrefix + hex.EncodeToString(b), nil
}
// Save inserts the API token into the database.
func (t *APIToken) Save(ctx context.Context) error {
if t.ID == "" {
t.ID = ulid.Make().String()
}
query := `INSERT INTO api_tokens
(id, user_id, name, token_hash, expires_at)
VALUES (?, ?, ?, ?, ?)`
_, err := t.db.Exec(
ctx, query,
t.ID, t.UserID, t.Name, t.TokenHash, t.ExpiresAt,
)
if err != nil {
return fmt.Errorf("inserting api token: %w", err)
}
return t.Reload(ctx)
}
// Reload refreshes the token from the database.
func (t *APIToken) Reload(ctx context.Context) error {
row := t.db.QueryRow(ctx,
`SELECT id, user_id, name, token_hash,
created_at, expires_at, last_used_at
FROM api_tokens WHERE id = ?`, t.ID)
return t.scan(row)
}
// Delete removes the token from the database.
func (t *APIToken) Delete(ctx context.Context) error {
_, err := t.db.Exec(ctx,
"DELETE FROM api_tokens WHERE id = ?", t.ID)
return err
}
// TouchLastUsed updates the last_used_at timestamp.
func (t *APIToken) TouchLastUsed(ctx context.Context) error {
_, err := t.db.Exec(ctx,
"UPDATE api_tokens SET last_used_at = ? WHERE id = ?",
time.Now().UTC(), t.ID)
return err
}
// IsExpired reports whether the token has expired.
func (t *APIToken) IsExpired() bool {
return t.ExpiresAt.Valid && t.ExpiresAt.Time.Before(time.Now())
}
func (t *APIToken) scan(row *sql.Row) error {
return row.Scan(
&t.ID, &t.UserID, &t.Name, &t.TokenHash,
&t.CreatedAt, &t.ExpiresAt, &t.LastUsedAt,
)
}
// FindAPITokenByHash finds a token by its hash.
//
//nolint:nilnil // nil,nil is idiomatic for "not found"
func FindAPITokenByHash(
ctx context.Context,
db *database.Database,
hash string,
) (*APIToken, error) {
token := NewAPIToken(db)
row := db.QueryRow(ctx,
`SELECT id, user_id, name, token_hash,
created_at, expires_at, last_used_at
FROM api_tokens WHERE token_hash = ?`, hash)
err := token.scan(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("finding api token by hash: %w", err)
}
return token, nil
}
// FindAPIToken finds a token by ID.
//
//nolint:nilnil // nil,nil is idiomatic for "not found"
func FindAPIToken(
ctx context.Context,
db *database.Database,
id string,
) (*APIToken, error) {
token := NewAPIToken(db)
row := db.QueryRow(ctx,
`SELECT id, user_id, name, token_hash,
created_at, expires_at, last_used_at
FROM api_tokens WHERE id = ?`, id)
err := token.scan(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("finding api token: %w", err)
}
return token, nil
}
// ListAPITokensByUser returns all tokens for a user.
func ListAPITokensByUser(
ctx context.Context,
db *database.Database,
userID int64,
) ([]*APIToken, error) {
rows, err := db.Query(ctx,
`SELECT id, user_id, name, token_hash,
created_at, expires_at, last_used_at
FROM api_tokens WHERE user_id = ?
ORDER BY created_at DESC`, userID)
if err != nil {
return nil, fmt.Errorf("listing api tokens: %w", err)
}
defer func() { _ = rows.Close() }()
var tokens []*APIToken
for rows.Next() {
t := NewAPIToken(db)
scanErr := rows.Scan(
&t.ID, &t.UserID, &t.Name, &t.TokenHash,
&t.CreatedAt, &t.ExpiresAt, &t.LastUsedAt,
)
if scanErr != nil {
return nil, fmt.Errorf("scanning api token: %w", scanErr)
}
tokens = append(tokens, t)
}
rowsErr := rows.Err()
if rowsErr != nil {
return nil, fmt.Errorf("iterating api tokens: %w", rowsErr)
}
return tokens, nil
}

View File

@@ -32,23 +32,23 @@ const (
type App struct {
db *database.Database
ID string
Name string
RepoURL string
Branch string
DockerfilePath string
ID string
Name string
RepoURL string
Branch string
DockerfilePath string
WebhookSecret string
WebhookSecretHash string
SSHPrivateKey string
SSHPublicKey string
ImageID sql.NullString
PreviousImageID sql.NullString
Status AppStatus
DockerNetwork sql.NullString
NtfyTopic sql.NullString
SlackWebhook sql.NullString
CreatedAt time.Time
UpdatedAt time.Time
SSHPublicKey string
ImageID sql.NullString
PreviousImageID sql.NullString
Status AppStatus
DockerNetwork sql.NullString
NtfyTopic sql.NullString
SlackWebhook sql.NullString
CreatedAt time.Time
UpdatedAt time.Time
}
// NewApp creates a new App with a database reference.

View File

@@ -54,51 +54,76 @@ func (s *Server) SetupRoutes() {
r.Group(func(r chi.Router) {
r.Use(s.mw.SessionAuth())
// Dashboard
r.Get("/", s.handlers.HandleDashboard())
// Dashboard
r.Get("/", s.handlers.HandleDashboard())
// Logout
r.Post("/logout", s.handlers.HandleLogout())
// Logout
r.Post("/logout", s.handlers.HandleLogout())
// App routes
r.Get("/apps/new", s.handlers.HandleAppNew())
r.Post("/apps", s.handlers.HandleAppCreate())
r.Get("/apps/{id}", s.handlers.HandleAppDetail())
r.Get("/apps/{id}/edit", s.handlers.HandleAppEdit())
r.Post("/apps/{id}", s.handlers.HandleAppUpdate())
r.Post("/apps/{id}/delete", s.handlers.HandleAppDelete())
r.Post("/apps/{id}/deploy", s.handlers.HandleAppDeploy())
r.Post("/apps/{id}/deployments/cancel", s.handlers.HandleCancelDeploy())
r.Get("/apps/{id}/deployments", s.handlers.HandleAppDeployments())
r.Get("/apps/{id}/deployments/{deploymentID}/logs", s.handlers.HandleDeploymentLogsAPI())
r.Get("/apps/{id}/deployments/{deploymentID}/download", s.handlers.HandleDeploymentLogDownload())
r.Get("/apps/{id}/logs", s.handlers.HandleAppLogs())
r.Get("/apps/{id}/container-logs", s.handlers.HandleContainerLogsAPI())
r.Get("/apps/{id}/status", s.handlers.HandleAppStatusAPI())
r.Get("/apps/{id}/recent-deployments", s.handlers.HandleRecentDeploymentsAPI())
r.Post("/apps/{id}/rollback", s.handlers.HandleAppRollback())
r.Post("/apps/{id}/restart", s.handlers.HandleAppRestart())
r.Post("/apps/{id}/stop", s.handlers.HandleAppStop())
r.Post("/apps/{id}/start", s.handlers.HandleAppStart())
// App routes
r.Get("/apps/new", s.handlers.HandleAppNew())
r.Post("/apps", s.handlers.HandleAppCreate())
r.Get("/apps/{id}", s.handlers.HandleAppDetail())
r.Get("/apps/{id}/edit", s.handlers.HandleAppEdit())
r.Post("/apps/{id}", s.handlers.HandleAppUpdate())
r.Post("/apps/{id}/delete", s.handlers.HandleAppDelete())
r.Post("/apps/{id}/deploy", s.handlers.HandleAppDeploy())
r.Post("/apps/{id}/deployments/cancel", s.handlers.HandleCancelDeploy())
r.Get("/apps/{id}/deployments", s.handlers.HandleAppDeployments())
r.Get("/apps/{id}/deployments/{deploymentID}/logs", s.handlers.HandleDeploymentLogsAPI())
r.Get("/apps/{id}/deployments/{deploymentID}/download", s.handlers.HandleDeploymentLogDownload())
r.Get("/apps/{id}/logs", s.handlers.HandleAppLogs())
r.Get("/apps/{id}/container-logs", s.handlers.HandleContainerLogsAPI())
r.Get("/apps/{id}/status", s.handlers.HandleAppStatusAPI())
r.Get("/apps/{id}/recent-deployments", s.handlers.HandleRecentDeploymentsAPI())
r.Post("/apps/{id}/rollback", s.handlers.HandleAppRollback())
r.Post("/apps/{id}/restart", s.handlers.HandleAppRestart())
r.Post("/apps/{id}/stop", s.handlers.HandleAppStop())
r.Post("/apps/{id}/start", s.handlers.HandleAppStart())
// Environment variables
r.Post("/apps/{id}/env-vars", s.handlers.HandleEnvVarAdd())
r.Post("/apps/{id}/env-vars/{varID}/edit", s.handlers.HandleEnvVarEdit())
r.Post("/apps/{id}/env-vars/{varID}/delete", s.handlers.HandleEnvVarDelete())
// Environment variables
r.Post("/apps/{id}/env-vars", s.handlers.HandleEnvVarAdd())
r.Post("/apps/{id}/env-vars/{varID}/edit", s.handlers.HandleEnvVarEdit())
r.Post("/apps/{id}/env-vars/{varID}/delete", s.handlers.HandleEnvVarDelete())
// Labels
r.Post("/apps/{id}/labels", s.handlers.HandleLabelAdd())
r.Post("/apps/{id}/labels/{labelID}/edit", s.handlers.HandleLabelEdit())
r.Post("/apps/{id}/labels/{labelID}/delete", s.handlers.HandleLabelDelete())
// Labels
r.Post("/apps/{id}/labels", s.handlers.HandleLabelAdd())
r.Post("/apps/{id}/labels/{labelID}/edit", s.handlers.HandleLabelEdit())
r.Post("/apps/{id}/labels/{labelID}/delete", s.handlers.HandleLabelDelete())
// Volumes
r.Post("/apps/{id}/volumes", s.handlers.HandleVolumeAdd())
r.Post("/apps/{id}/volumes/{volumeID}/edit", s.handlers.HandleVolumeEdit())
r.Post("/apps/{id}/volumes/{volumeID}/delete", s.handlers.HandleVolumeDelete())
// Volumes
r.Post("/apps/{id}/volumes", s.handlers.HandleVolumeAdd())
r.Post("/apps/{id}/volumes/{volumeID}/edit", s.handlers.HandleVolumeEdit())
r.Post("/apps/{id}/volumes/{volumeID}/delete", s.handlers.HandleVolumeDelete())
// Ports
r.Post("/apps/{id}/ports", s.handlers.HandlePortAdd())
r.Post("/apps/{id}/ports/{portID}/delete", s.handlers.HandlePortDelete())
// Ports
r.Post("/apps/{id}/ports", s.handlers.HandlePortAdd())
r.Post("/apps/{id}/ports/{portID}/delete", s.handlers.HandlePortDelete())
})
})
// API v1 routes (cookie-based session auth, no CSRF)
s.router.Route("/api/v1", func(r chi.Router) {
// Login endpoint is public (returns session cookie)
r.With(s.mw.LoginRateLimit()).Post("/login", s.handlers.HandleAPILoginPOST())
// All other API routes require session auth
r.Group(func(r chi.Router) {
r.Use(s.mw.APISessionAuth())
r.Get("/whoami", s.handlers.HandleAPIWhoAmI())
r.Get("/apps", s.handlers.HandleAPIListApps())
r.Post("/apps", s.handlers.HandleAPICreateApp())
r.Get("/apps/{id}", s.handlers.HandleAPIGetApp())
r.Delete("/apps/{id}", s.handlers.HandleAPIDeleteApp())
r.Post("/apps/{id}/deploy", s.handlers.HandleAPITriggerDeploy())
r.Get("/apps/{id}/deployments", s.handlers.HandleAPIListDeployments())
// API token management
r.Post("/tokens", s.handlers.HandleAPICreateToken())
r.Get("/tokens", s.handlers.HandleAPIListTokens())
r.Delete("/tokens/{tokenID}", s.handlers.HandleAPIDeleteToken())
})
})

View File

@@ -26,6 +26,21 @@ const (
sessionUserID = "user_id"
)
// contextKeyUser is the context key for storing the authenticated user.
type contextKeyUser struct{}
// ContextWithUser returns a new context with the given user attached.
func ContextWithUser(ctx context.Context, user *models.User) context.Context {
return context.WithValue(ctx, contextKeyUser{}, user)
}
// UserFromContext retrieves the user from the context, if set.
func UserFromContext(ctx context.Context) *models.User {
user, _ := ctx.Value(contextKeyUser{}).(*models.User)
return user
}
// Argon2 parameters.
const (
argonTime = 1
@@ -239,6 +254,11 @@ func (svc *Service) GetCurrentUser(
ctx context.Context,
request *http.Request,
) (*models.User, error) {
// Check context first (set by bearer token auth).
if user := UserFromContext(ctx); user != nil {
return user, nil
}
session, sessionErr := svc.store.Get(request, sessionName)
if sessionErr != nil {
// Session error means no user - this is not an error condition

View File

@@ -82,7 +82,7 @@ type deploymentLogWriter struct {
lineBuffer bytes.Buffer // buffer for incomplete lines
mu sync.Mutex
done chan struct{}
flushed sync.WaitGroup // waits for flush goroutine to finish
flushed sync.WaitGroup // waits for flush goroutine to finish
flushCtx context.Context //nolint:containedctx // needed for async flush goroutine
}