From 9daccbae82cb3d1053fbf952f9a65109163ef13e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leon=20B=C3=B6sche?= Date: Fri, 9 Jan 2026 19:53:09 +0100 Subject: [PATCH] Fix auth for 1.0.0: add logout endpoint, fix JWT claims consistency, add session revocation --- go_cloud/internal/database/db.go | 9 ++++++ go_cloud/internal/http/routes.go | 44 +++++++++++++++++++++++-- go_cloud/migrations/run-migrations.sh | 46 +++++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 go_cloud/migrations/run-migrations.sh diff --git a/go_cloud/internal/database/db.go b/go_cloud/internal/database/db.go index eaae00d..fcc5ae4 100644 --- a/go_cloud/internal/database/db.go +++ b/go_cloud/internal/database/db.go @@ -132,6 +132,15 @@ func (db *DB) GetSession(ctx context.Context, sessionID uuid.UUID) (*Session, er return &session, nil } +func (db *DB) RevokeSession(ctx context.Context, sessionID uuid.UUID) error { + _, err := db.ExecContext(ctx, ` + UPDATE sessions + SET revoked_at = NOW() + WHERE id = $1 AND revoked_at IS NULL + `, sessionID) + return err +} + func (db *DB) GetUserOrganizations(ctx context.Context, userID uuid.UUID) ([]Organization, error) { rows, err := db.QueryContext(ctx, ` SELECT o.id, o.name, o.slug, o.created_at diff --git a/go_cloud/internal/http/routes.go b/go_cloud/internal/http/routes.go index 506625a..4b92e3c 100644 --- a/go_cloud/internal/http/routes.go +++ b/go_cloud/internal/http/routes.go @@ -48,6 +48,9 @@ func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, aut r.Post("/refresh", func(w http.ResponseWriter, req *http.Request) { refreshHandler(w, req, jwtManager, db) }) + r.Post("/logout", func(w http.ResponseWriter, req *http.Request) { + logoutHandler(w, req, jwtManager, db, auditLogger) + }) // Passkey routes r.Post("/signup", func(w http.ResponseWriter, req *http.Request) { signupHandler(w, req, db, auditLogger) @@ -200,6 +203,41 @@ func refreshHandler(w http.ResponseWriter, r *http.Request, jwtManager *jwt.Mana w.Write([]byte(`{"token": "` + newToken + `"}`)) } +func logoutHandler(w http.ResponseWriter, r *http.Request, jwtManager *jwt.Manager, db *database.DB, auditLogger *audit.Logger) { + authHeader := r.Header.Get("Authorization") + if !strings.HasPrefix(authHeader, "Bearer ") { + errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized) + return + } + tokenString := strings.TrimPrefix(authHeader, "Bearer ") + + claims, session, err := jwtManager.ValidateWithSession(r.Context(), tokenString, db) + if err != nil { + // Token invalid or session already revoked/expired — still return success + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status": "ok"}`)) + return + } + + userID, _ := uuid.Parse(claims.UserID) + + // Revoke session + if err := db.RevokeSession(r.Context(), session.ID); err != nil { + errors.LogError(r, err, "Failed to revoke session") + errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) + return + } + + auditLogger.Log(r.Context(), audit.Entry{ + UserID: &userID, + Action: "logout", + Success: true, + }) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status": "ok"}`)) +} + func listOrgsHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager) { authHeader := r.Header.Get("Authorization") if !strings.HasPrefix(authHeader, "Bearer ") { @@ -616,7 +654,7 @@ func registrationVerifyHandler(w http.ResponseWriter, r *http.Request, db *datab // Generate JWT orgIDs := []string{} - token, err := jwtManager.Generate(user.Email, orgIDs, session.ID.String()) + token, err := jwtManager.Generate(user.ID.String(), orgIDs, session.ID.String()) if err != nil { errors.LogError(r, err, "Token generation failed") errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) @@ -718,7 +756,7 @@ func authenticationVerifyHandler(w http.ResponseWriter, r *http.Request, db *dat } // Generate JWT - token, err := jwtManager.Generate(user.Email, orgIDs, session.ID.String()) + token, err := jwtManager.Generate(user.ID.String(), orgIDs, session.ID.String()) if err != nil { errors.LogError(r, err, "Token generation failed") errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) @@ -788,7 +826,7 @@ func passwordLoginHandler(w http.ResponseWriter, r *http.Request, db *database.D } // Generate JWT - token, err := jwtManager.Generate(user.Email, orgIDs, session.ID.String()) + token, err := jwtManager.Generate(user.ID.String(), orgIDs, session.ID.String()) if err != nil { errors.LogError(r, err, "Token generation failed") errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) diff --git a/go_cloud/migrations/run-migrations.sh b/go_cloud/migrations/run-migrations.sh new file mode 100644 index 0000000..01a97f1 --- /dev/null +++ b/go_cloud/migrations/run-migrations.sh @@ -0,0 +1,46 @@ +#!/bin/bash +# Database Migration Runner for b0esche.cloud +# Runs all SQL migrations in order + +set -e + +# Check for required environment variable +if [ -z "$DATABASE_URL" ]; then + echo "ERROR: DATABASE_URL environment variable not set" + echo "Example: DATABASE_URL=postgres://user:pass@localhost:5432/dbname" + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +echo "=== b0esche.cloud Database Migrations ===" +echo "Database: $DATABASE_URL" +echo + +# Function to run a single migration +run_migration() { + local file=$1 + echo "Running: $(basename $file)" + psql "$DATABASE_URL" -f "$file" -v ON_ERROR_STOP=1 + if [ $? -eq 0 ]; then + echo "✓ Success" + else + echo "✗ Failed" + exit 1 + fi +} + +# Run migrations in order +echo "Step 1/3: Initial schema..." +run_migration "$SCRIPT_DIR/0001_initial.sql" + +echo +echo "Step 2/3: Passkeys and authentication..." +run_migration "$SCRIPT_DIR/0002_passkeys.sql" + +echo +echo "Step 3/3: Files and storage..." +run_migration "$SCRIPT_DIR/0003_files.sql" + +echo +echo "=== All migrations completed successfully! ==="