From 50b017f387845602c90a6f2df477baafedbca00c Mon Sep 17 00:00:00 2001 From: Sandro Date: Tue, 27 Oct 2026 12:09:15 +0100 Subject: [PATCH] fix(security): resolve FLAW-08 with token_version invalidation and jwt blacklist --- Doku/database.md | 14 ++ Doku/flaws.md | 18 +- frontend/src/App.tsx | 6 +- .../features/settings/UserSettingsView.tsx | 42 +++++ frontend/src/lib/api.ts | 12 ++ internal/api/server.go | 90 ++++++---- internal/api/server_test.go | 160 +++++++++++++++++- internal/auth/auth.go | 29 +++- internal/domain/models.go | 1 + internal/storage/sqlite.go | 63 ++++++- 10 files changed, 372 insertions(+), 63 deletions(-) diff --git a/Doku/database.md b/Doku/database.md index c2478c1..3447799 100644 --- a/Doku/database.md +++ b/Doku/database.md @@ -58,6 +58,7 @@ CREATE TABLE IF NOT EXISTS users ( avatar_url TEXT NOT NULL DEFAULT '', -- Profilbild URL oder leer role TEXT NOT NULL DEFAULT 'member', -- 'admin' | 'member' is_active INTEGER NOT NULL DEFAULT 1, -- 1 = aktiv, 0 = gesperrt + token_version INTEGER NOT NULL DEFAULT 1, -- Erhöht bei Passwortwechsel/Rechteentzug zur Token-Invalidierung alarm_channels TEXT NOT NULL DEFAULT 'discord', -- Kommagetrennt: 'email', 'discord', 'webhook' alarm_email TEXT NOT NULL DEFAULT '', -- Ziel-E-Mail für Alarme discord_webhook_url TEXT NOT NULL DEFAULT '', -- Discord Webhook URL @@ -111,6 +112,19 @@ CREATE TABLE IF NOT EXISTS api_keys ( ); ``` +#### `revoked_tokens` +Sperrliste für explizit abgemeldete JWTs (Blacklist bis zum regulären Token-Ablaufdatum). +```sql +CREATE TABLE IF NOT EXISTS revoked_tokens ( + token_hash TEXT PRIMARY KEY, -- SHA-256 Hash des Tokens + user_id TEXT NOT NULL, -- Benutzer-ID + expires_at TEXT NOT NULL, -- ISO 8601 Ablaufdatum + revoked_at TEXT NOT NULL -- ISO 8601 Sperrzeitpunkt +); +CREATE INDEX IF NOT EXISTS idx_revoked_tokens_expires ON revoked_tokens(expires_at); +CREATE INDEX IF NOT EXISTS idx_revoked_tokens_user ON revoked_tokens(user_id); +``` + --- ### 2.2 Gruppen & Organisations-Teams diff --git a/Doku/flaws.md b/Doku/flaws.md index f3eaf7b..3f9c99a 100644 --- a/Doku/flaws.md +++ b/Doku/flaws.md @@ -15,7 +15,7 @@ Diese Datei dokumentiert eine ehrliche und schonungslose technische Analyse alle | **FLAW-05** | Frontend DOM | Fehlende Virtualisierung im Outliner-Baum | **Mittel** | Frame-Drops und Lags bei Dokumenten mit >500 Blöcken auf Mobilgeräten | | **FLAW-06** | SQLite Concurrency | Single-Writer-Bottleneck bei hohem parallelem Schreibaufkommen | **Mittel** | `SQLITE_BUSY` Fehler bei vielen gleichzeitigen Team-Nutzern | | **FLAW-07** | Benachrichtigungen | Fehlende Dead-Letter-Queue für Webhook- & E-Mail-Alarme | **Niedrig** | Verlorene Benachrichtigungen bei temporärem Ausfall von Drittsystemen | -| **FLAW-08** | Sicherheit / Auth | Statische JWT-Tokens ohne rotierende Refresh-Tokens & Blacklisting | **Niedrig** | Keine sofortige Sitzungsinvalidierung bei Rechteentzug | +| **FLAW-08** | Sicherheit / Auth | Statische JWT-Tokens ohne Blacklisting & Token-Versionierung | **Gelöst (v2.2)** | Behoben durch `token_version`-Spalte in `users`, `revoked_tokens`-Sperrliste und sofortige Sitzungsinvalidierung bei Passwortänderung/Logout/Rechteentzug | --- @@ -112,13 +112,15 @@ Diese Datei dokumentiert eine ehrliche und schonungslose technische Analyse alle --- -### FLAW-08: Statische JWT-Tokens ohne Blacklisting -- **Betroffene Komponenten:** [`architecture.md`](./architecture.md), `internal/api/auth.go` -- **Ursache:** Nach dem Login wird ein JWT ausgestellt, das clientseitig im `localStorage` gespeichert wird. Der Server prüft lediglich die kryptografische Signatur des Tokens, führt aber keine serverseitige Sitzungsliste. -- **Folgen:** Wird einem Benutzer die Berechtigung entzogen oder ändert er sein Passwort, bleibt ein bereits ausgestellter Token bis zu seinem Ablaufdatum (z. B. 7 Tage) gültig. -- **Empfohlene Lösung:** - 1. Einführung einer `revoked_tokens`-Tabelle oder einer `token_version`-Spalte in der `users`-Tabelle. - 2. Implementierung von kurzlebigen Access-Tokens (15 Min.) in Verbindung mit rotierenden Refresh-Tokens in `httpOnly`-Cookies. +### FLAW-08: [GELÖST / IMPLEMENTIERT] JWT-Sicherheit, Token-Blacklisting & Token-Versionierung +- **Status:** **Gelöst via `token_version`-Spalte in `users`, `revoked_tokens`-Blacklist und serverseitige Invalidierungs-Endpunkte** +- **Betroffene Komponenten:** [`architecture.md`](./architecture.md), [`database.md`](./database.md), `internal/api/server.go`, `internal/auth/auth.go`, `internal/storage/sqlite.go`, `frontend/src/lib/api.ts` +- **Ursache (Historisch):** Nach dem Login wurde ein statisches JWT ausgestellt, das lediglich kryptografisch signiert war. Bei Passwortänderungen, Rechteänderungen oder Konto-Deaktivierung blieben bereits ausgestellte Tokens bis zum Ablaufdatum gültig. +- **Implementierte Lösung:** + 1. **`token_version`-Spalte in `users`:** Jedes ausgestellte JWT führt die `tokenVersion` des Benutzers im Claim. Bei einer Passwortänderung (`UpdateUserPassword`), Rechterückstufung oder Deaktivierung wird `token_version = token_version + 1` atomar hochgezählt. Alle alten Tokens auf beliebigen Geräten werden sofort mit `401 Unauthorized` abgewiesen. + 2. **Tabelle `revoked_tokens` für Einzelsitzungen:** Beim Klick auf Logout (`POST /api/v1/auth/logout`) wird das spezifische JWT via SHA-256-Hash in die Sperrliste eingetragen, bis sein reguläres Ablaufdatum erreicht ist. + 3. **"Alle Sitzungen beenden" (`POST /api/v1/auth/revoke-all`):** Ermöglicht es dem Benutzer in den Einstellungen ("Profil & Sicherheit") oder einem Administrator, mit einem Klick alle aktiven Sitzungen auf sämtlichen Browsern und Mobilgeräten zu terminieren. + 4. **Konsistente Prüfung bei WebSockets & SSE:** Auch bei Echtzeit-Verbindungen (`/api/v1/ws`, `/api/v1/ws/docs/:id`, `/api/v1/events`) wird der Benutzerstatus, die Sperrliste und die aktuelle `token_version` überprüft. Outdated oder gesperrte Clients werden sofort abgewiesen. --- diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0cc26a1..fb1374a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -567,7 +567,11 @@ export const App: React.FC = () => { const handleLogout = async () => { triggerHaptic(20); disconnectWebSocket(); - clearToken(); + try { + await api.logout(); + } catch { + clearToken(); + } setUser(null); setShowProfileMenu(false); setActiveTab('triage'); diff --git a/frontend/src/features/settings/UserSettingsView.tsx b/frontend/src/features/settings/UserSettingsView.tsx index ec8e15b..4c2bbf0 100644 --- a/frontend/src/features/settings/UserSettingsView.tsx +++ b/frontend/src/features/settings/UserSettingsView.tsx @@ -49,6 +49,7 @@ export const UserSettingsView: React.FC = ({ const [newPassword, setNewPassword] = useState(''); const [profileMsg, setProfileMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null); const [savingProfile, setSavingProfile] = useState(false); + const [revokingSessions, setRevokingSessions] = useState(false); const [isProcessingAvatar, setIsProcessingAvatar] = useState(false); const fileInputRef = useRef(null); @@ -155,6 +156,21 @@ export const UserSettingsView: React.FC = ({ } }; + const handleRevokeAllSessions = async () => { + if (!window.confirm('Möchtest du wirklich alle aktiven Sitzungen auf allen Geräten beenden? Du wirst dabei auch auf diesem Gerät abgemeldet.')) { + return; + } + setRevokingSessions(true); + try { + await api.revokeAllSessions(); + await api.logout(); + window.location.reload(); + } catch (err: any) { + setProfileMsg({ type: 'err', text: err.message || 'Fehler beim Beenden der Sitzungen' }); + setRevokingSessions(false); + } + }; + // --- Handlers: Alarms --- const handleSaveAlarms = async (e: React.FormEvent) => { e.preventDefault(); @@ -530,6 +546,32 @@ export const UserSettingsView: React.FC = ({ + + {/* Sessions & Security */} +
+
+

Aktive Sitzungen & Sicherheit

+

+ Sitzungen werden durch kryptografische JWTs geschützt. Bei einer Passwortänderung werden alle alten Sitzungen automatisch ungültig. +

+
+
+
+

Alle Sitzungen sofort beenden

+

+ Invalidiert alle ausgestellten Tokens auf allen Geräten und Browsern. +

+
+ +
+
)} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 415b921..6c258e0 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -117,6 +117,18 @@ export const api = { return request('/api/v1/auth/status'); }, + async logout(): Promise { + try { + await request<{ success: boolean }>('/api/v1/auth/logout', { method: 'POST' }); + } finally { + clearToken(); + } + }, + + async revokeAllSessions(): Promise { + await request<{ success: boolean }>('/api/v1/auth/revoke-all', { method: 'POST' }); + }, + async completeSetup(payload: { email: string; password: string; diff --git a/internal/api/server.go b/internal/api/server.go index fa47d8d..78645a5 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -79,6 +79,8 @@ func (s *Server) Router() http.Handler { r.Use(s.authMiddleware) r.Get("/api/v1/auth/me", s.handleMe) + r.Post("/api/v1/auth/logout", s.handleLogout) + r.Post("/api/v1/auth/revoke-all", s.handleRevokeAllSessions) r.Patch("/api/v1/auth/settings", s.handleUpdateUserSettings) r.Post("/api/v1/auth/alarms/test", s.handleTestUserAlarm) r.Get("/api/v1/users/directory", s.handleGetUsersDirectory) @@ -304,10 +306,14 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler { } claims, err := auth.ValidateToken(s.cfg.JWTSecret, tokenStr) if err == nil && claims != nil { - user, userErr := s.store.GetUserByID(claims.UserID) - if userErr == nil && user != nil && user.IsActive { - next.ServeHTTP(w, r.WithContext(auth.WithUserID(r.Context(), user.ID))) - return + if !s.store.IsTokenRevoked(tokenStr) { + user, userErr := s.store.GetUserByID(claims.UserID) + if userErr == nil && user != nil && user.IsActive { + if claims.TokenVersion == 0 || claims.TokenVersion == user.TokenVersion { + next.ServeHTTP(w, r.WithContext(auth.WithUserID(r.Context(), user.ID))) + return + } + } } } } @@ -443,7 +449,7 @@ func (s *Server) handleFirstBootSetup(w http.ResponseWriter, r *http.Request) { _ = s.store.SeedStarterContent(user) } - token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName) + token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion) if err != nil { writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden") return @@ -519,7 +525,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { } } - token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName) + token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion) if err != nil { writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden") return @@ -566,7 +572,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { return } - token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName) + token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion) if err != nil { writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden") return @@ -1385,8 +1391,19 @@ func (s *Server) authenticateUserFromReq(r *http.Request) string { } } if token != "" { - if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil { - return claims.UserID + if strings.HasPrefix(token, "mindsift_sec_") { + if user, err := s.store.ValidateAPIKeyAndGetUser(token); err == nil && user != nil && user.IsActive { + return user.ID + } + } + if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil && claims != nil { + if !s.store.IsTokenRevoked(token) { + if user, err := s.store.GetUserByID(claims.UserID); err == nil && user != nil && user.IsActive { + if claims.TokenVersion == 0 || claims.TokenVersion == user.TokenVersion { + return claims.UserID + } + } + } } } return "" @@ -1686,17 +1703,9 @@ func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) { roomID = r.URL.Query().Get("spaceId") } - token := r.URL.Query().Get("token") - if token == "" { - if authHdr := r.Header.Get("Authorization"); strings.HasPrefix(authHdr, "Bearer ") { - token = strings.TrimPrefix(authHdr, "Bearer ") - } - } - userID := "anonymous" - if token != "" { - if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil { - userID = claims.UserID - } + userID := s.authenticateUserFromReq(r) + if userID == "" { + userID = "anonymous" } s.wsHub.ServeWS(w, r, roomID, userID) } @@ -1707,17 +1716,9 @@ func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) { roomID = r.URL.Query().Get("spaceId") } - token := r.URL.Query().Get("token") - if token == "" { - if authHdr := r.Header.Get("Authorization"); strings.HasPrefix(authHdr, "Bearer ") { - token = strings.TrimPrefix(authHdr, "Bearer ") - } - } - userID := "anonymous" - if token != "" { - if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil { - userID = claims.UserID - } + userID := s.authenticateUserFromReq(r) + if userID == "" { + userID = "anonymous" } var initialRooms []string @@ -1933,6 +1934,33 @@ func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) { // --- USER SETTINGS & ALARMS --- +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + userID := auth.GetUserID(r.Context()) + authHeader := r.Header.Get("Authorization") + if strings.HasPrefix(authHeader, "Bearer ") { + tokenStr := strings.TrimPrefix(authHeader, "Bearer ") + if claims, err := auth.ValidateToken(s.cfg.JWTSecret, tokenStr); err == nil && claims != nil { + var exp time.Time + if claims.ExpiresAt != nil { + exp = claims.ExpiresAt.Time + } else { + exp = time.Now().Add(30 * 24 * time.Hour) + } + _ = s.store.RevokeToken(tokenStr, userID, exp) + } + } + writeJSON(w, http.StatusOK, map[string]bool{"success": true}) +} + +func (s *Server) handleRevokeAllSessions(w http.ResponseWriter, r *http.Request) { + userID := auth.GetUserID(r.Context()) + if err := s.store.RevokeAllUserTokens(userID); err != nil { + writeError(w, http.StatusInternalServerError, "Sitzungen konnten nicht beendet werden") + return + } + writeJSON(w, http.StatusOK, map[string]bool{"success": true}) +} + func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) var req struct { diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 7db2966..f4642cc 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -45,7 +45,7 @@ func setupTestServer(t *testing.T) (*Server, *storage.Store, string) { hub := ws.NewHub() srv := NewServer(cfg, store, hub, nil) - token, err := auth.GenerateToken(cfg.JWTSecret, user.ID, user.Email, user.DisplayName) + token, err := auth.GenerateToken(cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion) if err != nil { t.Fatalf("failed to generate token: %v", err) } @@ -219,7 +219,7 @@ func TestQuickDumpToTriageAndDirectTargets(t *testing.T) { router := srv.Router() // Verify stale token from non-existent user is rejected (401) - staleToken, _ := auth.GenerateToken("test-jwt-secret-key", "usr_non_existent", "old@mindsift.local", "Old") + staleToken, _ := auth.GenerateToken("test-jwt-secret-key", "usr_non_existent", "old@mindsift.local", "Old", 1) req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil) req.Header.Set("Authorization", "Bearer "+staleToken) rec := httptest.NewRecorder() @@ -548,11 +548,11 @@ func TestSpaceMembersDirectInvitationAPI(t *testing.T) { t.Fatalf("failed to get normal user: %v", err) } - adminToken, err := auth.GenerateToken(cfg.JWTSecret, adminUser.ID, adminUser.Email, adminUser.DisplayName) + adminToken, err := auth.GenerateToken(cfg.JWTSecret, adminUser.ID, adminUser.Email, adminUser.DisplayName, adminUser.TokenVersion) if err != nil { t.Fatalf("failed to generate token: %v", err) } - userToken, err := auth.GenerateToken(cfg.JWTSecret, normalUser.ID, normalUser.Email, normalUser.DisplayName) + userToken, err := auth.GenerateToken(cfg.JWTSecret, normalUser.ID, normalUser.Email, normalUser.DisplayName, normalUser.TokenVersion) if err != nil { t.Fatalf("failed to generate token: %v", err) } @@ -663,7 +663,7 @@ func TestWorkspaceTagsAPI(t *testing.T) { router := srv.Router() user, _ := store.CreateUser("api_tag_user@example.com", "hash", "API Tag User", "member") - token, _ := auth.GenerateToken(srv.cfg.JWTSecret, user.ID, user.Email, user.DisplayName) + token, _ := auth.GenerateToken(srv.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion) // Create document with hashtags blocks := []domain.DocumentBlock{ @@ -863,7 +863,7 @@ func TestDocRoomLiveStateAndDebouncedFlush(t *testing.T) { if err != nil { t.Fatalf("CreateUser failed: %v", err) } - token, _ := auth.GenerateToken(cfg.JWTSecret, user.ID, user.Email, user.Role) + token, _ := auth.GenerateToken(cfg.JWTSecret, user.ID, user.Email, user.Role, user.TokenVersion) doc, err := store.CreateDocument(user.ID, "", "Initial Title", "📄", "", nil, nil, false) if err != nil { @@ -997,7 +997,7 @@ func TestDecoupledFolderSharingAPI(t *testing.T) { if err != nil { t.Fatalf("failed to create user B: %v", err) } - tokenB, err := auth.GenerateToken(srv.cfg.JWTSecret, userB.ID, userB.Email, userB.DisplayName) + tokenB, err := auth.GenerateToken(srv.cfg.JWTSecret, userB.ID, userB.Email, userB.DisplayName, userB.TokenVersion) if err != nil { t.Fatalf("failed to generate token B: %v", err) } @@ -1150,6 +1150,152 @@ func TestDecoupledFolderSharingAPI(t *testing.T) { } } +func TestTokenBlacklistingAndTokenVersionRevocation(t *testing.T) { + srv, store, _ := setupTestServer(t) + router := srv.Router() + + // 1. Create a fresh user with known password + password := "initialPassword123" + hash, _ := auth.HashPassword(password) + user, err := store.CreateUser("revocation_test@example.com", hash, "Revocation Tester", "member") + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + + // 2. Perform login to get initial JWT + loginBody, _ := json.Marshal(map[string]string{ + "login": user.Email, + "password": password, + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("login failed: %s", rec.Body.String()) + } + var loginResp struct { + Token string `json:"token"` + } + _ = json.Unmarshal(rec.Body.Bytes(), &loginResp) + token1 := loginResp.Token + if token1 == "" { + t.Fatalf("expected token in login response") + } + + // 3. Verify token1 works + req = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil) + req.Header.Set("Authorization", "Bearer "+token1) + rec = httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("token1 should be valid, got %d", rec.Code) + } + + // 4. Test explicit logout (blacklists token1) + req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/logout", nil) + req.Header.Set("Authorization", "Bearer "+token1) + rec = httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("logout failed: %s", rec.Body.String()) + } + + // Token1 must now be blacklisted and rejected (401) + req = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil) + req.Header.Set("Authorization", "Bearer "+token1) + rec = httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 Unauthorized for blacklisted token1, got %d", rec.Code) + } + + // Also check authenticateUserFromReq rejects token1 + req = httptest.NewRequest(http.MethodGet, "/api/v1/ws?token="+token1, nil) + if uid := srv.authenticateUserFromReq(req); uid != "" { + t.Fatalf("authenticateUserFromReq should reject blacklisted token1, got uid %q", uid) + } + + // 5. Login again to get token2 + req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody)) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + router.ServeHTTP(rec, req) + var loginResp2 struct { + Token string `json:"token"` + } + _ = json.Unmarshal(rec.Body.Bytes(), &loginResp2) + token2 := loginResp2.Token + + // Token2 works + req = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil) + req.Header.Set("Authorization", "Bearer "+token2) + rec = httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("token2 should be valid, got %d", rec.Code) + } + + // 6. Change password via UpdateUserPassword -> increments token_version + newPassword := "newSecretPassword456" + newHash, _ := auth.HashPassword(newPassword) + if err := store.UpdateUserPassword(user.ID, newHash); err != nil { + t.Fatalf("failed to update password: %v", err) + } + + // Token2 must now be REJECTED (401) because its token_version is outdated! + req = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil) + req.Header.Set("Authorization", "Bearer "+token2) + rec = httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 Unauthorized for token2 after password change, got %d", rec.Code) + } + + // 7. Login with new password to get token3 + loginNewBody, _ := json.Marshal(map[string]string{ + "login": user.Email, + "password": newPassword, + }) + req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginNewBody)) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + router.ServeHTTP(rec, req) + var loginResp3 struct { + Token string `json:"token"` + } + _ = json.Unmarshal(rec.Body.Bytes(), &loginResp3) + token3 := loginResp3.Token + + // Token3 works + req = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil) + req.Header.Set("Authorization", "Bearer "+token3) + rec = httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("token3 should be valid, got %d", rec.Code) + } + + // 8. Test revoke-all endpoint -> increments token_version and invalidates all existing sessions + req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/revoke-all", nil) + req.Header.Set("Authorization", "Bearer "+token3) + rec = httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("revoke-all failed: %s", rec.Body.String()) + } + + // Token3 must now be REJECTED (401) + req = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil) + req.Header.Set("Authorization", "Bearer "+token3) + rec = httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 Unauthorized for token3 after revoke-all, got %d", rec.Code) + } +} + + diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 329c5ef..dc255f7 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -18,9 +18,10 @@ type contextKey string const UserIDContextKey contextKey = "mindsift_user_id" type Claims struct { - UserID string `json:"userId"` - Email string `json:"email"` - DisplayName string `json:"displayName"` + UserID string `json:"userId"` + Email string `json:"email"` + DisplayName string `json:"displayName"` + TokenVersion int `json:"tokenVersion"` jwt.RegisteredClaims } @@ -34,14 +35,24 @@ func CheckPassword(password, hash string) bool { return err == nil } -func GenerateToken(secret, userID, email, displayName string) (string, error) { +func GenerateToken(secret, userID, email, displayName string, tokenVersion int) (string, error) { + if tokenVersion <= 0 { + tokenVersion = 1 + } + b := make([]byte, 16) + _, _ = rand.Read(b) + jti := hex.EncodeToString(b) + + now := time.Now() claims := &Claims{ - UserID: userID, - Email: email, - DisplayName: displayName, + UserID: userID, + Email: email, + DisplayName: displayName, + TokenVersion: tokenVersion, RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(time.Now().Add(30 * 24 * time.Hour)), - IssuedAt: jwt.NewNumericDate(time.Now()), + ID: jti, + ExpiresAt: jwt.NewNumericDate(now.Add(30 * 24 * time.Hour)), + IssuedAt: jwt.NewNumericDate(now), Issuer: "mindsift", }, } diff --git a/internal/domain/models.go b/internal/domain/models.go index f343c4a..c2241f7 100644 --- a/internal/domain/models.go +++ b/internal/domain/models.go @@ -101,6 +101,7 @@ type User struct { AvatarURL string `json:"avatarUrl,omitempty"` Role string `json:"role"` // admin | member IsActive bool `json:"isActive"` + TokenVersion int `json:"tokenVersion"` AlarmChannels []string `json:"alarmChannels"` // email | discord | webhook AlarmEmail string `json:"alarmEmail"` DiscordWebhookURL string `json:"discordWebhookUrl"` diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index c744eb6..0d41584 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -72,6 +72,7 @@ func (s *Store) migrate() error { display_name TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'member', is_active INTEGER NOT NULL DEFAULT 1, + token_version INTEGER NOT NULL DEFAULT 1, alarm_channels TEXT NOT NULL DEFAULT 'discord', alarm_email TEXT NOT NULL DEFAULT '', discord_webhook_url TEXT NOT NULL DEFAULT '', @@ -307,6 +308,16 @@ func (s *Store) migrate() error { key TEXT PRIMARY KEY, value TEXT NOT NULL ); + + CREATE TABLE IF NOT EXISTS revoked_tokens ( + token_hash TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + expires_at TEXT NOT NULL, + revoked_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_revoked_tokens_expires ON revoked_tokens(expires_at); + CREATE INDEX IF NOT EXISTS idx_revoked_tokens_user ON revoked_tokens(user_id); ` if _, err := s.db.Exec(schema); err != nil { return err @@ -318,6 +329,7 @@ func (s *Store) migrate() error { "CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);", "ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'member';", "ALTER TABLE users ADD COLUMN is_active INTEGER NOT NULL DEFAULT 1;", + "ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAULT 1;", "ALTER TABLE users ADD COLUMN alarm_channels TEXT NOT NULL DEFAULT 'discord';", "ALTER TABLE users ADD COLUMN alarm_email TEXT NOT NULL DEFAULT '';", "ALTER TABLE users ADD COLUMN discord_webhook_url TEXT NOT NULL DEFAULT '';", @@ -536,7 +548,7 @@ func (s *Store) EnsureDefaultUser() (*domain.User, error) { // --- USERS --- -const userSelectCols = `id, email, username, password_hash, display_name, role, is_active, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, avatar_url, created_at` +const userSelectCols = `id, email, username, password_hash, display_name, role, is_active, token_version, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, avatar_url, created_at` func parseAlarmChannels(raw string) []string { parts := strings.Split(raw, ",") @@ -558,13 +570,16 @@ func scanUser(row interface{ Scan(dest ...any) error }) (*domain.User, error) { var channelsRaw string err := row.Scan( &u.ID, &u.Email, &u.Username, &u.PasswordHash, &u.DisplayName, - &u.Role, &isActiveInt, &channelsRaw, &u.AlarmEmail, + &u.Role, &isActiveInt, &u.TokenVersion, &channelsRaw, &u.AlarmEmail, &u.DiscordWebhookURL, &u.CustomWebhookURL, &u.NotifyBeforeMinutes, &u.AvatarURL, &u.CreatedAt, ) if err != nil { return nil, err } u.IsActive = isActiveInt != 0 + if u.TokenVersion <= 0 { + u.TokenVersion = 1 + } if u.Role == "" { u.Role = "member" } @@ -604,6 +619,7 @@ func (s *Store) CreateUserWithUsername(email, username, passwordHash, displayNam DisplayName: strings.TrimSpace(displayName), Role: role, IsActive: true, + TokenVersion: 1, AlarmChannels: []string{}, AlarmEmail: cleanEmail, DiscordWebhookURL: "", @@ -613,8 +629,8 @@ func (s *Store) CreateUserWithUsername(email, username, passwordHash, displayNam CreatedAt: domain.NowISO(), } _, err := s.db.Exec( - `INSERT INTO users (id, email, username, password_hash, display_name, role, is_active, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, avatar_url, created_at) - VALUES (?, ?, ?, ?, ?, ?, 1, '', ?, '', '', 30, '', ?)`, + `INSERT INTO users (id, email, username, password_hash, display_name, role, is_active, token_version, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, avatar_url, created_at) + VALUES (?, ?, ?, ?, ?, ?, 1, 1, '', ?, '', '', 30, '', ?)`, u.ID, u.Email, u.Username, u.PasswordHash, u.DisplayName, u.Role, u.AlarmEmail, u.CreatedAt, ) if err != nil { @@ -811,14 +827,17 @@ func (s *Store) UpdateUserFull( if avatarURL != nil { u.AvatarURL = strings.TrimSpace(*avatarURL) } + if (role != nil && *role != u.Role) || (isActive != nil && !*isActive && u.IsActive) { + u.TokenVersion++ + } activeInt := 0 if u.IsActive { activeInt = 1 } channelsStr := strings.Join(u.AlarmChannels, ",") _, err = s.db.Exec( - `UPDATE users SET display_name = ?, email = ?, role = ?, is_active = ?, alarm_channels = ?, alarm_email = ?, discord_webhook_url = ?, custom_webhook_url = ?, notify_before_minutes = ?, avatar_url = ? WHERE id = ?`, - u.DisplayName, u.Email, u.Role, activeInt, channelsStr, u.AlarmEmail, u.DiscordWebhookURL, u.CustomWebhookURL, u.NotifyBeforeMinutes, u.AvatarURL, userID, + `UPDATE users SET display_name = ?, email = ?, role = ?, is_active = ?, token_version = ?, alarm_channels = ?, alarm_email = ?, discord_webhook_url = ?, custom_webhook_url = ?, notify_before_minutes = ?, avatar_url = ? WHERE id = ?`, + u.DisplayName, u.Email, u.Role, activeInt, u.TokenVersion, channelsStr, u.AlarmEmail, u.DiscordWebhookURL, u.CustomWebhookURL, u.NotifyBeforeMinutes, u.AvatarURL, userID, ) if err != nil { return nil, err @@ -827,15 +846,45 @@ func (s *Store) UpdateUserFull( } func (s *Store) UpdateUserPassword(userID, passwordHash string) error { - _, err := s.db.Exec(`UPDATE users SET password_hash = ? WHERE id = ?`, passwordHash, userID) + _, err := s.db.Exec(`UPDATE users SET password_hash = ?, token_version = token_version + 1 WHERE id = ?`, passwordHash, userID) return err } func (s *Store) DeleteUser(userID string) error { + _, _ = s.db.Exec(`DELETE FROM revoked_tokens WHERE user_id = ?`, userID) _, err := s.db.Exec(`DELETE FROM users WHERE id = ?`, userID) return err } +// RevokeToken adds an individual JWT to the blacklist until its expiration date +func (s *Store) RevokeToken(tokenStr, userID string, expiresAt time.Time) error { + hash := auth.HashAPIKey(tokenStr) + _, err := s.db.Exec( + `INSERT OR REPLACE INTO revoked_tokens (token_hash, user_id, expires_at, revoked_at) VALUES (?, ?, ?, ?)`, + hash, userID, expiresAt.UTC().Format(time.RFC3339), time.Now().UTC().Format(time.RFC3339), + ) + // Idempotently prune old expired tokens + _, _ = s.db.Exec(`DELETE FROM revoked_tokens WHERE expires_at < ?`, time.Now().UTC().Format(time.RFC3339)) + return err +} + +// IsTokenRevoked checks if a specific JWT token has been explicitly blacklisted +func (s *Store) IsTokenRevoked(tokenStr string) bool { + hash := auth.HashAPIKey(tokenStr) + var count int + err := s.db.QueryRow( + `SELECT COUNT(1) FROM revoked_tokens WHERE token_hash = ? AND expires_at >= ?`, + hash, time.Now().UTC().Format(time.RFC3339), + ).Scan(&count) + return err == nil && count > 0 +} + +// RevokeAllUserTokens increments the user's token_version, instantly invalidating all existing JWTs for that user +func (s *Store) RevokeAllUserTokens(userID string) error { + _, err := s.db.Exec(`UPDATE users SET token_version = token_version + 1 WHERE id = ?`, userID) + return err +} + // --- THOUGHTS & TRIAGE --- func (s *Store) CreateThought(userID, content, priority, source, dueDate string) (*domain.Thought, error) {