fix(security): resolve FLAW-08 with token_version invalidation and jwt blacklist
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m0s

This commit is contained in:
sandrodach committed 2026-10-27 12:09:15 +01:00
1 parent f7e70b649f
commit 50b017f387
10 files changed
+372 -63

No files matched your search

+14
View File
@@ -58,6 +58,7 @@ CREATE TABLE IF NOT EXISTS users (
avatar_url TEXT NOT NULL DEFAULT '', -- Profilbild URL oder leer avatar_url TEXT NOT NULL DEFAULT '', -- Profilbild URL oder leer
role TEXT NOT NULL DEFAULT 'member', -- 'admin' | 'member' role TEXT NOT NULL DEFAULT 'member', -- 'admin' | 'member'
is_active INTEGER NOT NULL DEFAULT 1, -- 1 = aktiv, 0 = gesperrt 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_channels TEXT NOT NULL DEFAULT 'discord', -- Kommagetrennt: 'email', 'discord', 'webhook'
alarm_email TEXT NOT NULL DEFAULT '', -- Ziel-E-Mail für Alarme alarm_email TEXT NOT NULL DEFAULT '', -- Ziel-E-Mail für Alarme
discord_webhook_url TEXT NOT NULL DEFAULT '', -- Discord Webhook URL 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 ### 2.2 Gruppen & Organisations-Teams
+10 -8
View File
@@ -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-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-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-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 ### FLAW-08: [GELÖST / IMPLEMENTIERT] JWT-Sicherheit, Token-Blacklisting & Token-Versionierung
- **Betroffene Komponenten:** [`architecture.md`](./architecture.md), `internal/api/auth.go` - **Status:** **Gelöst via `token_version`-Spalte in `users`, `revoked_tokens`-Blacklist und serverseitige Invalidierungs-Endpunkte**
- **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. - **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`
- **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. - **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.
- **Empfohlene Lösung:** - **Implementierte Lösung:**
1. Einführung einer `revoked_tokens`-Tabelle oder einer `token_version`-Spalte in der `users`-Tabelle. 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. Implementierung von kurzlebigen Access-Tokens (15 Min.) in Verbindung mit rotierenden Refresh-Tokens in `httpOnly`-Cookies. 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.
--- ---
+5 -1
View File
@@ -567,7 +567,11 @@ export const App: React.FC = () => {
const handleLogout = async () => { const handleLogout = async () => {
triggerHaptic(20); triggerHaptic(20);
disconnectWebSocket(); disconnectWebSocket();
clearToken(); try {
await api.logout();
} catch {
clearToken();
}
setUser(null); setUser(null);
setShowProfileMenu(false); setShowProfileMenu(false);
setActiveTab('triage'); setActiveTab('triage');
@@ -49,6 +49,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
const [newPassword, setNewPassword] = useState(''); const [newPassword, setNewPassword] = useState('');
const [profileMsg, setProfileMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null); const [profileMsg, setProfileMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
const [savingProfile, setSavingProfile] = useState(false); const [savingProfile, setSavingProfile] = useState(false);
const [revokingSessions, setRevokingSessions] = useState(false);
const [isProcessingAvatar, setIsProcessingAvatar] = useState(false); const [isProcessingAvatar, setIsProcessingAvatar] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@@ -155,6 +156,21 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
} }
}; };
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 --- // --- Handlers: Alarms ---
const handleSaveAlarms = async (e: React.FormEvent) => { const handleSaveAlarms = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -530,6 +546,32 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
</button> </button>
</div> </div>
</form> </form>
{/* Sessions & Security */}
<div className="pt-5 border-t border-border space-y-3">
<div>
<h3 className="text-xs font-bold text-main">Aktive Sitzungen & Sicherheit</h3>
<p className="text-[11px] text-muted">
Sitzungen werden durch kryptografische JWTs geschützt. Bei einer Passwortänderung werden alle alten Sitzungen automatisch ungültig.
</p>
</div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 p-3.5 rounded-card border border-border bg-canvas">
<div className="text-xs">
<p className="font-semibold text-main">Alle Sitzungen sofort beenden</p>
<p className="text-[11px] text-faint">
Invalidiert alle ausgestellten Tokens auf allen Geräten und Browsern.
</p>
</div>
<button
type="button"
onClick={handleRevokeAllSessions}
disabled={revokingSessions}
className="btn-danger h-8 px-3 text-xs shrink-0 cursor-pointer"
>
{revokingSessions ? 'Beende…' : 'Alle Sitzungen beenden'}
</button>
</div>
</div>
</div> </div>
)} )}
+12
View File
@@ -117,6 +117,18 @@ export const api = {
return request<AuthStatus>('/api/v1/auth/status'); return request<AuthStatus>('/api/v1/auth/status');
}, },
async logout(): Promise<void> {
try {
await request<{ success: boolean }>('/api/v1/auth/logout', { method: 'POST' });
} finally {
clearToken();
}
},
async revokeAllSessions(): Promise<void> {
await request<{ success: boolean }>('/api/v1/auth/revoke-all', { method: 'POST' });
},
async completeSetup(payload: { async completeSetup(payload: {
email: string; email: string;
password: string; password: string;
+59 -31
View File
@@ -79,6 +79,8 @@ func (s *Server) Router() http.Handler {
r.Use(s.authMiddleware) r.Use(s.authMiddleware)
r.Get("/api/v1/auth/me", s.handleMe) 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.Patch("/api/v1/auth/settings", s.handleUpdateUserSettings)
r.Post("/api/v1/auth/alarms/test", s.handleTestUserAlarm) r.Post("/api/v1/auth/alarms/test", s.handleTestUserAlarm)
r.Get("/api/v1/users/directory", s.handleGetUsersDirectory) 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) claims, err := auth.ValidateToken(s.cfg.JWTSecret, tokenStr)
if err == nil && claims != nil { if err == nil && claims != nil {
user, userErr := s.store.GetUserByID(claims.UserID) if !s.store.IsTokenRevoked(tokenStr) {
if userErr == nil && user != nil && user.IsActive { user, userErr := s.store.GetUserByID(claims.UserID)
next.ServeHTTP(w, r.WithContext(auth.WithUserID(r.Context(), user.ID))) if userErr == nil && user != nil && user.IsActive {
return 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) _ = 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 { if err != nil {
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden") writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
return 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 { if err != nil {
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden") writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
return return
@@ -566,7 +572,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
return 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 { if err != nil {
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden") writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
return return
@@ -1385,8 +1391,19 @@ func (s *Server) authenticateUserFromReq(r *http.Request) string {
} }
} }
if token != "" { if token != "" {
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil { if strings.HasPrefix(token, "mindsift_sec_") {
return claims.UserID 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 "" return ""
@@ -1686,17 +1703,9 @@ func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
roomID = r.URL.Query().Get("spaceId") roomID = r.URL.Query().Get("spaceId")
} }
token := r.URL.Query().Get("token") userID := s.authenticateUserFromReq(r)
if token == "" { if userID == "" {
if authHdr := r.Header.Get("Authorization"); strings.HasPrefix(authHdr, "Bearer ") { userID = "anonymous"
token = strings.TrimPrefix(authHdr, "Bearer ")
}
}
userID := "anonymous"
if token != "" {
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil {
userID = claims.UserID
}
} }
s.wsHub.ServeWS(w, r, roomID, userID) 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") roomID = r.URL.Query().Get("spaceId")
} }
token := r.URL.Query().Get("token") userID := s.authenticateUserFromReq(r)
if token == "" { if userID == "" {
if authHdr := r.Header.Get("Authorization"); strings.HasPrefix(authHdr, "Bearer ") { userID = "anonymous"
token = strings.TrimPrefix(authHdr, "Bearer ")
}
}
userID := "anonymous"
if token != "" {
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil {
userID = claims.UserID
}
} }
var initialRooms []string var initialRooms []string
@@ -1933,6 +1934,33 @@ func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
// --- USER SETTINGS & ALARMS --- // --- 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) { func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context()) userID := auth.GetUserID(r.Context())
var req struct { var req struct {
+153 -7
View File
@@ -45,7 +45,7 @@ func setupTestServer(t *testing.T) (*Server, *storage.Store, string) {
hub := ws.NewHub() hub := ws.NewHub()
srv := NewServer(cfg, store, hub, nil) 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 { if err != nil {
t.Fatalf("failed to generate token: %v", err) t.Fatalf("failed to generate token: %v", err)
} }
@@ -219,7 +219,7 @@ func TestQuickDumpToTriageAndDirectTargets(t *testing.T) {
router := srv.Router() router := srv.Router()
// Verify stale token from non-existent user is rejected (401) // 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 := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
req.Header.Set("Authorization", "Bearer "+staleToken) req.Header.Set("Authorization", "Bearer "+staleToken)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@@ -548,11 +548,11 @@ func TestSpaceMembersDirectInvitationAPI(t *testing.T) {
t.Fatalf("failed to get normal user: %v", err) 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 { if err != nil {
t.Fatalf("failed to generate token: %v", err) 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 { if err != nil {
t.Fatalf("failed to generate token: %v", err) t.Fatalf("failed to generate token: %v", err)
} }
@@ -663,7 +663,7 @@ func TestWorkspaceTagsAPI(t *testing.T) {
router := srv.Router() router := srv.Router()
user, _ := store.CreateUser("api_tag_user@example.com", "hash", "API Tag User", "member") 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 // Create document with hashtags
blocks := []domain.DocumentBlock{ blocks := []domain.DocumentBlock{
@@ -863,7 +863,7 @@ func TestDocRoomLiveStateAndDebouncedFlush(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("CreateUser failed: %v", err) 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) doc, err := store.CreateDocument(user.ID, "", "Initial Title", "📄", "", nil, nil, false)
if err != nil { if err != nil {
@@ -997,7 +997,7 @@ func TestDecoupledFolderSharingAPI(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("failed to create user B: %v", err) 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 { if err != nil {
t.Fatalf("failed to generate token B: %v", err) 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)
}
}
+20 -9
View File
@@ -18,9 +18,10 @@ type contextKey string
const UserIDContextKey contextKey = "mindsift_user_id" const UserIDContextKey contextKey = "mindsift_user_id"
type Claims struct { type Claims struct {
UserID string `json:"userId"` UserID string `json:"userId"`
Email string `json:"email"` Email string `json:"email"`
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
TokenVersion int `json:"tokenVersion"`
jwt.RegisteredClaims jwt.RegisteredClaims
} }
@@ -34,14 +35,24 @@ func CheckPassword(password, hash string) bool {
return err == nil 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{ claims := &Claims{
UserID: userID, UserID: userID,
Email: email, Email: email,
DisplayName: displayName, DisplayName: displayName,
TokenVersion: tokenVersion,
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(30 * 24 * time.Hour)), ID: jti,
IssuedAt: jwt.NewNumericDate(time.Now()), ExpiresAt: jwt.NewNumericDate(now.Add(30 * 24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(now),
Issuer: "mindsift", Issuer: "mindsift",
}, },
} }
+1
View File
@@ -101,6 +101,7 @@ type User struct {
AvatarURL string `json:"avatarUrl,omitempty"` AvatarURL string `json:"avatarUrl,omitempty"`
Role string `json:"role"` // admin | member Role string `json:"role"` // admin | member
IsActive bool `json:"isActive"` IsActive bool `json:"isActive"`
TokenVersion int `json:"tokenVersion"`
AlarmChannels []string `json:"alarmChannels"` // email | discord | webhook AlarmChannels []string `json:"alarmChannels"` // email | discord | webhook
AlarmEmail string `json:"alarmEmail"` AlarmEmail string `json:"alarmEmail"`
DiscordWebhookURL string `json:"discordWebhookUrl"` DiscordWebhookURL string `json:"discordWebhookUrl"`
+56 -7
View File
@@ -72,6 +72,7 @@ func (s *Store) migrate() error {
display_name TEXT NOT NULL, display_name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member', role TEXT NOT NULL DEFAULT 'member',
is_active INTEGER NOT NULL DEFAULT 1, is_active INTEGER NOT NULL DEFAULT 1,
token_version INTEGER NOT NULL DEFAULT 1,
alarm_channels TEXT NOT NULL DEFAULT 'discord', alarm_channels TEXT NOT NULL DEFAULT 'discord',
alarm_email TEXT NOT NULL DEFAULT '', alarm_email TEXT NOT NULL DEFAULT '',
discord_webhook_url TEXT NOT NULL DEFAULT '', discord_webhook_url TEXT NOT NULL DEFAULT '',
@@ -307,6 +308,16 @@ func (s *Store) migrate() error {
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
value TEXT NOT NULL 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 { if _, err := s.db.Exec(schema); err != nil {
return err return err
@@ -318,6 +329,7 @@ func (s *Store) migrate() error {
"CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);", "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 role TEXT NOT NULL DEFAULT 'member';",
"ALTER TABLE users ADD COLUMN is_active INTEGER NOT NULL DEFAULT 1;", "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_channels TEXT NOT NULL DEFAULT 'discord';",
"ALTER TABLE users ADD COLUMN alarm_email TEXT NOT NULL DEFAULT '';", "ALTER TABLE users ADD COLUMN alarm_email TEXT NOT NULL DEFAULT '';",
"ALTER TABLE users ADD COLUMN discord_webhook_url 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 --- // --- 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 { func parseAlarmChannels(raw string) []string {
parts := strings.Split(raw, ",") parts := strings.Split(raw, ",")
@@ -558,13 +570,16 @@ func scanUser(row interface{ Scan(dest ...any) error }) (*domain.User, error) {
var channelsRaw string var channelsRaw string
err := row.Scan( err := row.Scan(
&u.ID, &u.Email, &u.Username, &u.PasswordHash, &u.DisplayName, &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, &u.DiscordWebhookURL, &u.CustomWebhookURL, &u.NotifyBeforeMinutes, &u.AvatarURL, &u.CreatedAt,
) )
if err != nil { if err != nil {
return nil, err return nil, err
} }
u.IsActive = isActiveInt != 0 u.IsActive = isActiveInt != 0
if u.TokenVersion <= 0 {
u.TokenVersion = 1
}
if u.Role == "" { if u.Role == "" {
u.Role = "member" u.Role = "member"
} }
@@ -604,6 +619,7 @@ func (s *Store) CreateUserWithUsername(email, username, passwordHash, displayNam
DisplayName: strings.TrimSpace(displayName), DisplayName: strings.TrimSpace(displayName),
Role: role, Role: role,
IsActive: true, IsActive: true,
TokenVersion: 1,
AlarmChannels: []string{}, AlarmChannels: []string{},
AlarmEmail: cleanEmail, AlarmEmail: cleanEmail,
DiscordWebhookURL: "", DiscordWebhookURL: "",
@@ -613,8 +629,8 @@ func (s *Store) CreateUserWithUsername(email, username, passwordHash, displayNam
CreatedAt: domain.NowISO(), CreatedAt: domain.NowISO(),
} }
_, err := s.db.Exec( _, 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) `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, '', ?, '', '', 30, '', ?)`, VALUES (?, ?, ?, ?, ?, ?, 1, 1, '', ?, '', '', 30, '', ?)`,
u.ID, u.Email, u.Username, u.PasswordHash, u.DisplayName, u.Role, u.AlarmEmail, u.CreatedAt, u.ID, u.Email, u.Username, u.PasswordHash, u.DisplayName, u.Role, u.AlarmEmail, u.CreatedAt,
) )
if err != nil { if err != nil {
@@ -811,14 +827,17 @@ func (s *Store) UpdateUserFull(
if avatarURL != nil { if avatarURL != nil {
u.AvatarURL = strings.TrimSpace(*avatarURL) u.AvatarURL = strings.TrimSpace(*avatarURL)
} }
if (role != nil && *role != u.Role) || (isActive != nil && !*isActive && u.IsActive) {
u.TokenVersion++
}
activeInt := 0 activeInt := 0
if u.IsActive { if u.IsActive {
activeInt = 1 activeInt = 1
} }
channelsStr := strings.Join(u.AlarmChannels, ",") channelsStr := strings.Join(u.AlarmChannels, ",")
_, err = s.db.Exec( _, 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 = ?`, `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, channelsStr, u.AlarmEmail, u.DiscordWebhookURL, u.CustomWebhookURL, u.NotifyBeforeMinutes, u.AvatarURL, userID, u.DisplayName, u.Email, u.Role, activeInt, u.TokenVersion, channelsStr, u.AlarmEmail, u.DiscordWebhookURL, u.CustomWebhookURL, u.NotifyBeforeMinutes, u.AvatarURL, userID,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@@ -827,15 +846,45 @@ func (s *Store) UpdateUserFull(
} }
func (s *Store) UpdateUserPassword(userID, passwordHash string) error { 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 return err
} }
func (s *Store) DeleteUser(userID string) error { 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) _, err := s.db.Exec(`DELETE FROM users WHERE id = ?`, userID)
return err 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 --- // --- THOUGHTS & TRIAGE ---
func (s *Store) CreateThought(userID, content, priority, source, dueDate string) (*domain.Thought, error) { func (s *Store) CreateThought(userID, content, priority, source, dueDate string) (*domain.Thought, error) {