From dde0ad5250095f3dc4eec61a5c5836786e1f3fdd Mon Sep 17 00:00:00 2001 From: clawbot Date: Thu, 19 Feb 2026 13:47:39 -0800 Subject: [PATCH] feat: add API token authentication (closes #87) - Add api_tokens table migration (007) - Add APIToken model with CRUD operations - Generate tokens with upaas_ prefix + 32 hex chars - Store SHA-256 hash of tokens (not plaintext) - Update APISessionAuth middleware to check Bearer tokens - Add POST/GET/DELETE /api/v1/tokens endpoints - Token creation returns plaintext once; list never exposes it - Expired and revoked tokens are rejected - Tests for creation, listing, deletion, bearer auth, revocation --- internal/database/database.go | 7 + .../migrations/007_add_api_tokens.sql | 12 + internal/handlers/api_token_test.go | 293 ++++++++++++++++++ internal/handlers/api_tokens.go | 220 +++++++++++++ internal/handlers/handlers_test.go | 9 +- internal/middleware/middleware.go | 79 ++++- internal/models/api_token.go | 206 ++++++++++++ internal/server/routes.go | 83 ++--- 8 files changed, 853 insertions(+), 56 deletions(-) create mode 100644 internal/database/migrations/007_add_api_tokens.sql create mode 100644 internal/handlers/api_token_test.go create mode 100644 internal/handlers/api_tokens.go create mode 100644 internal/models/api_token.go 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/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/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/middleware/middleware.go b/internal/middleware/middleware.go index 0b8179b..18e04d0 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. @@ -235,9 +238,9 @@ func (m *Middleware) CSRF() func(http.Handler) http.Handler { // loginRateLimit configures the login rate limiter. const ( loginRateLimit = rate.Limit(5.0 / 60.0) // 5 requests per 60 seconds - loginBurst = 5 // allow burst of 5 - limiterExpiry = 10 * time.Minute // evict entries not seen in 10 minutes - limiterCleanupEvery = 1 * time.Minute // sweep interval + loginBurst = 5 // allow burst of 5 + limiterExpiry = 10 * time.Minute // evict entries not seen in 10 minutes + limiterCleanupEvery = 1 * time.Minute // sweep interval ) // ipLimiterEntry stores a rate limiter with its last-seen timestamp. @@ -249,8 +252,8 @@ type ipLimiterEntry struct { // ipLimiter tracks per-IP rate limiters for login attempts with automatic // eviction of stale entries to prevent unbounded memory growth. type ipLimiter struct { - mu sync.Mutex - limiters map[string]*ipLimiterEntry + mu sync.Mutex + limiters map[string]*ipLimiterEntry lastSweep time.Time } @@ -339,18 +342,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 m.tryBearerAuth(request) { + next.ServeHTTP(writer, request) + + 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 } @@ -403,3 +424,35 @@ func (m *Middleware) SetupRequired() func(http.Handler) http.Handler { }) } } + +// tryBearerAuth checks for a valid Bearer token in the +// Authorization header. +func (m *Middleware) tryBearerAuth(request *http.Request) bool { + authHeader := request.Header.Get("Authorization") + if !strings.HasPrefix(authHeader, bearerPrefix) { + return false + } + + rawToken := strings.TrimPrefix(authHeader, bearerPrefix) + if rawToken == "" { + return false + } + + tokenHash := database.HashAPIToken(rawToken) + + apiToken, err := models.FindAPITokenByHash( + request.Context(), m.params.Database, tokenHash, + ) + if err != nil || apiToken == nil { + return false + } + + if apiToken.IsExpired() { + return false + } + + // Update last_used_at (best effort). + _ = apiToken.TouchLastUsed(request.Context()) + + return true +} diff --git a/internal/models/api_token.go b/internal/models/api_token.go new file mode 100644 index 0000000..648d5fa --- /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 = 16 + +// 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 21e4d3d..c2bcf26 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -54,51 +54,51 @@ func (s *Server) SetupRoutes() { r.Group(func(r chi.Router) { r.Use(s.mw.SessionAuth()) - // Dashboard - r.Get("/", s.handlers.HandleDashboard()) + // Dashboard + r.Get("/", s.handlers.HandleDashboard()) - // Logout - r.Post("/logout", s.handlers.HandleLogout()) + // Logout + r.Post("/logout", s.handlers.HandleLogout()) - // App routes - r.Get("/apps/new", s.handlers.HandleAppNew()) - r.Post("/apps", s.handlers.HandleAppCreate()) - r.Get("/apps/{id}", s.handlers.HandleAppDetail()) - r.Get("/apps/{id}/edit", s.handlers.HandleAppEdit()) - r.Post("/apps/{id}", s.handlers.HandleAppUpdate()) - r.Post("/apps/{id}/delete", s.handlers.HandleAppDelete()) - r.Post("/apps/{id}/deploy", s.handlers.HandleAppDeploy()) - r.Post("/apps/{id}/deployments/cancel", s.handlers.HandleCancelDeploy()) - r.Get("/apps/{id}/deployments", s.handlers.HandleAppDeployments()) - r.Get("/apps/{id}/deployments/{deploymentID}/logs", s.handlers.HandleDeploymentLogsAPI()) - r.Get("/apps/{id}/deployments/{deploymentID}/download", s.handlers.HandleDeploymentLogDownload()) - r.Get("/apps/{id}/logs", s.handlers.HandleAppLogs()) - r.Get("/apps/{id}/container-logs", s.handlers.HandleContainerLogsAPI()) - r.Get("/apps/{id}/status", s.handlers.HandleAppStatusAPI()) - r.Get("/apps/{id}/recent-deployments", s.handlers.HandleRecentDeploymentsAPI()) - r.Post("/apps/{id}/rollback", s.handlers.HandleAppRollback()) - r.Post("/apps/{id}/restart", s.handlers.HandleAppRestart()) - r.Post("/apps/{id}/stop", s.handlers.HandleAppStop()) - r.Post("/apps/{id}/start", s.handlers.HandleAppStart()) + // App routes + r.Get("/apps/new", s.handlers.HandleAppNew()) + r.Post("/apps", s.handlers.HandleAppCreate()) + r.Get("/apps/{id}", s.handlers.HandleAppDetail()) + r.Get("/apps/{id}/edit", s.handlers.HandleAppEdit()) + r.Post("/apps/{id}", s.handlers.HandleAppUpdate()) + r.Post("/apps/{id}/delete", s.handlers.HandleAppDelete()) + r.Post("/apps/{id}/deploy", s.handlers.HandleAppDeploy()) + r.Post("/apps/{id}/deployments/cancel", s.handlers.HandleCancelDeploy()) + r.Get("/apps/{id}/deployments", s.handlers.HandleAppDeployments()) + r.Get("/apps/{id}/deployments/{deploymentID}/logs", s.handlers.HandleDeploymentLogsAPI()) + r.Get("/apps/{id}/deployments/{deploymentID}/download", s.handlers.HandleDeploymentLogDownload()) + r.Get("/apps/{id}/logs", s.handlers.HandleAppLogs()) + r.Get("/apps/{id}/container-logs", s.handlers.HandleContainerLogsAPI()) + r.Get("/apps/{id}/status", s.handlers.HandleAppStatusAPI()) + r.Get("/apps/{id}/recent-deployments", s.handlers.HandleRecentDeploymentsAPI()) + r.Post("/apps/{id}/rollback", s.handlers.HandleAppRollback()) + r.Post("/apps/{id}/restart", s.handlers.HandleAppRestart()) + r.Post("/apps/{id}/stop", s.handlers.HandleAppStop()) + r.Post("/apps/{id}/start", s.handlers.HandleAppStart()) - // Environment variables - r.Post("/apps/{id}/env-vars", s.handlers.HandleEnvVarAdd()) - r.Post("/apps/{id}/env-vars/{varID}/edit", s.handlers.HandleEnvVarEdit()) - r.Post("/apps/{id}/env-vars/{varID}/delete", s.handlers.HandleEnvVarDelete()) + // Environment variables + r.Post("/apps/{id}/env-vars", s.handlers.HandleEnvVarAdd()) + r.Post("/apps/{id}/env-vars/{varID}/edit", s.handlers.HandleEnvVarEdit()) + r.Post("/apps/{id}/env-vars/{varID}/delete", s.handlers.HandleEnvVarDelete()) - // Labels - r.Post("/apps/{id}/labels", s.handlers.HandleLabelAdd()) - r.Post("/apps/{id}/labels/{labelID}/edit", s.handlers.HandleLabelEdit()) - r.Post("/apps/{id}/labels/{labelID}/delete", s.handlers.HandleLabelDelete()) + // Labels + r.Post("/apps/{id}/labels", s.handlers.HandleLabelAdd()) + r.Post("/apps/{id}/labels/{labelID}/edit", s.handlers.HandleLabelEdit()) + r.Post("/apps/{id}/labels/{labelID}/delete", s.handlers.HandleLabelDelete()) - // Volumes - r.Post("/apps/{id}/volumes", s.handlers.HandleVolumeAdd()) - r.Post("/apps/{id}/volumes/{volumeID}/edit", s.handlers.HandleVolumeEdit()) - r.Post("/apps/{id}/volumes/{volumeID}/delete", s.handlers.HandleVolumeDelete()) + // Volumes + r.Post("/apps/{id}/volumes", s.handlers.HandleVolumeAdd()) + r.Post("/apps/{id}/volumes/{volumeID}/edit", s.handlers.HandleVolumeEdit()) + r.Post("/apps/{id}/volumes/{volumeID}/delete", s.handlers.HandleVolumeDelete()) - // Ports - r.Post("/apps/{id}/ports", s.handlers.HandlePortAdd()) - r.Post("/apps/{id}/ports/{portID}/delete", s.handlers.HandlePortDelete()) + // Ports + r.Post("/apps/{id}/ports", s.handlers.HandlePortAdd()) + r.Post("/apps/{id}/ports/{portID}/delete", s.handlers.HandlePortDelete()) }) }) @@ -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()) }) })