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:
1 parent
e94b9dd5d1
commit
5d213ac1eb
7 files changed
+675
-12
No files matched your search
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
Reference in new issue
Block a user