Files

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)
}
}