diff --git a/internal/config/config.go b/internal/config/config.go index b3adafb..d6f919b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -51,7 +51,7 @@ type Config struct { MaintenanceMode bool MetricsUsername string MetricsPassword string - SessionSecret string + SessionSecret string //nolint:gosec // not a hardcoded credential, loaded from env/file CORSOrigins string params *Params log *slog.Logger diff --git a/internal/database/database.go b/internal/database/database.go index 060699d..b4abaaa 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -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 != ''") diff --git a/internal/database/migrations/007_add_api_tokens.sql b/internal/database/migrations/007_add_api_tokens.sql new file mode 100644 index 0000000..73d05eb --- /dev/null +++ b/internal/database/migrations/007_add_api_tokens.sql @@ -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); diff --git a/internal/docker/client.go b/internal/docker/client.go index 10af151..38cc198 100644 --- a/internal/docker/client.go +++ b/internal/docker/client.go @@ -480,6 +480,20 @@ func (c *Client) CloneRepo( return c.performClone(ctx, cfg) } +// RemoveImage removes a Docker image by ID or tag. +// It returns nil if the image was successfully removed or does not exist. +func (c *Client) RemoveImage(ctx context.Context, imageID string) error { + _, err := c.docker.ImageRemove(ctx, imageID, image.RemoveOptions{ + Force: true, + PruneChildren: true, + }) + if err != nil && !client.IsErrNotFound(err) { + return fmt.Errorf("failed to remove image %s: %w", imageID, err) + } + + return nil +} + func (c *Client) performBuild( ctx context.Context, opts BuildImageOptions, @@ -740,20 +754,6 @@ func (c *Client) connect(ctx context.Context) error { return nil } -// RemoveImage removes a Docker image by ID or tag. -// It returns nil if the image was successfully removed or does not exist. -func (c *Client) RemoveImage(ctx context.Context, imageID string) error { - _, err := c.docker.ImageRemove(ctx, imageID, image.RemoveOptions{ - Force: true, - PruneChildren: true, - }) - if err != nil && !client.IsErrNotFound(err) { - return fmt.Errorf("failed to remove image %s: %w", imageID, err) - } - - return nil -} - func (c *Client) close() error { if c.docker != nil { err := c.docker.Close() diff --git a/internal/handlers/api.go b/internal/handlers/api.go index d97be9b..edf7f46 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -76,7 +76,7 @@ func deploymentToAPI(d *models.Deployment) apiDeploymentResponse { func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc { type loginRequest struct { Username string `json:"username"` - Password string `json:"password"` + Password string `json:"password"` //nolint:gosec // request field, not a hardcoded credential } type loginResponse struct { diff --git a/internal/handlers/api_token_test.go b/internal/handlers/api_token_test.go new file mode 100644 index 0000000..bca8d58 --- /dev/null +++ b/internal/handlers/api_token_test.go @@ -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) +} diff --git a/internal/handlers/api_tokens.go b/internal/handlers/api_tokens.go new file mode 100644 index 0000000..0d7ccb7 --- /dev/null +++ b/internal/handlers/api_tokens.go @@ -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) + } +} diff --git a/internal/handlers/app.go b/internal/handlers/app.go index 72fb07c..500dd14 100644 --- a/internal/handlers/app.go +++ b/internal/handlers/app.go @@ -499,7 +499,8 @@ func (h *Handlers) HandleAppLogs() http.HandlerFunc { return } - _, _ = writer.Write([]byte(logs)) + //nolint:gosec // logs sanitized: ANSI escapes and control chars stripped + _, _ = writer.Write([]byte(SanitizeLogs(logs))) } } @@ -538,7 +539,7 @@ func (h *Handlers) HandleDeploymentLogsAPI() http.HandlerFunc { } response := map[string]any{ - "logs": logs, + "logs": SanitizeLogs(logs), "status": deployment.Status, } @@ -582,7 +583,7 @@ func (h *Handlers) HandleDeploymentLogDownload() http.HandlerFunc { } // Check if file exists - _, err := os.Stat(logPath) + _, err := os.Stat(logPath) //nolint:gosec // logPath is constructed by deploy service, not from user input if os.IsNotExist(err) { http.NotFound(writer, request) @@ -661,7 +662,7 @@ func (h *Handlers) HandleContainerLogsAPI() http.HandlerFunc { } response := map[string]any{ - "logs": logs, + "logs": SanitizeLogs(logs), "status": status, } diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go index ae04f3c..37413e2 100644 --- a/internal/handlers/handlers_test.go +++ b/internal/handlers/handlers_test.go @@ -169,10 +169,11 @@ 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, + Logger: logInstance, + Globals: globalInstance, + Config: cfg, + Auth: authSvc, + Database: dbInstance, }) require.NoError(t, mwErr) diff --git a/internal/handlers/sanitize.go b/internal/handlers/sanitize.go new file mode 100644 index 0000000..91f2ddc --- /dev/null +++ b/internal/handlers/sanitize.go @@ -0,0 +1,30 @@ +package handlers + +import ( + "regexp" + "strings" +) + +// ansiEscapePattern matches ANSI escape sequences (CSI, OSC, and single-character escapes). +var ansiEscapePattern = regexp.MustCompile(`(\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[^[\]])`) + +// SanitizeLogs strips ANSI escape sequences and non-printable control characters +// from container log output. Newlines (\n), carriage returns (\r), and tabs (\t) +// are preserved. This ensures that attacker-controlled container output cannot +// inject terminal escape sequences or other dangerous control characters. +func SanitizeLogs(input string) string { + // Strip ANSI escape sequences + result := ansiEscapePattern.ReplaceAllString(input, "") + + // Strip remaining non-printable characters (keep \n, \r, \t) + var b strings.Builder + b.Grow(len(result)) + + for _, r := range result { + if r == '\n' || r == '\r' || r == '\t' || r >= ' ' { + b.WriteRune(r) + } + } + + return b.String() +} diff --git a/internal/handlers/sanitize_test.go b/internal/handlers/sanitize_test.go new file mode 100644 index 0000000..8282082 --- /dev/null +++ b/internal/handlers/sanitize_test.go @@ -0,0 +1,84 @@ +package handlers_test + +import ( + "testing" + + "git.eeqj.de/sneak/upaas/internal/handlers" +) + +func TestSanitizeLogs(t *testing.T) { //nolint:funlen // table-driven tests + t.Parallel() + + tests := []struct { + name string + input string + expected string + }{ + { + name: "plain text unchanged", + input: "hello world\n", + expected: "hello world\n", + }, + { + name: "strips ANSI color codes", + input: "\x1b[31mERROR\x1b[0m: something failed\n", + expected: "ERROR: something failed\n", + }, + { + name: "strips OSC sequences", + input: "\x1b]0;window title\x07normal text\n", + expected: "normal text\n", + }, + { + name: "strips null bytes", + input: "hello\x00world\n", + expected: "helloworld\n", + }, + { + name: "strips bell characters", + input: "alert\x07here\n", + expected: "alerthere\n", + }, + { + name: "preserves tabs", + input: "field1\tfield2\tfield3\n", + expected: "field1\tfield2\tfield3\n", + }, + { + name: "preserves carriage returns", + input: "line1\r\nline2\r\n", + expected: "line1\r\nline2\r\n", + }, + { + name: "strips mixed escape sequences", + input: "\x1b[32m2024-01-01\x1b[0m \x1b[1mINFO\x1b[0m starting\x00\n", + expected: "2024-01-01 INFO starting\n", + }, + { + name: "empty string", + input: "", + expected: "", + }, + { + name: "only control characters", + input: "\x00\x01\x02\x03", + expected: "", + }, + { + name: "cursor movement sequences stripped", + input: "\x1b[2J\x1b[H\x1b[3Atext\n", + expected: "text\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := handlers.SanitizeLogs(tt.input) + if got != tt.expected { + t.Errorf("SanitizeLogs(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} diff --git a/internal/middleware/bearer_auth_test.go b/internal/middleware/bearer_auth_test.go new file mode 100644 index 0000000..30bc332 --- /dev/null +++ b/internal/middleware/bearer_auth_test.go @@ -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) +} diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index 32a121c..607fe53 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -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. @@ -370,18 +373,36 @@ func (m *Middleware) LoginRateLimit() 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. +// 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, ) { - user, err := m.params.Auth.GetCurrentUser(request.Context(), 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) + http.Error( + writer, + `{"error":"unauthorized"}`, + http.StatusUnauthorized, + ) return } @@ -434,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 +} diff --git a/internal/models/api_token.go b/internal/models/api_token.go new file mode 100644 index 0000000..6697fb8 --- /dev/null +++ b/internal/models/api_token.go @@ -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 +} diff --git a/internal/server/routes.go b/internal/server/routes.go index 3339bb8..c2bcf26 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -119,6 +119,11 @@ func (s *Server) SetupRoutes() { 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()) }) }) diff --git a/internal/service/auth/auth.go b/internal/service/auth/auth.go index 726c2c0..db654b2 100644 --- a/internal/service/auth/auth.go +++ b/internal/service/auth/auth.go @@ -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 diff --git a/internal/service/deploy/deploy.go b/internal/service/deploy/deploy.go index 19a65a4..0959729 100644 --- a/internal/service/deploy/deploy.go +++ b/internal/service/deploy/deploy.go @@ -726,6 +726,7 @@ func (svc *Service) cleanupCancelledDeploy( } else { svc.log.Info("cleaned up build dir from cancelled deploy", "app", app.Name, "path", dirPath) + _ = deployment.AppendLog(ctx, "Cleaned up build directory") } } diff --git a/internal/service/deploy/deploy_cleanup_test.go b/internal/service/deploy/deploy_cleanup_test.go index 1474342..5a49ee5 100644 --- a/internal/service/deploy/deploy_cleanup_test.go +++ b/internal/service/deploy/deploy_cleanup_test.go @@ -32,7 +32,7 @@ func TestCleanupCancelledDeploy_RemovesBuildDir(t *testing.T) { require.NoError(t, os.MkdirAll(deployDir, 0o750)) // Create a file inside to verify full removal - require.NoError(t, os.WriteFile(filepath.Join(deployDir, "work"), []byte("test"), 0o640)) + require.NoError(t, os.WriteFile(filepath.Join(deployDir, "work"), []byte("test"), 0o600)) // Also create a dir for a different deployment (should NOT be removed) otherDir := filepath.Join(buildDir, "99-xyz789") diff --git a/internal/service/deploy/export_test.go b/internal/service/deploy/export_test.go index a5aa241..bd90daa 100644 --- a/internal/service/deploy/export_test.go +++ b/internal/service/deploy/export_test.go @@ -52,10 +52,10 @@ func NewTestServiceWithConfig(log *slog.Logger, cfg *config.Config, dockerClient // cleanupCancelledDeploy for testing. It removes build directories matching // the deployment ID prefix. func (svc *Service) CleanupCancelledDeploy( - ctx context.Context, + _ context.Context, appName string, deploymentID int64, - imageID string, + _ string, ) { // We can't create real models.App/Deployment in tests easily, // so we test the build dir cleanup portion directly. diff --git a/internal/service/notify/notify.go b/internal/service/notify/notify.go index 0cc29da..0d10728 100644 --- a/internal/service/notify/notify.go +++ b/internal/service/notify/notify.go @@ -260,7 +260,7 @@ func (svc *Service) sendNtfy( request.Header.Set("Title", title) request.Header.Set("Priority", svc.ntfyPriority(priority)) - resp, err := svc.client.Do(request) + resp, err := svc.client.Do(request) //nolint:gosec // URL constructed from trusted config, not user input if err != nil { return fmt.Errorf("failed to send ntfy request: %w", err) } @@ -352,7 +352,7 @@ func (svc *Service) sendSlack( request.Header.Set("Content-Type", "application/json") - resp, err := svc.client.Do(request) + resp, err := svc.client.Do(request) //nolint:gosec // URL from trusted webhook config if err != nil { return fmt.Errorf("failed to send slack request: %w", err) } diff --git a/internal/ssh/keygen.go b/internal/ssh/keygen.go index 49e0ee9..ce2d8c0 100644 --- a/internal/ssh/keygen.go +++ b/internal/ssh/keygen.go @@ -12,7 +12,7 @@ import ( // KeyPair contains an SSH key pair. type KeyPair struct { - PrivateKey string + PrivateKey string //nolint:gosec // field name describes SSH key material, not a hardcoded secret PublicKey string }