IAM-17: session-token-widerruf-bei-logout-und-kompromittierung

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPsyieCYwvbUBsnN598SbP
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-17 09:50:55 +02:00
1 parent e94b9dd5d1
commit 5d213ac1eb
7 files changed
+675 -12

No files matched your search

+88 -8
View File
@@ -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)
}
+25 -4
View File
@@ -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))
}
+183
View File
@@ -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)
}
+338
View File
@@ -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)
}
}
+20
View File
@@ -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).
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS user_token_revocations;
DROP TABLE IF EXISTS token_revocations;
@@ -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()
);