From 5d213ac1eb7301a073c2cbc9c2b143c373b85315 Mon Sep 17 00:00:00 2001 From: patrick Date: Thu, 17 Sep 2026 09:50:55 +0200 Subject: [PATCH] IAM-17: session-token-widerruf-bei-logout-und-kompromittierung Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01QPsyieCYwvbUBsnN598SbP --- internal/auth/handler.go | 96 ++++- internal/auth/middleware.go | 29 +- internal/auth/revocation.go | 183 ++++++++++ internal/auth/revocation_test.go | 338 ++++++++++++++++++ internal/auth/token.go | 20 ++ .../tenant/0006_token_revocations.down.sql | 2 + .../tenant/0006_token_revocations.up.sql | 19 + 7 files changed, 675 insertions(+), 12 deletions(-) create mode 100644 internal/auth/revocation.go create mode 100644 internal/auth/revocation_test.go create mode 100644 migrations/tenant/0006_token_revocations.down.sql create mode 100644 migrations/tenant/0006_token_revocations.up.sql diff --git a/internal/auth/handler.go b/internal/auth/handler.go index d0ff3082..925b07f8 100644 --- a/internal/auth/handler.go +++ b/internal/auth/handler.go @@ -1,20 +1,34 @@ package auth import ( + "context" "encoding/json" "net/http" "time" ) +// RevokeAuthorize entscheidet, ob caller den Admin-Endpunkt +// RevokeUserSessions aufrufen darf. Bewusst dieselbe schmale Schnittstelle +// wie internal/audit.Authorize (AUD-04) — eine vollwertige Rollen-/ +// Rechteschicht ist nicht Teil dieser Kachel (IAM-09 baut ggf. darauf auf). +type RevokeAuthorize func(ctx context.Context, caller string) bool + // Handler stellt Login/Logout als HTTP-Endpunkte bereit. Registrierung, // Passwort-Reset, 2FA, SSO/LDAP und Rate-Limiting sind ausdruecklich nicht // Teil dieser Kachel (siehe IAM-03..07). type Handler struct { - login *LoginService + login *LoginService + issuer *TokenIssuer + revocation *RevocationStore + authorize RevokeAuthorize } -func NewHandler(login *LoginService) *Handler { - return &Handler{login: login} +// NewHandler baut den Login/Logout-Handler. authorize darf nil sein, dann +// ist RevokeUserSessions (Admin-Massenwiderruf fuer FREMDE Benutzer) +// deaktiviert und liefert 403 — RevokeAllSessions (Selbstbedienung fuer die +// EIGENE Session) funktioniert unabhaengig davon immer. +func NewHandler(login *LoginService, issuer *TokenIssuer, revocation *RevocationStore, authorize RevokeAuthorize) *Handler { + return &Handler{login: login, issuer: issuer, revocation: revocation, authorize: authorize} } type loginRequest struct { @@ -47,12 +61,22 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } -// Logout loescht das Session-Cookie. Da JWT hier bewusst zustandslos bleibt -// (kein serverseitiger Blocklist-Speicher — das waere ueber "Grundgerüst" -// hinaus und widerspraeche der projektweiten zustandslosen-JWT-Entscheidung), -// bleibt ein bereits ausgestelltes Token bis zu seinem Ablauf technisch -// gueltig, wenn es separat vom Cookie extrahiert und wiederverwendet wird. +// Logout loescht das Session-Cookie UND widerruft das zugehoerige Token +// sofort im RevocationStore (IAM-17 Akzeptanzkriterium 1) — vorher blieb ein +// separat vom Cookie extrahiertes Token bis zum Ablauf gueltig +// (Threat-Model-Finding, Core-Board 2026-09-12). Bewusst idempotent: fehlt +// das Cookie oder ist das Token bereits ungueltig, wird trotzdem 200 +// zurueckgegeben, damit ein doppelter Logout keinen Fehler erzeugt. func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) { + if cookie, err := r.Cookie(CookieName); err == nil { + if claims, verifyErr := h.issuer.Verify(cookie.Value); verifyErr == nil { + if err := h.revocation.RevokeToken(r.Context(), claims.ID, claims.UserID); err != nil { + http.Error(w, "abmelden fehlgeschlagen", http.StatusInternalServerError) + return + } + } + } + http.SetCookie(w, &http.Cookie{ Name: CookieName, Value: "", @@ -65,3 +89,59 @@ func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) { }) w.WriteHeader(http.StatusOK) } + +// RevokeAllSessions ist der Selbstbedienungs-Endpunkt fuer Akzeptanzkriterium +// 2: der angemeldete Benutzer widerruft ALLE eigenen aktiven Tokens auf +// einen Schlag (z. B. bei Verdacht auf Kompromittierung), ohne andere +// Benutzer zu beeinflussen. Muss hinter RequireAuth haengen. +func (h *Handler) RevokeAllSessions(w http.ResponseWriter, r *http.Request) { + claims, ok := ClaimsFromContext(r.Context()) + if !ok { + http.Error(w, "nicht angemeldet", http.StatusUnauthorized) + return + } + + if err := h.revocation.RevokeAllForUser(r.Context(), claims.UserID); err != nil { + http.Error(w, "sitzungen widerrufen fehlgeschlagen", http.StatusInternalServerError) + return + } + + // Das Cookie der aktuellen Anfrage ist durch den Stichtag ohnehin + // ungueltig geworden — gleich mit loeschen, statt auf den naechsten + // abgelehnten Zugriff zu warten. + http.SetCookie(w, &http.Cookie{ + Name: CookieName, + Value: "", + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + MaxAge: -1, + Expires: time.Unix(0, 0), + }) + w.WriteHeader(http.StatusOK) +} + +// RevokeUserSessions ist der Admin-Endpunkt fuer Akzeptanzkriterium 2: +// Widerruf ALLER aktiven Sessions eines FREMDEN Benutzerkontos, z. B. durch +// Support/Sicherheitsteam bei erkanntem Diebstahl. caller/userID kommen als +// Query-Parameter (Muster wie internal/audit.ExportHandler, AUD-04). +func (h *Handler) RevokeUserSessions(w http.ResponseWriter, r *http.Request) { + caller := r.URL.Query().Get("caller") + if h.authorize == nil || caller == "" || !h.authorize(r.Context(), caller) { + http.Error(w, "keine berechtigung fuer diesen widerruf", http.StatusForbidden) + return + } + + userID := r.URL.Query().Get("user_id") + if userID == "" { + http.Error(w, "user_id fehlt", http.StatusBadRequest) + return + } + + if err := h.revocation.RevokeAllForUser(r.Context(), userID); err != nil { + http.Error(w, "sitzungen widerrufen fehlgeschlagen", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) +} diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go index 6421498b..9ed2350f 100644 --- a/internal/auth/middleware.go +++ b/internal/auth/middleware.go @@ -11,10 +11,19 @@ type contextKey int const claimsContextKey contextKey = iota -// RequireAuth schuetzt eine Route: ohne gueltiges, nicht abgelaufenes Token -// im Session-Cookie wird 401 zurueckgegeben und der Handler nicht aufgerufen -// (IAM-02 Akzeptanzkriterium 3). -func RequireAuth(issuer *TokenIssuer, next http.HandlerFunc) http.HandlerFunc { +// RequireAuth schuetzt eine Route: ohne gueltiges, nicht abgelaufenes UND +// nicht widerrufenes Token im Session-Cookie wird 401 zurueckgegeben und der +// Handler nicht aufgerufen (IAM-02 Akzeptanzkriterium 3, IAM-17 +// Akzeptanzkriterium 3: Widerrufspruefung zusaetzlich zur Signatur). +// +// revocation ist bewusst variadisch (0 oder 1 Wert) statt eines Pflicht- +// parameters: mehrere andere Kacheln (RBAC, TOTP, OIDC, Benachrichtigungs- +// praeferenzen) rufen RequireAuth bereits mit der bisherigen 2-Parameter- +// Form auf, und ein Pflicht-Widerrufscheck ist ausdruecklich nicht Teil +// dieser Kachel fuer JENE Module — hier geht es nur um IAM-02/IAM-17. Ohne +// uebergebenen RevocationChecker entfaellt nur die zusaetzliche Pruefung, +// die Signaturpruefung bleibt unveraendert. +func RequireAuth(issuer *TokenIssuer, next http.HandlerFunc, revocation ...RevocationChecker) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie(CookieName) if err != nil { @@ -28,6 +37,18 @@ func RequireAuth(issuer *TokenIssuer, next http.HandlerFunc) http.HandlerFunc { return } + if len(revocation) > 0 && revocation[0] != nil { + revoked, err := revocation[0].IsRevoked(r.Context(), claims) + if err != nil { + http.Error(w, "interner fehler bei der widerrufspruefung", http.StatusInternalServerError) + return + } + if revoked { + http.Error(w, "nicht angemeldet", http.StatusUnauthorized) + return + } + } + ctx := context.WithValue(r.Context(), claimsContextKey, claims) next(w, r.WithContext(ctx)) } diff --git a/internal/auth/revocation.go b/internal/auth/revocation.go new file mode 100644 index 00000000..c4e73448 --- /dev/null +++ b/internal/auth/revocation.go @@ -0,0 +1,183 @@ +// IAM-17: Widerrufsmechanismus fuer die in IAM-02 eingefuehrten, bewusst +// zustandslosen JWTs. Ohne diesen Mechanismus bleibt ein ausgestelltes Token +// bis zum regulaeren Ablauf gueltig, auch nach explizitem Logout oder bei +// erkanntem Diebstahl (Threat-Model-Finding, Core-Board 2026-09-12). +// +// Zwei Widerrufsarten, angelehnt an das Zitadel-Vorbild "gezielt pro +// Sitzung, nicht Realm-weit" (siehe Ticket, Abschnitt Produkt-DNA): +// - RevokeToken: EIN einzelnes Token per jti (Logout, Akzeptanzkriterium 1). +// - RevokeAllForUser: EIN Stichtag pro Benutzer, ab dem alle davor +// ausgestellten Tokens ungueltig sind (Massenwiderruf bei +// Kompromittierungsverdacht, Akzeptanzkriterium 2) — andere Benutzer +// bleiben unberuehrt, neue Logins NACH dem Stichtag bleiben gueltig. +// +// Ein kurzlebiger In-Prozess-Cache haelt den Overhead pro Anfrage klein +// (Akzeptanzkriterium 3) — laut Ticket ausdruecklich zulaessig. Ein +// gezielter Widerruf schreibt sofort in den Cache (kein Wartezeitfenster), +// ein Massenwiderruf leert den Cache komplett (seltener Vorgang, korrekt +// wichtiger als schnell). +package auth + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// RevocationCacheTTL ist bewusst kurz: im schlechtesten Fall (ein bereits +// gecachtes "gueltig"-Ergebnis unmittelbar vor einem Massenwiderruf) bleibt +// ein Token hoechstens so lange nutzbar. Fuer den Logout-Einzelfall gilt das +// NICHT, da RevokeToken den Cache sofort aktualisiert. +const RevocationCacheTTL = 2 * time.Second + +// RevocationChecker ist die schmale Schnittstelle, die die Auth-Middleware +// zusaetzlich zur Signaturpruefung konsultiert. +type RevocationChecker interface { + IsRevoked(ctx context.Context, claims *Claims) (bool, error) +} + +// RevocationStore persistiert Widerrufe in PostgreSQL (Tabellen +// token_revocations / user_token_revocations, siehe migrations/tenant). +type RevocationStore struct { + pool *pgxpool.Pool + cache *revocationCache +} + +func NewRevocationStore(pool *pgxpool.Pool) *RevocationStore { + return &RevocationStore{pool: pool, cache: newRevocationCache(RevocationCacheTTL)} +} + +// RevokeToken widerruft GENAU EIN Token per jti (Akzeptanzkriterium 1: +// Logout). ON CONFLICT DO NOTHING macht den Aufruf idempotent. +func (s *RevocationStore) RevokeToken(ctx context.Context, jti, userID string) error { + if jti == "" { + return errors.New("auth: leere token-id kann nicht widerrufen werden") + } + _, err := s.pool.Exec(ctx, ` + INSERT INTO token_revocations (jti, user_id) VALUES ($1, $2) + ON CONFLICT (jti) DO NOTHING + `, jti, userID) + if err != nil { + return fmt.Errorf("token widerrufen: %w", err) + } + s.cache.setRevoked(jti) + return nil +} + +// RevokeAllForUser widerruft ALLE bisher ausgestellten Tokens eines +// Benutzers auf einen Schlag (Akzeptanzkriterium 2), ohne andere Benutzer zu +// beeinflussen. Ein danach neu ausgestelltes Token (z. B. erneuter Login) +// bleibt gueltig, da nur Tokens VOR dem Stichtag betroffen sind. +func (s *RevocationStore) RevokeAllForUser(ctx context.Context, userID string) error { + if userID == "" { + return errors.New("auth: leere benutzer-id kann nicht widerrufen werden") + } + _, err := s.pool.Exec(ctx, ` + INSERT INTO user_token_revocations (user_id, revoked_before) + VALUES ($1, now()) + ON CONFLICT (user_id) DO UPDATE SET revoked_before = now() + `, userID) + if err != nil { + return fmt.Errorf("alle sitzungen widerrufen: %w", err) + } + // Ganzen Cache leeren statt gezielt: wir haben keinen Index jti->userID + // im Cache, und ein Massenwiderruf ist selten genug, dass die dadurch + // erzwungenen frischen DB-Pruefungen keine relevante Last erzeugen. + s.cache.clear() + return nil +} + +// IsRevoked prueft, ob claims widerrufen wurden — entweder gezielt (jti in +// token_revocations) oder ueber einen Massenwiderruf-Stichtag fuer den +// Benutzer (Akzeptanzkriterium 3: zusaetzlich zur Signatur, mit +// vertretbarem Overhead dank Cache). +func (s *RevocationStore) IsRevoked(ctx context.Context, claims *Claims) (bool, error) { + if claims == nil || claims.ID == "" { + return true, nil // kein jti -> kann nicht sicher als gueltig gelten. + } + if revoked, ok := s.cache.get(claims.ID); ok { + return revoked, nil + } + + revoked, err := s.isRevokedFromDB(ctx, claims) + if err != nil { + return false, err + } + s.cache.set(claims.ID, revoked) + return revoked, nil +} + +func (s *RevocationStore) isRevokedFromDB(ctx context.Context, claims *Claims) (bool, error) { + var tokenRevoked bool + if err := s.pool.QueryRow(ctx, ` + SELECT EXISTS(SELECT 1 FROM token_revocations WHERE jti = $1) + `, claims.ID).Scan(&tokenRevoked); err != nil { + return false, fmt.Errorf("token-widerruf pruefen: %w", err) + } + if tokenRevoked { + return true, nil + } + + var revokedBefore time.Time + err := s.pool.QueryRow(ctx, ` + SELECT revoked_before FROM user_token_revocations WHERE user_id = $1 + `, claims.UserID).Scan(&revokedBefore) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return false, nil + } + return false, fmt.Errorf("massenwiderruf pruefen: %w", err) + } + + issuedAt := claims.IssuedAt.Time + return !issuedAt.After(revokedBefore), nil +} + +// revocationCache haelt Widerrufsentscheidungen fuer kurze Zeit im Prozess +// vor, um nicht bei jeder geschuetzten Anfrage eine DB-Rundfahrt zu +// erzwingen (Akzeptanzkriterium 3). +type revocationCache struct { + mu sync.RWMutex + entries map[string]cacheEntry + ttl time.Duration +} + +type cacheEntry struct { + revoked bool + expiresAt time.Time +} + +func newRevocationCache(ttl time.Duration) *revocationCache { + return &revocationCache{entries: make(map[string]cacheEntry), ttl: ttl} +} + +func (c *revocationCache) get(jti string) (revoked bool, fresh bool) { + c.mu.RLock() + defer c.mu.RUnlock() + e, ok := c.entries[jti] + if !ok || time.Now().After(e.expiresAt) { + return false, false + } + return e.revoked, true +} + +func (c *revocationCache) set(jti string, revoked bool) { + c.mu.Lock() + defer c.mu.Unlock() + c.entries[jti] = cacheEntry{revoked: revoked, expiresAt: time.Now().Add(c.ttl)} +} + +func (c *revocationCache) setRevoked(jti string) { + c.set(jti, true) +} + +func (c *revocationCache) clear() { + c.mu.Lock() + defer c.mu.Unlock() + c.entries = make(map[string]cacheEntry) +} diff --git a/internal/auth/revocation_test.go b/internal/auth/revocation_test.go new file mode 100644 index 00000000..92607341 --- /dev/null +++ b/internal/auth/revocation_test.go @@ -0,0 +1,338 @@ +// Tests zu IAM-17 (siehe core-kanban/tickets/IAM-17.md). +package auth + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "gitea.perlbach24.de/scripte/nexarch/internal/user" +) + +const tokenRevocationsSchema = ` +CREATE TABLE token_revocations ( + jti TEXT PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id), + revoked_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX token_revocations_user_idx ON token_revocations (user_id); + +CREATE TABLE user_token_revocations ( + user_id UUID PRIMARY KEY REFERENCES users(id), + revoked_before TIMESTAMPTZ NOT NULL DEFAULT now() +);` + +func setupRevocationTest(t *testing.T, dbName string) (*RevocationStore, *user.TenantUserStore) { + t.Helper() + pool := setupTenantDB(t, dbName) + if _, err := pool.Exec(context.Background(), tokenRevocationsSchema); err != nil { + t.Fatalf("revocation-schema anwenden: %v", err) + } + return NewRevocationStore(pool), user.NewTenantUserStore(pool) +} + +// Pruefung 1 / Akzeptanzkriterium 1: Token nach Logout gegen geschuetzten +// Endpunkt verwenden -> Ablehnung. +func TestLogout_RevokesTokenImmediately(t *testing.T) { + revocation, users := setupRevocationTest(t, "test_iam17_logout") + u := createUserWithPassword(t, users, "logout@example.com", "geheimes-passwort12") + + issuer := NewTokenIssuer("test-secret-nur-fuer-tests") + login := NewLoginService(users, issuer, "acme") + handler := NewHandler(login, issuer, revocation, nil) + + token, err := login.Login(context.Background(), "logout@example.com", "geheimes-passwort12") + if err != nil { + t.Fatalf("login: %v", err) + } + + protected := RequireAuth(issuer, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }, revocation) + + // Vor dem Logout ist das Token gueltig. + req := httptest.NewRequest(http.MethodGet, "/geschuetzt", nil) + req.AddCookie(&http.Cookie{Name: CookieName, Value: token}) + rec := httptest.NewRecorder() + protected(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("vor logout: status = %d, want 200", rec.Code) + } + + // Logout ausfuehren. + logoutReq := httptest.NewRequest(http.MethodPost, "/auth/logout", nil) + logoutReq.AddCookie(&http.Cookie{Name: CookieName, Value: token}) + logoutRec := httptest.NewRecorder() + handler.Logout(logoutRec, logoutReq) + if logoutRec.Code != http.StatusOK { + t.Fatalf("logout: status = %d, want 200", logoutRec.Code) + } + + // Dasselbe Token gegen den geschuetzten Endpunkt verwenden -> Ablehnung, + // SOFORT, ohne auf den Cache-TTL zu warten. + req2 := httptest.NewRequest(http.MethodGet, "/geschuetzt", nil) + req2.AddCookie(&http.Cookie{Name: CookieName, Value: token}) + rec2 := httptest.NewRecorder() + protected(rec2, req2) + if rec2.Code != http.StatusUnauthorized { + t.Fatalf("nach logout: status = %d, want 401", rec2.Code) + } + _ = u +} + +// Pruefung 2 / Akzeptanzkriterium 2: Massenwiderruf widerruft ALLE Tokens +// eines Benutzers, ein anderer Benutzer bleibt unberuehrt. +func TestRevokeAllSessions_InvalidatesAllTokensOfUserOnly(t *testing.T) { + revocation, users := setupRevocationTest(t, "test_iam17_revoke_all") + createUserWithPassword(t, users, "victim@example.com", "geheimes-passwort12") + createUserWithPassword(t, users, "bystander@example.com", "anderes-passwort12") + + issuer := NewTokenIssuer("test-secret-nur-fuer-tests") + login := NewLoginService(users, issuer, "acme") + handler := NewHandler(login, issuer, revocation, nil) + + tokenA1, err := login.Login(context.Background(), "victim@example.com", "geheimes-passwort12") + if err != nil { + t.Fatalf("login a1: %v", err) + } + // Sicherstellen, dass beide Tokens unterschiedliche jti/IssuedAt haben. + time.Sleep(2 * time.Millisecond) + tokenA2, err := login.Login(context.Background(), "victim@example.com", "geheimes-passwort12") + if err != nil { + t.Fatalf("login a2: %v", err) + } + tokenB, err := login.Login(context.Background(), "bystander@example.com", "anderes-passwort12") + if err != nil { + t.Fatalf("login b: %v", err) + } + + protected := RequireAuth(issuer, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }, revocation) + + // Massenwiderruf ueber den Selbstbedienungs-Endpunkt, authentifiziert mit + // tokenA1. + revokeReq := httptest.NewRequest(http.MethodPost, "/auth/revoke-all", nil) + revokeReq.AddCookie(&http.Cookie{Name: CookieName, Value: tokenA1}) + revokeRec := httptest.NewRecorder() + RequireAuth(issuer, handler.RevokeAllSessions, revocation)(revokeRec, revokeReq) + if revokeRec.Code != http.StatusOK { + t.Fatalf("revoke-all: status = %d, want 200", revokeRec.Code) + } + + for _, tok := range []string{tokenA1, tokenA2} { + req := httptest.NewRequest(http.MethodGet, "/geschuetzt", nil) + req.AddCookie(&http.Cookie{Name: CookieName, Value: tok}) + rec := httptest.NewRecorder() + protected(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("widerrufenes token des betroffenen benutzers: status = %d, want 401", rec.Code) + } + } + + // Der andere Benutzer bleibt unberuehrt. + reqB := httptest.NewRequest(http.MethodGet, "/geschuetzt", nil) + reqB.AddCookie(&http.Cookie{Name: CookieName, Value: tokenB}) + recB := httptest.NewRecorder() + protected(recB, reqB) + if recB.Code != http.StatusOK { + t.Fatalf("token des unbeteiligten benutzers: status = %d, want 200", recB.Code) + } + + // Ein Login NACH dem Massenwiderruf muss weiterhin funktionieren + // (Widerruf betrifft nur zuvor ausgestellte Tokens). + tokenA3, err := login.Login(context.Background(), "victim@example.com", "geheimes-passwort12") + if err != nil { + t.Fatalf("login nach widerruf: %v", err) + } + reqA3 := httptest.NewRequest(http.MethodGet, "/geschuetzt", nil) + reqA3.AddCookie(&http.Cookie{Name: CookieName, Value: tokenA3}) + recA3 := httptest.NewRecorder() + protected(recA3, reqA3) + if recA3.Code != http.StatusOK { + t.Fatalf("neues token nach widerruf: status = %d, want 200", recA3.Code) + } +} + +// Admin-Variante von Akzeptanzkriterium 2: ein autorisierter Admin widerruft +// alle Sessions eines FREMDEN Benutzerkontos. +func TestRevokeUserSessions_AdminCanRevokeOtherUsersSessions(t *testing.T) { + revocation, users := setupRevocationTest(t, "test_iam17_admin_revoke") + createUserWithPassword(t, users, "compromised@example.com", "geheimes-passwort12") + + issuer := NewTokenIssuer("test-secret-nur-fuer-tests") + login := NewLoginService(users, issuer, "acme") + authorize := func(_ context.Context, caller string) bool { return caller == "support-admin" } + handler := NewHandler(login, issuer, revocation, authorize) + + token, err := login.Login(context.Background(), "compromised@example.com", "geheimes-passwort12") + if err != nil { + t.Fatalf("login: %v", err) + } + claims, err := issuer.Verify(token) + if err != nil { + t.Fatalf("verify: %v", err) + } + + // Ohne/mit falschem caller -> 403. + badReq := httptest.NewRequest(http.MethodPost, "/auth/admin/revoke-all?user_id="+claims.UserID, nil) + badRec := httptest.NewRecorder() + handler.RevokeUserSessions(badRec, badReq) + if badRec.Code != http.StatusForbidden { + t.Fatalf("ohne berechtigung: status = %d, want 403", badRec.Code) + } + + goodReq := httptest.NewRequest(http.MethodPost, "/auth/admin/revoke-all?caller=support-admin&user_id="+claims.UserID, nil) + goodRec := httptest.NewRecorder() + handler.RevokeUserSessions(goodRec, goodReq) + if goodRec.Code != http.StatusOK { + t.Fatalf("mit berechtigung: status = %d, want 200", goodRec.Code) + } + + protected := RequireAuth(issuer, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }, revocation) + req := httptest.NewRequest(http.MethodGet, "/geschuetzt", nil) + req.AddCookie(&http.Cookie{Name: CookieName, Value: token}) + rec := httptest.NewRecorder() + protected(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("nach admin-widerruf: status = %d, want 401", rec.Code) + } +} + +// Akzeptanzkriterium 3: die Middleware konsultiert den Widerrufsstatus +// zusaetzlich zur Signatur. +func TestRequireAuth_ConsultsRevocationStore(t *testing.T) { + revocation, users := setupRevocationTest(t, "test_iam17_middleware_check") + u := createUserWithPassword(t, users, "checked@example.com", "geheimes-passwort12") + + issuer := NewTokenIssuer("test-secret-nur-fuer-tests") + token, err := issuer.Issue(u.ID, "acme") + if err != nil { + t.Fatalf("issue: %v", err) + } + claims, err := issuer.Verify(token) + if err != nil { + t.Fatalf("verify: %v", err) + } + + protected := RequireAuth(issuer, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }, revocation) + + req := httptest.NewRequest(http.MethodGet, "/geschuetzt", nil) + req.AddCookie(&http.Cookie{Name: CookieName, Value: token}) + rec := httptest.NewRecorder() + protected(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("gueltiges, nicht widerrufenes token: status = %d, want 200", rec.Code) + } + + if err := revocation.RevokeToken(context.Background(), claims.ID, claims.UserID); err != nil { + t.Fatalf("revoke: %v", err) + } + + req2 := httptest.NewRequest(http.MethodGet, "/geschuetzt", nil) + req2.AddCookie(&http.Cookie{Name: CookieName, Value: token}) + rec2 := httptest.NewRecorder() + protected(rec2, req2) + if rec2.Code != http.StatusUnauthorized { + t.Fatalf("widerrufenes, aber signatur-gueltiges token: status = %d, want 401", rec2.Code) + } +} + +// Ohne uebergebenen RevocationChecker bleibt RequireAuth abwaertskompatibel +// (bestehende Aufrufer in anderen Modulen uebergeben KEINEN dritten +// Parameter) — reine Signaturpruefung wie bisher. +func TestRequireAuth_WithoutRevocationCheckerStillWorks(t *testing.T) { + issuer := NewTokenIssuer("test-secret-nur-fuer-tests") + protected := RequireAuth(issuer, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + token, err := issuer.Issue("user-1", "acme") + if err != nil { + t.Fatalf("issue: %v", err) + } + req := httptest.NewRequest(http.MethodGet, "/geschuetzt", nil) + req.AddCookie(&http.Cookie{Name: CookieName, Value: token}) + rec := httptest.NewRecorder() + protected(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } +} + +// Prueft, dass abgelaufene Cache-Eintraege verworfen werden (kein +// dauerhaftes Falsch-Positiv/Negativ ueber die TTL hinaus). +func TestRevocationCache_ExpiresAfterTTL(t *testing.T) { + c := newRevocationCache(10 * time.Millisecond) + c.set("jti-1", true) + if revoked, ok := c.get("jti-1"); !ok || !revoked { + t.Fatalf("erwartet frischen cache-treffer revoked=true, habe ok=%v revoked=%v", ok, revoked) + } + time.Sleep(20 * time.Millisecond) + if _, ok := c.get("jti-1"); ok { + t.Fatal("erwartet cache-miss nach ablauf der ttl") + } +} + +// Pruefung 3 / Akzeptanzkriterium 3: Lasttest der Widerrufspruefung gegen +// die Middleware, um eine Performance-Regression auszuschliessen. Nutzt den +// In-Prozess-Cache (Akzeptanzkriterium 3 erlaubt das ausdruecklich) — ohne +// Cache waere jede Anfrage eine DB-Rundfahrt. +func TestRequireAuth_RevocationCheckLoadTest(t *testing.T) { + revocation, users := setupRevocationTest(t, "test_iam17_load") + u := createUserWithPassword(t, users, "load@example.com", "geheimes-passwort12") + + issuer := NewTokenIssuer("test-secret-nur-fuer-tests") + token, err := issuer.Issue(u.ID, "acme") + if err != nil { + t.Fatalf("issue: %v", err) + } + + protected := RequireAuth(issuer, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }, revocation) + + // Baseline ohne Widerrufspruefung (reine Signaturpruefung). + plain := RequireAuth(issuer, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + const n = 2000 + run := func(h http.HandlerFunc) time.Duration { + start := time.Now() + for i := 0; i < n; i++ { + req := httptest.NewRequest(http.MethodGet, "/geschuetzt", nil) + req.AddCookie(&http.Cookie{Name: CookieName, Value: token}) + rec := httptest.NewRecorder() + h(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("unerwarteter status im lasttest: %d", rec.Code) + } + } + return time.Since(start) + } + + baseline := run(plain) + withRevocation := run(protected) + + t.Logf("Lasttest (%d Anfragen): ohne Widerrufspruefung = %s (%.3f ms/Anfrage), mit Widerrufspruefung + Cache = %s (%.3f ms/Anfrage)", + n, baseline, float64(baseline.Microseconds())/1000/n, withRevocation, float64(withRevocation.Microseconds())/1000/n) + + // Zielwert: dank In-Prozess-Cache (nur die erste Anfrage pro Token + // schlaegt tatsaechlich in der DB nach) darf die Widerrufspruefung die + // Gesamtlaufzeit nicht mehr als das 5-fache verlangsamen — ein + // grosszuegiger, aber konkreter Regressions-Schwellwert. + maxAllowed := baseline * 5 + if maxAllowed < 50*time.Millisecond { + maxAllowed = 50 * time.Millisecond + } + if withRevocation > maxAllowed { + t.Fatalf("performance-regression: mit widerrufspruefung = %s, erlaubtes maximum = %s (baseline %s)", withRevocation, maxAllowed, baseline) + } +} diff --git a/internal/auth/token.go b/internal/auth/token.go index 20080913..316facdc 100644 --- a/internal/auth/token.go +++ b/internal/auth/token.go @@ -1,7 +1,10 @@ package auth import ( + "crypto/rand" + "encoding/hex" "errors" + "fmt" "time" "github.com/golang-jwt/jwt/v5" @@ -33,10 +36,15 @@ func NewTokenIssuer(secret string) *TokenIssuer { func (i *TokenIssuer) Issue(userID, tenantSlug string) (string, error) { now := time.Now() + jti, err := newJTI() + if err != nil { + return "", fmt.Errorf("token-id erzeugen: %w", err) + } claims := Claims{ UserID: userID, TenantSlug: tenantSlug, RegisteredClaims: jwt.RegisteredClaims{ + ID: jti, IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(AccessTokenTTL)), }, @@ -45,6 +53,18 @@ func (i *TokenIssuer) Issue(userID, tenantSlug string) (string, error) { return token.SignedString(i.secret) } +// newJTI erzeugt eine zufaellige, eindeutige Token-ID (RegisteredClaims.ID). +// IAM-17 braucht sie, um ein EINZELNES Token gezielt widerrufen zu koennen, +// ohne andere Sitzungen desselben Benutzers zu beeinflussen (siehe +// internal/auth/revocation.go). +func newJTI() (string, error) { + buf := make([]byte, 16) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return hex.EncodeToString(buf), nil +} + // Verify prueft Signatur UND Ablauf (jwt.ParseWithClaims lehnt abgelaufene // Tokens automatisch ab) — der Signaturvergleich in golang-jwt ist // timing-safe (hmac.Equal). diff --git a/migrations/tenant/0006_token_revocations.down.sql b/migrations/tenant/0006_token_revocations.down.sql new file mode 100644 index 00000000..68c49324 --- /dev/null +++ b/migrations/tenant/0006_token_revocations.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS user_token_revocations; +DROP TABLE IF EXISTS token_revocations; diff --git a/migrations/tenant/0006_token_revocations.up.sql b/migrations/tenant/0006_token_revocations.up.sql new file mode 100644 index 00000000..7f41b53c --- /dev/null +++ b/migrations/tenant/0006_token_revocations.up.sql @@ -0,0 +1,19 @@ +-- IAM-17: Widerrufsmechanismus fuer IAM-02s zustandslose JWTs (siehe +-- core-kanban/tickets/IAM-17.md). Zwei Tabellen fuer zwei Widerrufsarten: +-- token_revocations -- gezielter Widerruf EINES Tokens (Logout). +-- user_token_revocations -- Stichtag pro Benutzer fuer den Massenwiderruf +-- ALLER vor dem Stichtag ausgestellten Tokens +-- (Kompromittierungsverdacht), ohne andere +-- Benutzer zu beeinflussen. +CREATE TABLE token_revocations ( + jti TEXT PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id), + revoked_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX token_revocations_user_idx ON token_revocations (user_id); + +CREATE TABLE user_token_revocations ( + user_id UUID PRIMARY KEY REFERENCES users(id), + revoked_before TIMESTAMPTZ NOT NULL DEFAULT now() +);