From 0536f57ec2ec40ae29e3e4177f2fc91fd8c743c4 Mon Sep 17 00:00:00 2001 From: user Date: Mon, 16 Feb 2026 00:20:41 -0800 Subject: [PATCH 1/3] 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 --- .../migrations/006_add_api_tokens.sql | 11 + internal/handlers/api.go | 372 ++++++++++++++++++ internal/handlers/api_test.go | 254 ++++++++++++ internal/handlers/handlers_test.go | 28 +- internal/middleware/middleware.go | 83 +++- internal/models/api_token.go | 187 +++++++++ internal/server/routes.go | 15 + 7 files changed, 938 insertions(+), 12 deletions(-) create mode 100644 internal/database/migrations/006_add_api_tokens.sql create mode 100644 internal/handlers/api.go create mode 100644 internal/handlers/api_test.go create mode 100644 internal/models/api_token.go diff --git a/internal/database/migrations/006_add_api_tokens.sql b/internal/database/migrations/006_add_api_tokens.sql new file mode 100644 index 0000000..fe36c4e --- /dev/null +++ b/internal/database/migrations/006_add_api_tokens.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS api_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL DEFAULT '', + token_hash TEXT NOT NULL UNIQUE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_used_at DATETIME +); + +CREATE INDEX IF NOT EXISTS idx_api_tokens_token_hash ON api_tokens(token_hash); +CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON api_tokens(user_id); diff --git a/internal/handlers/api.go b/internal/handlers/api.go new file mode 100644 index 0000000..72b6db5 --- /dev/null +++ b/internal/handlers/api.go @@ -0,0 +1,372 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + + "git.eeqj.de/sneak/upaas/internal/middleware" + "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 +} + +// 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) + } +} + +// HandleAPICreateToken returns a handler that creates an API token. +func (h *Handlers) HandleAPICreateToken() http.HandlerFunc { + type createTokenRequest struct { + Name string `json:"name"` + } + + type createTokenResponse struct { + Token string `json:"token"` + Name string `json:"name"` + ID int64 `json:"id"` + } + + return func(writer http.ResponseWriter, request *http.Request) { + user := middleware.APIUserFromContext(request.Context()) + if 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 { + req.Name = "default" + } + + if req.Name == "" { + req.Name = "default" + } + + rawToken, token, err := models.GenerateAPIToken( + request.Context(), h.db, user.ID, req.Name, + ) + if err != nil { + h.log.Error("api: failed to create token", "error", err) + h.respondJSON(writer, request, + map[string]string{"error": "failed to create token"}, + http.StatusInternalServerError) + + return + } + + h.respondJSON(writer, request, createTokenResponse{ + Token: rawToken, + Name: token.Name, + ID: token.ID, + }, http.StatusCreated) + } +} + +// 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 := middleware.APIUserFromContext(request.Context()) + if 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) + } +} diff --git a/internal/handlers/api_test.go b/internal/handlers/api_test.go new file mode 100644 index 0000000..9ff9719 --- /dev/null +++ b/internal/handlers/api_test.go @@ -0,0 +1,254 @@ +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" + + "git.eeqj.de/sneak/upaas/internal/models" +) + +func setupAPITest(t *testing.T) (*testContext, string) { + t.Helper() + + tc := setupTestHandlers(t) + + // Create a user first. + _, err := tc.authSvc.CreateUser(t.Context(), "admin", "password123") + require.NoError(t, err) + + user, err := models.FindUserByUsername(t.Context(), tc.database, "admin") + require.NoError(t, err) + require.NotNil(t, user) + + // Generate an API token. + rawToken, _, err := models.GenerateAPIToken(t.Context(), tc.database, user.ID, "test") + require.NoError(t, err) + + return tc, rawToken +} + +func apiRequest( + t *testing.T, + tc *testContext, + token, 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) + } + + req.Header.Set("Authorization", "Bearer "+token) + + rr := httptest.NewRecorder() + + // Build a chi router with API routes. + r := chi.NewRouter() + mw := tc.middleware + + r.Route("/api/v1", func(apiR chi.Router) { + apiR.Use(mw.APITokenAuth()) + apiR.Get("/whoami", tc.handlers.HandleAPIWhoAmI()) + apiR.Post("/tokens", tc.handlers.HandleAPICreateToken()) + 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()) + }) + + r.ServeHTTP(rr, req) + + return rr +} + +func TestAPIAuthRejectsNoToken(t *testing.T) { + t.Parallel() + + tc := setupTestHandlers(t) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/apps", nil) + rr := httptest.NewRecorder() + + r := chi.NewRouter() + r.Route("/api/v1", func(apiR chi.Router) { + apiR.Use(tc.middleware.APITokenAuth()) + apiR.Get("/apps", tc.handlers.HandleAPIListApps()) + }) + + r.ServeHTTP(rr, req) + assert.Equal(t, http.StatusUnauthorized, rr.Code) +} + +func TestAPIAuthRejectsInvalidToken(t *testing.T) { + t.Parallel() + + tc := setupTestHandlers(t) + + rr := apiRequest(t, tc, "invalid-token", http.MethodGet, "/api/v1/apps", "") + assert.Equal(t, http.StatusUnauthorized, rr.Code) +} + +func TestAPIWhoAmI(t *testing.T) { + t.Parallel() + + tc, token := setupAPITest(t) + + rr := apiRequest(t, tc, token, 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, token := setupAPITest(t) + + rr := apiRequest(t, tc, token, 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, token := setupAPITest(t) + + body := `{"name":"test-app","repoUrl":"https://github.com/example/repo"}` + rr := apiRequest(t, tc, token, 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, token := setupAPITest(t) + + // Missing required fields. + body := `{"name":"","repoUrl":""}` + rr := apiRequest(t, tc, token, http.MethodPost, "/api/v1/apps", body) + assert.Equal(t, http.StatusBadRequest, rr.Code) +} + +func TestAPIGetApp(t *testing.T) { + t.Parallel() + + tc, token := setupAPITest(t) + + // Create an app first. + body := `{"name":"my-app","repoUrl":"https://github.com/example/repo"}` + rr := apiRequest(t, tc, token, 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) + + // Get the app. + rr = apiRequest(t, tc, token, 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, token := setupAPITest(t) + + rr := apiRequest(t, tc, token, http.MethodGet, "/api/v1/apps/nonexistent", "") + assert.Equal(t, http.StatusNotFound, rr.Code) +} + +func TestAPIDeleteApp(t *testing.T) { + t.Parallel() + + tc, token := setupAPITest(t) + + // Create an app. + body := `{"name":"delete-me","repoUrl":"https://github.com/example/repo"}` + rr := apiRequest(t, tc, token, 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) + + // Delete it. + rr = apiRequest(t, tc, token, http.MethodDelete, "/api/v1/apps/"+appID, "") + assert.Equal(t, http.StatusOK, rr.Code) + + // Verify it's gone. + rr = apiRequest(t, tc, token, http.MethodGet, "/api/v1/apps/"+appID, "") + assert.Equal(t, http.StatusNotFound, rr.Code) +} + +func TestAPIListDeployments(t *testing.T) { + t.Parallel() + + tc, token := setupAPITest(t) + + // Create an app. + body := `{"name":"deploy-app","repoUrl":"https://github.com/example/repo"}` + rr := apiRequest(t, tc, token, 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) + + // List deployments (should be empty). + rr = apiRequest(t, tc, token, 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) +} + +func TestAPICreateToken(t *testing.T) { + t.Parallel() + + tc, token := setupAPITest(t) + + body := `{"name":"new-token"}` + rr := apiRequest(t, tc, token, http.MethodPost, "/api/v1/tokens", body) + assert.Equal(t, http.StatusCreated, rr.Code) + + var resp map[string]any + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp)) + assert.Equal(t, "new-token", resp["name"]) + assert.NotEmpty(t, resp["token"]) +} diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go index df6964d..37413e2 100644 --- a/internal/handlers/handlers_test.go +++ b/internal/handlers/handlers_test.go @@ -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, } } diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index 69fa447..0ca3caf 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -2,6 +2,7 @@ package middleware import ( + "context" "log/slog" "math" "net" @@ -19,22 +20,28 @@ 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" ) // corsMaxAge is the maximum age for CORS preflight responses in seconds. const corsMaxAge = 300 +// apiUserContextKey is the context key for the authenticated API user. +type apiUserContextKey struct{} + // Params contains dependencies for Middleware. 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. @@ -339,6 +346,74 @@ func (m *Middleware) LoginRateLimit() func(http.Handler) http.Handler { } } +// APITokenAuth returns middleware that authenticates requests via Bearer token. +// It looks up the token hash in the database and stores the user in context. +func (m *Middleware) APITokenAuth() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func( + writer http.ResponseWriter, + request *http.Request, + ) { + authHeader := request.Header.Get("Authorization") + if authHeader == "" { + http.Error(writer, `{"error":"missing Authorization header"}`, http.StatusUnauthorized) + + return + } + + const bearerPrefix = "Bearer " + if !strings.HasPrefix(authHeader, bearerPrefix) { + http.Error(writer, `{"error":"invalid Authorization header"}`, http.StatusUnauthorized) + + return + } + + rawToken := strings.TrimPrefix(authHeader, bearerPrefix) + if rawToken == "" { + http.Error(writer, `{"error":"empty token"}`, http.StatusUnauthorized) + + return + } + + hash := models.HashAPIToken(rawToken) + + apiToken, err := models.FindAPITokenByHash(request.Context(), m.params.Database, hash) + if err != nil { + m.log.Error("api token lookup error", "error", err) + http.Error(writer, `{"error":"internal server error"}`, http.StatusInternalServerError) + + return + } + + if apiToken == nil { + http.Error(writer, `{"error":"invalid token"}`, http.StatusUnauthorized) + + return + } + + // Touch last used (best-effort, don't block on error) + _ = apiToken.TouchLastUsed(request.Context()) + + user, userErr := models.FindUser(request.Context(), m.params.Database, apiToken.UserID) + if userErr != nil || user == nil { + http.Error(writer, `{"error":"token user not found"}`, http.StatusUnauthorized) + + return + } + + ctx := context.WithValue(request.Context(), apiUserContextKey{}, user) + next.ServeHTTP(writer, request.WithContext(ctx)) + }) + } +} + +// APIUserFromContext extracts the authenticated API user from the context. +func APIUserFromContext(ctx context.Context) *models.User { + user, _ := ctx.Value(apiUserContextKey{}).(*models.User) + + return user +} + // 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 { diff --git a/internal/models/api_token.go b/internal/models/api_token.go new file mode 100644 index 0000000..ed88b32 --- /dev/null +++ b/internal/models/api_token.go @@ -0,0 +1,187 @@ +package models + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "time" + + "git.eeqj.de/sneak/upaas/internal/database" +) + +// tokenBytes is the number of random bytes for a raw API token. +const tokenBytes = 32 + +// APIToken represents an API authentication token. +type APIToken struct { + db *database.Database + + ID int64 + UserID int64 + Name string + TokenHash string + CreatedAt time.Time + LastUsedAt sql.NullTime +} + +// NewAPIToken creates a new APIToken with a database reference. +func NewAPIToken(db *database.Database) *APIToken { + return &APIToken{db: db} +} + +// GenerateAPIToken creates a new API token for a user, returning the raw token +// string (shown once) and the persisted APIToken record. +func GenerateAPIToken( + ctx context.Context, + db *database.Database, + userID int64, + name string, +) (string, *APIToken, error) { + raw := make([]byte, tokenBytes) + + _, err := rand.Read(raw) + if err != nil { + return "", nil, fmt.Errorf("generating token bytes: %w", err) + } + + rawHex := hex.EncodeToString(raw) + hash := HashAPIToken(rawHex) + + token := NewAPIToken(db) + token.UserID = userID + token.Name = name + token.TokenHash = hash + + query := `INSERT INTO api_tokens (user_id, name, token_hash) VALUES (?, ?, ?)` + + result, execErr := db.Exec(ctx, query, userID, name, hash) + if execErr != nil { + return "", nil, fmt.Errorf("inserting api token: %w", execErr) + } + + id, idErr := result.LastInsertId() + if idErr != nil { + return "", nil, fmt.Errorf("getting token id: %w", idErr) + } + + token.ID = id + + reloadErr := token.Reload(ctx) + if reloadErr != nil { + return "", nil, fmt.Errorf("reloading token: %w", reloadErr) + } + + return rawHex, token, nil +} + +// HashAPIToken returns the SHA-256 hex digest of a raw token string. +func HashAPIToken(raw string) string { + sum := sha256.Sum256([]byte(raw)) + + return hex.EncodeToString(sum[:]) +} + +// 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, 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 = CURRENT_TIMESTAMP WHERE id = ?", + t.ID, + ) + + return err +} + +func (t *APIToken) scan(row *sql.Row) error { + return row.Scan( + &t.ID, &t.UserID, &t.Name, &t.TokenHash, + &t.CreatedAt, &t.LastUsedAt, + ) +} + +// FindAPITokenByHash looks up a token by its SHA-256 hash. +// +//nolint:nilnil // returning nil,nil is idiomatic for "not found" in Active Record +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, 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("scanning api token: %w", err) + } + + return token, nil +} + +// FindAPITokensByUserID returns all tokens for a user. +func FindAPITokensByUserID( + ctx context.Context, + db *database.Database, + userID int64, +) ([]*APIToken, error) { + rows, err := db.Query(ctx, + `SELECT id, user_id, name, token_hash, created_at, last_used_at + FROM api_tokens WHERE user_id = ? ORDER BY created_at DESC`, userID, + ) + if err != nil { + return nil, fmt.Errorf("querying api tokens: %w", err) + } + + defer func() { _ = rows.Close() }() + + var tokens []*APIToken + + for rows.Next() { + tok := NewAPIToken(db) + + scanErr := rows.Scan( + &tok.ID, &tok.UserID, &tok.Name, &tok.TokenHash, + &tok.CreatedAt, &tok.LastUsedAt, + ) + if scanErr != nil { + return nil, fmt.Errorf("scanning api token row: %w", scanErr) + } + + tokens = append(tokens, tok) + } + + rowsErr := rows.Err() + if rowsErr != nil { + return nil, fmt.Errorf("iterating api token rows: %w", rowsErr) + } + + return tokens, nil +} diff --git a/internal/server/routes.go b/internal/server/routes.go index 3acadea..845863d 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -98,6 +98,21 @@ func (s *Server) SetupRoutes() { }) }) + // API v1 routes (Bearer token auth, no CSRF) + s.router.Route("/api/v1", func(r chi.Router) { + r.Use(s.mw.APITokenAuth()) + + r.Get("/whoami", s.handlers.HandleAPIWhoAmI()) + r.Post("/tokens", s.handlers.HandleAPICreateToken()) + + 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()) + }) + // Metrics endpoint (optional, with basic auth) if s.params.Config.MetricsUsername != "" { s.router.Group(func(r chi.Router) { From 9ac1d2578812240f92af2e43ab3413dd6a37eb32 Mon Sep 17 00:00:00 2001 From: user Date: Mon, 16 Feb 2026 00:31:10 -0800 Subject: [PATCH 2/3] 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. --- .../migrations/007_drop_api_tokens.sql | 1 + internal/handlers/api.go | 117 +++++----- internal/handlers/api_test.go | 211 +++++++++++------- internal/handlers/handlers_test.go | 9 +- internal/middleware/middleware.go | 78 +------ internal/models/api_token.go | 187 ---------------- internal/server/routes.go | 25 ++- 7 files changed, 221 insertions(+), 407 deletions(-) create mode 100644 internal/database/migrations/007_drop_api_tokens.sql delete mode 100644 internal/models/api_token.go diff --git a/internal/database/migrations/007_drop_api_tokens.sql b/internal/database/migrations/007_drop_api_tokens.sql new file mode 100644 index 0000000..a844841 --- /dev/null +++ b/internal/database/migrations/007_drop_api_tokens.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS api_tokens; diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 72b6db5..d97be9b 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -7,7 +7,6 @@ import ( "github.com/go-chi/chi/v5" - "git.eeqj.de/sneak/upaas/internal/middleware" "git.eeqj.de/sneak/upaas/internal/models" "git.eeqj.de/sneak/upaas/internal/service/app" ) @@ -72,6 +71,65 @@ func deploymentToAPI(d *models.Deployment) apiDeploymentResponse { 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) { @@ -294,59 +352,6 @@ func (h *Handlers) HandleAPITriggerDeploy() http.HandlerFunc { } } -// HandleAPICreateToken returns a handler that creates an API token. -func (h *Handlers) HandleAPICreateToken() http.HandlerFunc { - type createTokenRequest struct { - Name string `json:"name"` - } - - type createTokenResponse struct { - Token string `json:"token"` - Name string `json:"name"` - ID int64 `json:"id"` - } - - return func(writer http.ResponseWriter, request *http.Request) { - user := middleware.APIUserFromContext(request.Context()) - if 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 { - req.Name = "default" - } - - if req.Name == "" { - req.Name = "default" - } - - rawToken, token, err := models.GenerateAPIToken( - request.Context(), h.db, user.ID, req.Name, - ) - if err != nil { - h.log.Error("api: failed to create token", "error", err) - h.respondJSON(writer, request, - map[string]string{"error": "failed to create token"}, - http.StatusInternalServerError) - - return - } - - h.respondJSON(writer, request, createTokenResponse{ - Token: rawToken, - Name: token.Name, - ID: token.ID, - }, http.StatusCreated) - } -} - // HandleAPIWhoAmI returns a handler that shows the current authenticated user. func (h *Handlers) HandleAPIWhoAmI() http.HandlerFunc { type whoAmIResponse struct { @@ -355,8 +360,8 @@ func (h *Handlers) HandleAPIWhoAmI() http.HandlerFunc { } return func(writer http.ResponseWriter, request *http.Request) { - user := middleware.APIUserFromContext(request.Context()) - if user == nil { + user, err := h.auth.GetCurrentUser(request.Context(), request) + if err != nil || user == nil { h.respondJSON(writer, request, map[string]string{"error": "unauthorized"}, http.StatusUnauthorized) diff --git a/internal/handlers/api_test.go b/internal/handlers/api_test.go index 9ff9719..6d7d899 100644 --- a/internal/handlers/api_test.go +++ b/internal/handlers/api_test.go @@ -10,34 +10,64 @@ import ( "github.com/go-chi/chi/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - - "git.eeqj.de/sneak/upaas/internal/models" ) -func setupAPITest(t *testing.T) (*testContext, string) { +// 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 first. + // Create a user. _, err := tc.authSvc.CreateUser(t.Context(), "admin", "password123") require.NoError(t, err) - user, err := models.FindUserByUsername(t.Context(), tc.database, "admin") - require.NoError(t, err) - require.NotNil(t, user) + // Login via the API to get session cookies. + r := apiRouter(tc) - // Generate an API token. - rawToken, _, err := models.GenerateAPIToken(t.Context(), tc.database, user.ID, "test") - require.NoError(t, err) + loginBody := `{"username":"admin","password":"password123"}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(loginBody)) + req.Header.Set("Content-Type", "application/json") - return tc, rawToken + 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, - token, method, path string, + cookies []*http.Cookie, + method, path string, body string, ) *httptest.ResponseRecorder { t.Helper() @@ -50,64 +80,102 @@ func apiRequest( req = httptest.NewRequest(method, path, nil) } - req.Header.Set("Authorization", "Bearer "+token) + for _, c := range cookies { + req.AddCookie(c) + } rr := httptest.NewRecorder() - // Build a chi router with API routes. - r := chi.NewRouter() - mw := tc.middleware - - r.Route("/api/v1", func(apiR chi.Router) { - apiR.Use(mw.APITokenAuth()) - apiR.Get("/whoami", tc.handlers.HandleAPIWhoAmI()) - apiR.Post("/tokens", tc.handlers.HandleAPICreateToken()) - 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()) - }) - + r := apiRouter(tc) r.ServeHTTP(rr, req) return rr } -func TestAPIAuthRejectsNoToken(t *testing.T) { +func TestAPILoginSuccess(t *testing.T) { t.Parallel() tc := setupTestHandlers(t) - req := httptest.NewRequest(http.MethodGet, "/api/v1/apps", nil) + _, 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 := chi.NewRouter() - r.Route("/api/v1", func(apiR chi.Router) { - apiR.Use(tc.middleware.APITokenAuth()) - apiR.Get("/apps", tc.handlers.HandleAPIListApps()) - }) - 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 TestAPIAuthRejectsInvalidToken(t *testing.T) { +func TestAPILoginMissingFields(t *testing.T) { t.Parallel() tc := setupTestHandlers(t) - rr := apiRequest(t, tc, "invalid-token", http.MethodGet, "/api/v1/apps", "") + 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, token := setupAPITest(t) + tc, cookies := setupAPITest(t) - rr := apiRequest(t, tc, token, http.MethodGet, "/api/v1/whoami", "") + rr := apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/whoami", "") assert.Equal(t, http.StatusOK, rr.Code) var resp map[string]any @@ -118,9 +186,9 @@ func TestAPIWhoAmI(t *testing.T) { func TestAPIListAppsEmpty(t *testing.T) { t.Parallel() - tc, token := setupAPITest(t) + tc, cookies := setupAPITest(t) - rr := apiRequest(t, tc, token, http.MethodGet, "/api/v1/apps", "") + rr := apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/apps", "") assert.Equal(t, http.StatusOK, rr.Code) var apps []any @@ -131,10 +199,10 @@ func TestAPIListAppsEmpty(t *testing.T) { func TestAPICreateApp(t *testing.T) { t.Parallel() - tc, token := setupAPITest(t) + tc, cookies := setupAPITest(t) body := `{"name":"test-app","repoUrl":"https://github.com/example/repo"}` - rr := apiRequest(t, tc, token, http.MethodPost, "/api/v1/apps", body) + rr := apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps", body) assert.Equal(t, http.StatusCreated, rr.Code) var app map[string]any @@ -146,22 +214,20 @@ func TestAPICreateApp(t *testing.T) { func TestAPICreateAppValidation(t *testing.T) { t.Parallel() - tc, token := setupAPITest(t) + tc, cookies := setupAPITest(t) - // Missing required fields. body := `{"name":"","repoUrl":""}` - rr := apiRequest(t, tc, token, http.MethodPost, "/api/v1/apps", body) + 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, token := setupAPITest(t) + tc, cookies := setupAPITest(t) - // Create an app first. body := `{"name":"my-app","repoUrl":"https://github.com/example/repo"}` - rr := apiRequest(t, tc, token, http.MethodPost, "/api/v1/apps", body) + rr := apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps", body) require.Equal(t, http.StatusCreated, rr.Code) var created map[string]any @@ -170,8 +236,7 @@ func TestAPIGetApp(t *testing.T) { appID, ok := created["id"].(string) require.True(t, ok) - // Get the app. - rr = apiRequest(t, tc, token, http.MethodGet, "/api/v1/apps/"+appID, "") + rr = apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/apps/"+appID, "") assert.Equal(t, http.StatusOK, rr.Code) var app map[string]any @@ -182,20 +247,19 @@ func TestAPIGetApp(t *testing.T) { func TestAPIGetAppNotFound(t *testing.T) { t.Parallel() - tc, token := setupAPITest(t) + tc, cookies := setupAPITest(t) - rr := apiRequest(t, tc, token, http.MethodGet, "/api/v1/apps/nonexistent", "") + 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, token := setupAPITest(t) + tc, cookies := setupAPITest(t) - // Create an app. body := `{"name":"delete-me","repoUrl":"https://github.com/example/repo"}` - rr := apiRequest(t, tc, token, http.MethodPost, "/api/v1/apps", body) + rr := apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps", body) require.Equal(t, http.StatusCreated, rr.Code) var created map[string]any @@ -204,23 +268,20 @@ func TestAPIDeleteApp(t *testing.T) { appID, ok := created["id"].(string) require.True(t, ok) - // Delete it. - rr = apiRequest(t, tc, token, http.MethodDelete, "/api/v1/apps/"+appID, "") + rr = apiRequest(t, tc, cookies, http.MethodDelete, "/api/v1/apps/"+appID, "") assert.Equal(t, http.StatusOK, rr.Code) - // Verify it's gone. - rr = apiRequest(t, tc, token, http.MethodGet, "/api/v1/apps/"+appID, "") + 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, token := setupAPITest(t) + tc, cookies := setupAPITest(t) - // Create an app. body := `{"name":"deploy-app","repoUrl":"https://github.com/example/repo"}` - rr := apiRequest(t, tc, token, http.MethodPost, "/api/v1/apps", body) + rr := apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps", body) require.Equal(t, http.StatusCreated, rr.Code) var created map[string]any @@ -229,26 +290,10 @@ func TestAPIListDeployments(t *testing.T) { appID, ok := created["id"].(string) require.True(t, ok) - // List deployments (should be empty). - rr = apiRequest(t, tc, token, http.MethodGet, "/api/v1/apps/"+appID+"/deployments", "") + 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) } - -func TestAPICreateToken(t *testing.T) { - t.Parallel() - - tc, token := setupAPITest(t) - - body := `{"name":"new-token"}` - rr := apiRequest(t, tc, token, http.MethodPost, "/api/v1/tokens", body) - assert.Equal(t, http.StatusCreated, rr.Code) - - var resp map[string]any - require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp)) - assert.Equal(t, "new-token", resp["name"]) - assert.NotEmpty(t, resp["token"]) -} diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go index 37413e2..ae04f3c 100644 --- a/internal/handlers/handlers_test.go +++ b/internal/handlers/handlers_test.go @@ -169,11 +169,10 @@ 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, + Logger: logInstance, + Globals: globalInstance, + Config: cfg, + Auth: authSvc, }) require.NoError(t, mwErr) diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index 0ca3caf..0b8179b 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -2,7 +2,6 @@ package middleware import ( - "context" "log/slog" "math" "net" @@ -20,28 +19,22 @@ 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" ) // corsMaxAge is the maximum age for CORS preflight responses in seconds. const corsMaxAge = 300 -// apiUserContextKey is the context key for the authenticated API user. -type apiUserContextKey struct{} - // Params contains dependencies for Middleware. type Params struct { fx.In - Logger *logger.Logger - Globals *globals.Globals - Config *config.Config - Auth *auth.Service - Database *database.Database + Logger *logger.Logger + Globals *globals.Globals + Config *config.Config + Auth *auth.Service } // Middleware provides HTTP middleware. @@ -346,74 +339,27 @@ func (m *Middleware) LoginRateLimit() func(http.Handler) http.Handler { } } -// APITokenAuth returns middleware that authenticates requests via Bearer token. -// It looks up the token hash in the database and stores the user in context. -func (m *Middleware) APITokenAuth() func(http.Handler) http.Handler { +// APISessionAuth returns middleware that requires session authentication for API routes. +// Unlike SessionAuth, it returns JSON 401 responses instead of redirecting to /login. +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, ) { - authHeader := request.Header.Get("Authorization") - if authHeader == "" { - http.Error(writer, `{"error":"missing Authorization header"}`, http.StatusUnauthorized) + 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 } - const bearerPrefix = "Bearer " - if !strings.HasPrefix(authHeader, bearerPrefix) { - http.Error(writer, `{"error":"invalid Authorization header"}`, http.StatusUnauthorized) - - return - } - - rawToken := strings.TrimPrefix(authHeader, bearerPrefix) - if rawToken == "" { - http.Error(writer, `{"error":"empty token"}`, http.StatusUnauthorized) - - return - } - - hash := models.HashAPIToken(rawToken) - - apiToken, err := models.FindAPITokenByHash(request.Context(), m.params.Database, hash) - if err != nil { - m.log.Error("api token lookup error", "error", err) - http.Error(writer, `{"error":"internal server error"}`, http.StatusInternalServerError) - - return - } - - if apiToken == nil { - http.Error(writer, `{"error":"invalid token"}`, http.StatusUnauthorized) - - return - } - - // Touch last used (best-effort, don't block on error) - _ = apiToken.TouchLastUsed(request.Context()) - - user, userErr := models.FindUser(request.Context(), m.params.Database, apiToken.UserID) - if userErr != nil || user == nil { - http.Error(writer, `{"error":"token user not found"}`, http.StatusUnauthorized) - - return - } - - ctx := context.WithValue(request.Context(), apiUserContextKey{}, user) - next.ServeHTTP(writer, request.WithContext(ctx)) + next.ServeHTTP(writer, request) }) } } -// APIUserFromContext extracts the authenticated API user from the context. -func APIUserFromContext(ctx context.Context) *models.User { - user, _ := ctx.Value(apiUserContextKey{}).(*models.User) - - return user -} - // 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 { diff --git a/internal/models/api_token.go b/internal/models/api_token.go deleted file mode 100644 index ed88b32..0000000 --- a/internal/models/api_token.go +++ /dev/null @@ -1,187 +0,0 @@ -package models - -import ( - "context" - "crypto/rand" - "crypto/sha256" - "database/sql" - "encoding/hex" - "errors" - "fmt" - "time" - - "git.eeqj.de/sneak/upaas/internal/database" -) - -// tokenBytes is the number of random bytes for a raw API token. -const tokenBytes = 32 - -// APIToken represents an API authentication token. -type APIToken struct { - db *database.Database - - ID int64 - UserID int64 - Name string - TokenHash string - CreatedAt time.Time - LastUsedAt sql.NullTime -} - -// NewAPIToken creates a new APIToken with a database reference. -func NewAPIToken(db *database.Database) *APIToken { - return &APIToken{db: db} -} - -// GenerateAPIToken creates a new API token for a user, returning the raw token -// string (shown once) and the persisted APIToken record. -func GenerateAPIToken( - ctx context.Context, - db *database.Database, - userID int64, - name string, -) (string, *APIToken, error) { - raw := make([]byte, tokenBytes) - - _, err := rand.Read(raw) - if err != nil { - return "", nil, fmt.Errorf("generating token bytes: %w", err) - } - - rawHex := hex.EncodeToString(raw) - hash := HashAPIToken(rawHex) - - token := NewAPIToken(db) - token.UserID = userID - token.Name = name - token.TokenHash = hash - - query := `INSERT INTO api_tokens (user_id, name, token_hash) VALUES (?, ?, ?)` - - result, execErr := db.Exec(ctx, query, userID, name, hash) - if execErr != nil { - return "", nil, fmt.Errorf("inserting api token: %w", execErr) - } - - id, idErr := result.LastInsertId() - if idErr != nil { - return "", nil, fmt.Errorf("getting token id: %w", idErr) - } - - token.ID = id - - reloadErr := token.Reload(ctx) - if reloadErr != nil { - return "", nil, fmt.Errorf("reloading token: %w", reloadErr) - } - - return rawHex, token, nil -} - -// HashAPIToken returns the SHA-256 hex digest of a raw token string. -func HashAPIToken(raw string) string { - sum := sha256.Sum256([]byte(raw)) - - return hex.EncodeToString(sum[:]) -} - -// 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, 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 = CURRENT_TIMESTAMP WHERE id = ?", - t.ID, - ) - - return err -} - -func (t *APIToken) scan(row *sql.Row) error { - return row.Scan( - &t.ID, &t.UserID, &t.Name, &t.TokenHash, - &t.CreatedAt, &t.LastUsedAt, - ) -} - -// FindAPITokenByHash looks up a token by its SHA-256 hash. -// -//nolint:nilnil // returning nil,nil is idiomatic for "not found" in Active Record -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, 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("scanning api token: %w", err) - } - - return token, nil -} - -// FindAPITokensByUserID returns all tokens for a user. -func FindAPITokensByUserID( - ctx context.Context, - db *database.Database, - userID int64, -) ([]*APIToken, error) { - rows, err := db.Query(ctx, - `SELECT id, user_id, name, token_hash, created_at, last_used_at - FROM api_tokens WHERE user_id = ? ORDER BY created_at DESC`, userID, - ) - if err != nil { - return nil, fmt.Errorf("querying api tokens: %w", err) - } - - defer func() { _ = rows.Close() }() - - var tokens []*APIToken - - for rows.Next() { - tok := NewAPIToken(db) - - scanErr := rows.Scan( - &tok.ID, &tok.UserID, &tok.Name, &tok.TokenHash, - &tok.CreatedAt, &tok.LastUsedAt, - ) - if scanErr != nil { - return nil, fmt.Errorf("scanning api token row: %w", scanErr) - } - - tokens = append(tokens, tok) - } - - rowsErr := rows.Err() - if rowsErr != nil { - return nil, fmt.Errorf("iterating api token rows: %w", rowsErr) - } - - return tokens, nil -} diff --git a/internal/server/routes.go b/internal/server/routes.go index 845863d..073cc6b 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -98,19 +98,24 @@ func (s *Server) SetupRoutes() { }) }) - // API v1 routes (Bearer token auth, no CSRF) + // API v1 routes (cookie-based session auth, no CSRF) s.router.Route("/api/v1", func(r chi.Router) { - r.Use(s.mw.APITokenAuth()) + // Login endpoint is public (returns session cookie) + r.With(s.mw.LoginRateLimit()).Post("/login", s.handlers.HandleAPILoginPOST()) - r.Get("/whoami", s.handlers.HandleAPIWhoAmI()) - r.Post("/tokens", s.handlers.HandleAPICreateToken()) + // All other API routes require session auth + r.Group(func(r chi.Router) { + r.Use(s.mw.APISessionAuth()) - 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()) + 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()) + }) }) // Metrics endpoint (optional, with basic auth) From 8d68a31366e24061667bf4a44a2a4e16556410be Mon Sep 17 00:00:00 2001 From: user Date: Mon, 16 Feb 2026 00:34:02 -0800 Subject: [PATCH 3/3] fix: remove undeployed api_tokens migrations (006 + 007) --- internal/database/migrations/006_add_api_tokens.sql | 11 ----------- internal/database/migrations/007_drop_api_tokens.sql | 1 - 2 files changed, 12 deletions(-) delete mode 100644 internal/database/migrations/006_add_api_tokens.sql delete mode 100644 internal/database/migrations/007_drop_api_tokens.sql diff --git a/internal/database/migrations/006_add_api_tokens.sql b/internal/database/migrations/006_add_api_tokens.sql deleted file mode 100644 index fe36c4e..0000000 --- a/internal/database/migrations/006_add_api_tokens.sql +++ /dev/null @@ -1,11 +0,0 @@ -CREATE TABLE IF NOT EXISTS api_tokens ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - name TEXT NOT NULL DEFAULT '', - token_hash TEXT NOT NULL UNIQUE, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - last_used_at DATETIME -); - -CREATE INDEX IF NOT EXISTS idx_api_tokens_token_hash ON api_tokens(token_hash); -CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON api_tokens(user_id); diff --git a/internal/database/migrations/007_drop_api_tokens.sql b/internal/database/migrations/007_drop_api_tokens.sql deleted file mode 100644 index a844841..0000000 --- a/internal/database/migrations/007_drop_api_tokens.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS api_tokens;