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 <noreply@anthropic.com>
178 lines
5.6 KiB
Go
178 lines
5.6 KiB
Go
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)
|
|
}
|
|
}
|