Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QPsyieCYwvbUBsnN598SbP
339 lines
12 KiB
Go
339 lines
12 KiB
Go
// 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)
|
|
}
|
|
}
|