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)