package totp import ( "context" "errors" "fmt" "os" "testing" "time" "github.com/jackc/pgx/v5/pgxpool" "gitea.perlbach24.de/scripte/nexarch/internal/auth" "gitea.perlbach24.de/scripte/nexarch/internal/user" ) func setupTest(t *testing.T) (*Store, *user.TenantUserStore, string, func()) { t.Helper() adminDSN := os.Getenv("TEST_ADMIN_DSN") if adminDSN == "" { t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen") } ctx := context.Background() pool, err := pgxpool.New(ctx, adminDSN) if err != nil { t.Fatalf("pool: %v", err) } if _, err := pool.Exec(ctx, ` CREATE TABLE IF NOT EXISTS 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() ); CREATE TABLE IF NOT EXISTS totp_credentials ( user_id UUID PRIMARY KEY REFERENCES users(id), secret TEXT NOT NULL, confirmed BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS totp_recovery_codes ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id), code_hash BYTEA NOT NULL UNIQUE, used_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); `); err != nil { t.Fatalf("schema: %v", err) } userStore := user.NewTenantUserStore(pool) email := fmt.Sprintf("totp-test-%d@example.com", time.Now().UnixNano()) u, err := userStore.Create(ctx, email, "TOTP Test") if err != nil { t.Fatalf("create user: %v", err) } hash, err := auth.HashPassword("korrektes-passwort") if err != nil { t.Fatalf("hash: %v", err) } if err := userStore.SetPasswordHash(ctx, u.ID, hash); err != nil { t.Fatalf("set password: %v", err) } cleanup := func() { pool.Close() } return NewStore(pool), userStore, u.ID, cleanup } // Akzeptanzkriterium 1: Einrichtung per QR-Code (URI), Bestaetigung vor Aktivierung. func TestBeginAndConfirmSetup(t *testing.T) { store, _, userID, cleanup := setupTest(t) defer cleanup() ctx := context.Background() secret, uri, err := store.BeginSetup(ctx, userID, "NEXARCH", "user@example.com") if err != nil { t.Fatalf("begin setup: %v", err) } if uri == "" { t.Fatal("erwartet nicht-leere provisioning-uri") } enabled, err := store.IsEnabled(ctx, userID) if err != nil { t.Fatalf("is enabled (vor bestaetigung): %v", err) } if enabled { t.Fatal("erwartet NICHT aktiviert vor bestaetigung") } // Falscher Code darf nicht aktivieren. if _, err := store.ConfirmSetup(ctx, userID, "000000"); !errors.Is(err, ErrInvalidCode) && err == nil { t.Fatalf("erwartet ablehnung bei falschem code, habe err=%v", err) } code, err := GenerateCode(secret, time.Now()) if err != nil { t.Fatalf("generate code: %v", err) } recoveryCodes, err := store.ConfirmSetup(ctx, userID, code) if err != nil { t.Fatalf("confirm setup: %v", err) } if len(recoveryCodes) != RecoveryCodeCount { t.Fatalf("erwartet %d wiederherstellungscodes, habe %d", RecoveryCodeCount, len(recoveryCodes)) } enabled, err = store.IsEnabled(ctx, userID) if err != nil { t.Fatalf("is enabled (nach bestaetigung): %v", err) } if !enabled { t.Fatal("erwartet aktiviert nach erfolgreicher bestaetigung") } } // Akzeptanzkriterium 2 + Pruefung 1: Login ohne zweiten Faktor bei aktivem // 2FA wird zuverlaessig abgewiesen. func TestLoginWithTOTP_RequiresSecondFactorWhenEnabled(t *testing.T) { store, userStore, userID, cleanup := setupTest(t) defer cleanup() ctx := context.Background() secret, _, err := store.BeginSetup(ctx, userID, "NEXARCH", "user@example.com") if err != nil { t.Fatalf("begin setup: %v", err) } code, _ := GenerateCode(secret, time.Now()) if _, err := store.ConfirmSetup(ctx, userID, code); err != nil { t.Fatalf("confirm setup: %v", err) } u, err := userStore.Get(ctx, userID) if err != nil { t.Fatalf("get user: %v", err) } issuer := auth.NewTokenIssuer("test-secret-nur-fuer-tests") loginService := auth.NewLoginService(userStore, issuer, "acme") // Korrektes Passwort, aber KEIN 2FA-Code -> abgewiesen. if _, err := LoginWithTOTP(ctx, userStore, store, loginService, u.Email, "korrektes-passwort", ""); !errors.Is(err, ErrSecondFactorRequired) { t.Fatalf("erwartet ErrSecondFactorRequired ohne code, habe %v", err) } // Korrektes Passwort + korrekter aktueller Code -> erfolgreich. loginCode, _ := GenerateCode(secret, time.Now()) token, err := LoginWithTOTP(ctx, userStore, store, loginService, u.Email, "korrektes-passwort", loginCode) if err != nil { t.Fatalf("login mit gueltigem 2fa-code: %v", err) } if token == "" { t.Fatal("erwartet gueltiges token") } } // Akzeptanzkriterium 3 + Pruefung 2: Wiederherstellungscode nach Nutzung als // verbraucht getestet. func TestVerifyLoginCode_RecoveryCodeIsSingleUse(t *testing.T) { store, _, userID, cleanup := setupTest(t) defer cleanup() ctx := context.Background() secret, _, err := store.BeginSetup(ctx, userID, "NEXARCH", "user@example.com") if err != nil { t.Fatalf("begin setup: %v", err) } code, _ := GenerateCode(secret, time.Now()) recoveryCodes, err := store.ConfirmSetup(ctx, userID, code) if err != nil { t.Fatalf("confirm setup: %v", err) } recovery := recoveryCodes[0] ok, err := store.VerifyLoginCode(ctx, userID, recovery) if err != nil { t.Fatalf("verify (erste nutzung): %v", err) } if !ok { t.Fatal("erwartet erfolgreiche erste nutzung des wiederherstellungscodes") } ok, err = store.VerifyLoginCode(ctx, userID, recovery) if err != nil { t.Fatalf("verify (zweite nutzung): %v", err) } if ok { t.Fatal("erwartet abgelehnt bei erneuter nutzung desselben wiederherstellungscodes") } }