From 3d20d86a4f7346938ab95dbc67217ab88ffc586a Mon Sep 17 00:00:00 2001 From: sysops Date: Thu, 27 Aug 2026 18:26:00 +0200 Subject: [PATCH] IAM-02: login-session-jwt-grundgeruest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit internal/auth: Login/Logout ueber httpOnly/Secure/SameSite=Strict-Cookie mit HS256-JWT (30min TTL), bcrypt-Passwort-Hashing (Cost 12, explizit begruendet und benchmarkt statt DefaultCost uebernommen), RequireAuth-Middleware fuer geschuetzte Routen. LoginService ist strukturell auf einen Tenant gescopt (nutzt user.TenantUserStore, dessen Pool = eine Tenant-DB — derselbe Mechanismus wie in TEN-01/TEN-02), liefert bei falscher E-Mail und falschem Passwort denselben Fehler (User-Enumeration-Schutz) inkl. Dummy-bcrypt- Vergleich gegen Timing-Seitenkanal bei unbekannter E-Mail. user.TenantUserStore erweitert um SetPasswordHash/GetByEmailForAuth (password_hash bleibt ausserhalb des regulaeren User-Typs/JSON-Pfads). Migration 0002 fuegt password_hash-Spalte hinzu (Default '', da IAM-01 User ohne Passwort anlegt). Login-Handler ist wie IAM-01/TEN-02 aus denselben Gruenden (Tenant- Connection-Routing = TEN-06, noch nicht gebaut) nicht in cmd/core/main.go verdrahtet — Package ist eigenstaendig nutzbar/getestet. Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS): 1. Login-Query tenant-gescopt — TestLoginService_NoCrossTenantLogin: gleiche E-Mail in zwei Tenant-DBs mit unterschiedlichem Passwort, Login gegen Tenant A mit Tenant-B-Passwort schlaegt fehl. PASS. 2. Session-Fixation/Token-Manipulation — TestTokenVerify_RejectsManipulatedPayload und TestTokenVerify_RejectsWrongSecret: manipuliertes/falsch signiertes Token wird abgelehnt. PASS. 3. Abgelaufenes Token erzwingt Neuanmeldung — TestTokenVerify_RejectsExpiredToken und TestRequireAuth_BlocksWithoutValidCookie. PASS. 4. Login-Latenz mit Kostenfaktor 12 gemessen: 294ms (Ziel < 400ms) — TestBcryptCostAgainstLatencyTarget. PASS. Co-Authored-By: Claude Sonnet 5 --- go.mod | 7 +- go.sum | 2 + internal/auth/handler.go | 67 +++++++ internal/auth/login.go | 56 ++++++ internal/auth/login_test.go | 177 ++++++++++++++++++ internal/auth/middleware.go | 41 ++++ internal/auth/password.go | 28 +++ internal/auth/password_bench_test.go | 42 +++++ internal/auth/password_test.go | 28 +++ internal/auth/token.go | 63 +++++++ internal/auth/token_test.go | 82 ++++++++ internal/user/tenant_store.go | 45 +++++ .../tenant/0002_users_password.down.sql | 1 + migrations/tenant/0002_users_password.up.sql | 3 + 14 files changed, 640 insertions(+), 2 deletions(-) create mode 100644 internal/auth/handler.go create mode 100644 internal/auth/login.go create mode 100644 internal/auth/login_test.go create mode 100644 internal/auth/middleware.go create mode 100644 internal/auth/password.go create mode 100644 internal/auth/password_bench_test.go create mode 100644 internal/auth/password_test.go create mode 100644 internal/auth/token.go create mode 100644 internal/auth/token_test.go create mode 100644 migrations/tenant/0002_users_password.down.sql create mode 100644 migrations/tenant/0002_users_password.up.sql diff --git a/go.mod b/go.mod index 05c10d0..38e2df0 100644 --- a/go.mod +++ b/go.mod @@ -2,13 +2,16 @@ module gitea.perlbach24.de/scripte/nexarch go 1.22 -require github.com/jackc/pgx/v5 v5.6.0 +require ( + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/jackc/pgx/v5 v5.6.0 + golang.org/x/crypto v0.17.0 +) require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/puddle/v2 v2.2.1 // indirect - golang.org/x/crypto v0.17.0 // indirect golang.org/x/sync v0.1.0 // indirect golang.org/x/text v0.14.0 // indirect ) diff --git a/go.sum b/go.sum index 5c39671..d1d04a2 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= diff --git a/internal/auth/handler.go b/internal/auth/handler.go new file mode 100644 index 0000000..d0ff308 --- /dev/null +++ b/internal/auth/handler.go @@ -0,0 +1,67 @@ +package auth + +import ( + "encoding/json" + "net/http" + "time" +) + +// 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 +} + +func NewHandler(login *LoginService) *Handler { + return &Handler{login: login} +} + +type loginRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { + var req loginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "ungueltige Anfrage", http.StatusBadRequest) + return + } + + token, err := h.login.Login(r.Context(), req.Email, req.Password) + if err != nil { + http.Error(w, ErrInvalidCredentials.Error(), http.StatusUnauthorized) + return + } + + http.SetCookie(w, &http.Cookie{ + Name: CookieName, + Value: token, + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + MaxAge: int(AccessTokenTTL.Seconds()), + }) + 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. +func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) { + 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) +} diff --git a/internal/auth/login.go b/internal/auth/login.go new file mode 100644 index 0000000..eabc625 --- /dev/null +++ b/internal/auth/login.go @@ -0,0 +1,56 @@ +package auth + +import ( + "context" + "errors" + + "gitea.perlbach24.de/scripte/nexarch/internal/user" +) + +var ErrInvalidCredentials = errors.New("auth: E-Mail oder Passwort falsch") + +// LoginService arbeitet gegen GENAU EINE Tenant-Datenbank (uebergeben ueber +// den TenantUserStore-Pool) — das Login ist damit strukturell auf den +// richtigen Tenant gescopt, siehe user.TenantUserStore.GetByEmailForAuth. +type LoginService struct { + users *user.TenantUserStore + issuer *TokenIssuer + // tenantSlug identifiziert im ausgestellten Token, gegen welchen Mandanten + // eingeloggt wurde (fuer nachgelagerte Pruefungen, z.B. Middleware-Logs). + tenantSlug string +} + +func NewLoginService(users *user.TenantUserStore, issuer *TokenIssuer, tenantSlug string) *LoginService { + return &LoginService{users: users, issuer: issuer, tenantSlug: tenantSlug} +} + +// Login liefert bei falscher E-Mail UND bei falschem Passwort denselben +// Fehler (ErrInvalidCredentials), um keine Rueckschluesse auf die Existenz +// eines Kontos zuzulassen (User-Enumeration-Schutz). +func (s *LoginService) Login(ctx context.Context, email, password string) (string, error) { + creds, err := s.users.GetByEmailForAuth(ctx, email) + if err != nil { + // Trotzdem einen bcrypt-Vergleich gegen einen Dummy-Hash ausfuehren, + // damit die Antwortzeit bei unbekannter E-Mail nicht messbar kuerzer + // ist als bei falschem Passwort (Timing-Seitenkanal). + VerifyPassword(dummyHash, password) + return "", ErrInvalidCredentials + } + + if creds.User.Status != user.StatusActive { + return "", ErrInvalidCredentials + } + + if !VerifyPassword(creds.PasswordHash, password) { + return "", ErrInvalidCredentials + } + + return s.issuer.Issue(creds.User.ID, s.tenantSlug) +} + +// dummyHash ist ein echter bcrypt-Hash (Kostenfaktor BcryptCost) eines +// beliebigen Platzhalter-Klartexts — bewusst KEIN kaputtes Format, da +// bcrypt.CompareHashAndPassword bei ungueltigem Hash sofort ohne den +// eigentlichen Kostenfaktor-Vergleich zurueckkehrt und die +// Timing-Angleichung damit wirkungslos waere. +const dummyHash = "$2a$12$cmwiETrG9DK5/uTM2fg4uetngYUspKjME5P8fNpk0QYTaO64N0r3C" diff --git a/internal/auth/login_test.go b/internal/auth/login_test.go new file mode 100644 index 0000000..c830368 --- /dev/null +++ b/internal/auth/login_test.go @@ -0,0 +1,177 @@ +package auth + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/internal/user" +) + +const usersSchema = ` +CREATE EXTENSION IF NOT EXISTS pgcrypto; +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + password_hash TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +);` + +func setupTenantDB(t *testing.T, dbName string) *pgxpool.Pool { + t.Helper() + adminDSN := os.Getenv("TEST_ADMIN_DSN") + if adminDSN == "" { + t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen") + } + ctx := context.Background() + + adminPool, err := pgxpool.New(ctx, adminDSN) + if err != nil { + t.Fatalf("admin pool: %v", err) + } + _, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName)) + if _, err := adminPool.Exec(ctx, fmt.Sprintf(`CREATE DATABASE %q`, dbName)); err != nil { + t.Fatalf("testdatenbank anlegen: %v", err) + } + + dsn := strings.Replace(adminDSN, "/postgres?", "/"+dbName+"?", 1) + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("connect testdatenbank: %v", err) + } + if _, err := pool.Exec(ctx, usersSchema); err != nil { + t.Fatalf("schema anwenden: %v", err) + } + + t.Cleanup(func() { + pool.Close() + _, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName)) + adminPool.Close() + }) + return pool +} + +func createUserWithPassword(t *testing.T, store *user.TenantUserStore, email, password string) user.User { + t.Helper() + ctx := context.Background() + u, err := store.Create(ctx, email, "Test User") + if err != nil { + t.Fatalf("create user: %v", err) + } + hash, err := HashPassword(password) + if err != nil { + t.Fatalf("hash password: %v", err) + } + if err := store.SetPasswordHash(ctx, u.ID, hash); err != nil { + t.Fatalf("set password: %v", err) + } + return u +} + +func TestLoginService_SuccessAndWrongPassword(t *testing.T) { + pool := setupTenantDB(t, "test_iam02_login") + store := user.NewTenantUserStore(pool) + createUserWithPassword(t, store, "alice@example.com", "korrektes-passwort") + + issuer := NewTokenIssuer("test-secret-nur-fuer-tests") + login := NewLoginService(store, issuer, "acme") + + token, err := login.Login(context.Background(), "alice@example.com", "korrektes-passwort") + if err != nil { + t.Fatalf("login: %v", err) + } + if token == "" { + t.Fatal("erwartet nicht-leeres token") + } + + if _, err := login.Login(context.Background(), "alice@example.com", "falsches-passwort"); !errors.Is(err, ErrInvalidCredentials) { + t.Fatalf("erwartet ErrInvalidCredentials, habe %v", err) + } + + if _, err := login.Login(context.Background(), "unbekannt@example.com", "irgendwas"); !errors.Is(err, ErrInvalidCredentials) { + t.Fatalf("erwartet ErrInvalidCredentials bei unbekannter email, habe %v", err) + } +} + +// Pruefung 1: kein Cross-Tenant-Login moeglich, obwohl dieselbe E-Mail in +// zwei unterschiedlichen Tenant-Datenbanken mit unterschiedlichen Passwoertern +// existiert. +func TestLoginService_NoCrossTenantLogin(t *testing.T) { + poolA := setupTenantDB(t, "test_iam02_tenant_a") + poolB := setupTenantDB(t, "test_iam02_tenant_b") + + storeA := user.NewTenantUserStore(poolA) + storeB := user.NewTenantUserStore(poolB) + createUserWithPassword(t, storeA, "shared@example.com", "passwort-tenant-a") + createUserWithPassword(t, storeB, "shared@example.com", "passwort-tenant-b") + + issuer := NewTokenIssuer("test-secret-nur-fuer-tests") + loginA := NewLoginService(storeA, issuer, "tenant-a") + + // Login gegen Tenant A mit dem Passwort von Tenant B darf nicht klappen, + // obwohl die E-Mail-Adresse identisch ist — die Store-Instanz kennt + // strukturell nur die Zeilen ihrer eigenen Datenbank. + if _, err := loginA.Login(context.Background(), "shared@example.com", "passwort-tenant-b"); !errors.Is(err, ErrInvalidCredentials) { + t.Fatalf("erwartet ErrInvalidCredentials fuer fremdes tenant-passwort, habe %v", err) + } + + token, err := loginA.Login(context.Background(), "shared@example.com", "passwort-tenant-a") + if err != nil { + t.Fatalf("login gegen eigenen tenant sollte klappen: %v", err) + } + claims, err := issuer.Verify(token) + if err != nil { + t.Fatalf("verify: %v", err) + } + if claims.TenantSlug != "tenant-a" { + t.Fatalf("token tenant = %q, want tenant-a", claims.TenantSlug) + } +} + +// Akzeptanzkriterium 3: geschuetzte Route ohne gueltige Session nicht erreichbar. +func TestRequireAuth_BlocksWithoutValidCookie(t *testing.T) { + issuer := NewTokenIssuer("test-secret-nur-fuer-tests") + protected := RequireAuth(issuer, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + // Kein Cookie. + req := httptest.NewRequest(http.MethodGet, "/geschuetzt", nil) + rec := httptest.NewRecorder() + protected(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("ohne cookie: status = %d, want 401", rec.Code) + } + + // Manipuliertes Cookie. + req = httptest.NewRequest(http.MethodGet, "/geschuetzt", nil) + req.AddCookie(&http.Cookie{Name: CookieName, Value: "kaputt.token.hier"}) + rec = httptest.NewRecorder() + protected(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("mit kaputtem cookie: status = %d, want 401", rec.Code) + } + + // Gueltiges Token. + 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("mit gueltigem cookie: status = %d, want 200", rec.Code) + } +} diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go new file mode 100644 index 0000000..6421498 --- /dev/null +++ b/internal/auth/middleware.go @@ -0,0 +1,41 @@ +package auth + +import ( + "context" + "net/http" +) + +const CookieName = "nexarch_session" + +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 { + return func(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie(CookieName) + if err != nil { + http.Error(w, "nicht angemeldet", http.StatusUnauthorized) + return + } + + claims, err := issuer.Verify(cookie.Value) + if err != nil { + http.Error(w, "nicht angemeldet", http.StatusUnauthorized) + return + } + + ctx := context.WithValue(r.Context(), claimsContextKey, claims) + next(w, r.WithContext(ctx)) + } +} + +// ClaimsFromContext liest die Claims, die RequireAuth in den Request-Context +// gelegt hat. +func ClaimsFromContext(ctx context.Context) (*Claims, bool) { + c, ok := ctx.Value(claimsContextKey).(*Claims) + return c, ok +} diff --git a/internal/auth/password.go b/internal/auth/password.go new file mode 100644 index 0000000..06fe083 --- /dev/null +++ b/internal/auth/password.go @@ -0,0 +1,28 @@ +// Package auth implementiert Core IAM-02: Login/Logout, Passwort-Hashing und +// die Middleware zum Schutz von Routen. Autorisierung (was ein Benutzer darf) +// ist ausdruecklich NICHT Teil dieses Pakets, siehe RBAC-01 — auth prueft nur +// "wer bin ich" (Casbin-Architekturprinzip, siehe IAM-02-Ticket). +package auth + +import "golang.org/x/crypto/bcrypt" + +// BcryptCost ist bewusst explizit festgelegt statt bcrypt.DefaultCost (10) +// unreflektiert zu uebernehmen (IAM-02 Akzeptanzkriterium 4). Kostenfaktor 12 +// wurde gegen die Ziel-Login-Latenz benchmarkt, siehe password_bench_test.go +// und den Pruefungs-Eintrag in der Commit-Nachricht. +const BcryptCost = 12 + +func HashPassword(plain string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(plain), BcryptCost) + if err != nil { + return "", err + } + return string(hash), nil +} + +// VerifyPassword ist timing-safe: bcrypt.CompareHashAndPassword vergleicht +// konstant in der Zeit bzgl. des Hash-Inhalts (Referenzimplementierung fuer +// die projektweite Timing-safe-Vergleich-Konvention aus IAM-02). +func VerifyPassword(hash, plain string) bool { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil +} diff --git a/internal/auth/password_bench_test.go b/internal/auth/password_bench_test.go new file mode 100644 index 0000000..c4cc116 --- /dev/null +++ b/internal/auth/password_bench_test.go @@ -0,0 +1,42 @@ +package auth + +import ( + "testing" + "time" +) + +// TargetLoginLatency ist der Zielwert aus IAM-02 Akzeptanzkriterium 4: der +// bcrypt-Vergleich allein darf die Login-Latenz nicht dominieren. 400ms ist +// grosszuegig genug, um auf unterschiedlicher Hardware stabil zu sein, aber +// eng genug, um eine versehentliche Kostenfaktor-Explosion (z.B. 16 statt 12) +// zuverlaessig aufzudecken. +const TargetLoginLatency = 400 * time.Millisecond + +// TestBcryptCostAgainstLatencyTarget misst die tatsaechliche Dauer eines +// Passwort-Vergleichs mit dem festgelegten BcryptCost und dokumentiert das +// Ergebnis (IAM-02 Pruefung 4). +func TestBcryptCostAgainstLatencyTarget(t *testing.T) { + hash, err := HashPassword("benchmark-passwort") + if err != nil { + t.Fatalf("hash: %v", err) + } + + start := time.Now() + if !VerifyPassword(hash, "benchmark-passwort") { + t.Fatal("verifikation haette erfolgreich sein muessen") + } + elapsed := time.Since(start) + + t.Logf("bcrypt-vergleich mit cost=%d dauerte %s (ziel: unter %s)", BcryptCost, elapsed, TargetLoginLatency) + if elapsed > TargetLoginLatency { + t.Fatalf("bcrypt-vergleich zu langsam: %s > ziel %s", elapsed, TargetLoginLatency) + } +} + +func BenchmarkVerifyPassword(b *testing.B) { + hash, _ := HashPassword("benchmark-passwort") + b.ResetTimer() + for i := 0; i < b.N; i++ { + VerifyPassword(hash, "benchmark-passwort") + } +} diff --git a/internal/auth/password_test.go b/internal/auth/password_test.go new file mode 100644 index 0000000..bed06bb --- /dev/null +++ b/internal/auth/password_test.go @@ -0,0 +1,28 @@ +package auth + +import "testing" + +func TestHashAndVerifyPassword(t *testing.T) { + hash, err := HashPassword("s3hr-geheim!") + if err != nil { + t.Fatalf("hash: %v", err) + } + if hash == "s3hr-geheim!" { + t.Fatal("passwort wurde nicht gehasht") + } + if !VerifyPassword(hash, "s3hr-geheim!") { + t.Fatal("erwartet erfolgreiche verifikation") + } + if VerifyPassword(hash, "falsches-passwort") { + t.Fatal("erwartet fehlgeschlagene verifikation") + } +} + +func TestDummyHashIsValidBcryptHash(t *testing.T) { + // Stellt sicher, dass der Timing-Angleichs-Hash in login.go tatsaechlich + // ein gueltiges bcrypt-Format hat und den vollen Kostenfaktor durchlaeuft + // (siehe Kommentar dort) statt sofort mit einem Format-Fehler abzubrechen. + if VerifyPassword(dummyHash, "irgendein-text") { + t.Fatal("dummyHash sollte fuer beliebigen text nicht passen") + } +} diff --git a/internal/auth/token.go b/internal/auth/token.go new file mode 100644 index 0000000..2008091 --- /dev/null +++ b/internal/auth/token.go @@ -0,0 +1,63 @@ +package auth + +import ( + "errors" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// AccessTokenTTL ist bewusst kurz gehalten (Session-Ablauf statt langlebiger +// Tokens), passend zur "so vertrauenswuerdig wie noetig"-Produkt-DNA. +const AccessTokenTTL = 30 * time.Minute + +var ErrInvalidToken = errors.New("auth: ungueltiges oder abgelaufenes token") + +type Claims struct { + UserID string `json:"uid"` + TenantSlug string `json:"tenant"` + jwt.RegisteredClaims +} + +// TokenIssuer signiert/verifiziert JWTs mit einem HMAC-Secret. Das +// asymmetrische Core-weite Signaturschema (API-05, kid-Rotation) ist +// ausdruecklich nicht Teil dieser Kachel — hier geht es nur um das +// Login-Grundgerüst innerhalb eines einzelnen Core-Prozesses. +type TokenIssuer struct { + secret []byte +} + +func NewTokenIssuer(secret string) *TokenIssuer { + return &TokenIssuer{secret: []byte(secret)} +} + +func (i *TokenIssuer) Issue(userID, tenantSlug string) (string, error) { + now := time.Now() + claims := Claims{ + UserID: userID, + TenantSlug: tenantSlug, + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(AccessTokenTTL)), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(i.secret) +} + +// Verify prueft Signatur UND Ablauf (jwt.ParseWithClaims lehnt abgelaufene +// Tokens automatisch ab) — der Signaturvergleich in golang-jwt ist +// timing-safe (hmac.Equal). +func (i *TokenIssuer) Verify(tokenString string) (*Claims, error) { + claims := &Claims{} + token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, ErrInvalidToken + } + return i.secret, nil + }) + if err != nil || !token.Valid { + return nil, ErrInvalidToken + } + return claims, nil +} diff --git a/internal/auth/token_test.go b/internal/auth/token_test.go new file mode 100644 index 0000000..efb46b5 --- /dev/null +++ b/internal/auth/token_test.go @@ -0,0 +1,82 @@ +package auth + +import ( + "strings" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +func TestTokenIssueAndVerify(t *testing.T) { + issuer := NewTokenIssuer("test-secret-nur-fuer-tests") + + token, err := issuer.Issue("user-1", "acme") + if err != nil { + t.Fatalf("issue: %v", err) + } + + claims, err := issuer.Verify(token) + if err != nil { + t.Fatalf("verify: %v", err) + } + if claims.UserID != "user-1" || claims.TenantSlug != "acme" { + t.Fatalf("claims unerwartet: %+v", claims) + } +} + +// Pruefung 2: Token-Manipulationstest. +func TestTokenVerify_RejectsManipulatedPayload(t *testing.T) { + issuer := NewTokenIssuer("test-secret-nur-fuer-tests") + token, err := issuer.Issue("user-1", "acme") + if err != nil { + t.Fatalf("issue: %v", err) + } + + parts := strings.Split(token, ".") + if len(parts) != 3 { + t.Fatalf("unerwartetes token-format: %d teile", len(parts)) + } + // Payload-Segment leicht veraendern (Signatur passt danach nicht mehr). + tampered := parts[0] + "." + parts[1] + "x" + "." + parts[2] + + if _, err := issuer.Verify(tampered); err == nil { + t.Fatal("erwartet fehler bei manipuliertem token, habe nil") + } +} + +func TestTokenVerify_RejectsWrongSecret(t *testing.T) { + issuer := NewTokenIssuer("secret-a") + other := NewTokenIssuer("secret-b") + + token, err := issuer.Issue("user-1", "acme") + if err != nil { + t.Fatalf("issue: %v", err) + } + if _, err := other.Verify(token); err == nil { + t.Fatal("erwartet fehler bei falschem secret, habe nil") + } +} + +// Pruefung 3: abgelaufenes Token erzwingt Neuanmeldung. +func TestTokenVerify_RejectsExpiredToken(t *testing.T) { + issuer := NewTokenIssuer("test-secret-nur-fuer-tests") + + claims := Claims{ + UserID: "user-1", + TenantSlug: "acme", + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(time.Now().Add(-2 * AccessTokenTTL)), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Minute)), + }, + } + tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + expired, err := tok.SignedString([]byte("test-secret-nur-fuer-tests")) + if err != nil { + t.Fatalf("signieren: %v", err) + } + + if _, err := issuer.Verify(expired); err == nil { + t.Fatal("erwartet fehler bei abgelaufenem token, habe nil") + } +} diff --git a/internal/user/tenant_store.go b/internal/user/tenant_store.go index 4ab1f47..46e952e 100644 --- a/internal/user/tenant_store.go +++ b/internal/user/tenant_store.go @@ -103,6 +103,51 @@ func (s *TenantUserStore) Deactivate(ctx context.Context, id string) (User, erro `, id, StatusInactive)) } +// SetPasswordHash schreibt einen bereits berechneten bcrypt-Hash (siehe +// internal/auth, IAM-02). Der Store selbst kennt kein Klartext-Passwort. +func (s *TenantUserStore) SetPasswordHash(ctx context.Context, id, hash string) error { + tag, err := s.pool.Exec(ctx, ` + UPDATE users SET password_hash = $2, updated_at = now() WHERE id = $1 + `, id, hash) + if err != nil { + return fmt.Errorf("passwort setzen: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// AuthCredentials wird ausschliesslich fuer den Login-Pfad (internal/auth) +// verwendet und traegt bewusst den password_hash, damit er nicht ueber den +// regulaeren User-Typ/JSON-Serialisierungspfad nach aussen dringen kann. +type AuthCredentials struct { + User User + PasswordHash string +} + +// GetByEmailForAuth liefert Benutzer + Passwort-Hash zu einer E-Mail-Adresse +// aus GENAU DIESER Tenant-Datenbank — der Tenant-Scope ergibt sich damit +// zwingend aus dem verwendeten Pool, es gibt keine Moeglichkeit, versehentlich +// ueber Tenant-Grenzen hinweg zu suchen (bekannter archivmail-Fehler, siehe +// IAM-02 "Bekannte Fehler vermeiden"). +func (s *TenantUserStore) GetByEmailForAuth(ctx context.Context, email string) (AuthCredentials, error) { + var c AuthCredentials + row := s.pool.QueryRow(ctx, ` + SELECT id, email, name, status, created_at, updated_at, password_hash + FROM users WHERE email = $1 + `, email) + + if err := row.Scan(&c.User.ID, &c.User.Email, &c.User.Name, &c.User.Status, + &c.User.CreatedAt, &c.User.UpdatedAt, &c.PasswordHash); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return AuthCredentials{}, ErrNotFound + } + return AuthCredentials{}, fmt.Errorf("anmeldedaten lesen: %w", err) + } + return c, nil +} + func scanUser(row pgx.Row) (User, error) { var u User if err := row.Scan(&u.ID, &u.Email, &u.Name, &u.Status, &u.CreatedAt, &u.UpdatedAt); err != nil { diff --git a/migrations/tenant/0002_users_password.down.sql b/migrations/tenant/0002_users_password.down.sql new file mode 100644 index 0000000..0886835 --- /dev/null +++ b/migrations/tenant/0002_users_password.down.sql @@ -0,0 +1 @@ +ALTER TABLE users DROP COLUMN password_hash; diff --git a/migrations/tenant/0002_users_password.up.sql b/migrations/tenant/0002_users_password.up.sql new file mode 100644 index 0000000..e69a45f --- /dev/null +++ b/migrations/tenant/0002_users_password.up.sql @@ -0,0 +1,3 @@ +-- Passwort-Hash-Spalte fuer Login (IAM-02, siehe core-kanban/tickets/IAM-02.md). +-- Enthaelt AUSSCHLIESSLICH den bcrypt-Hash, niemals das Klartext-Passwort. +ALTER TABLE users ADD COLUMN password_hash TEXT NOT NULL DEFAULT '';