internal/totp/totp.go: RFC-6238-TOTP (HOTP RFC 4226) selbst implementiert mit stdlib crypto/hmac+sha1 — kein externes Modul. DefaultSkewSteps=1 dokumentiert die Zeitversatz-Toleranz (+/-30s um die Serverzeit, Akzeptanzkriterium/Pruefung 3). ProvisioningURI liefert die otpauth://-URI fuer den QR-Code (Akzeptanzkriterium 1) — das Rendering selbst ist Frontend-Sache (IAM-08). internal/totp/store.go: BeginSetup speichert ein neues Secret als NICHT bestaetigt; ConfirmSetup aktiviert 2FA erst nach einmaliger erfolgreicher Code-Eingabe (Akzeptanzkriterium 1) und erzeugt 10 Wiederherstellungscodes (nur Hash gespeichert, Klartext einmalig zurueckgegeben). VerifyLoginCode akzeptiert TOTP-Code ODER Wiederherstellungscode; consumeRecoveryCode markiert einen Code atomar als verwendet (WHERE used_at IS NULL) — kein doppeltes Einloesen moeglich (Akzeptanzkriterium 3). internal/totp/login.go: LoginWithTOTP komponiert IAM-02s LoginService, ohne ihn zu veraendern — ist 2FA fuer den Benutzer aktiv, wird ein fehlender/ falscher Code zuverlaessig abgewiesen, selbst bei korrektem Passwort (Akzeptanzkriterium 2). Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS): 1. Login-Versuch ohne zweiten Faktor bei aktivem 2FA zuverlaessig abgewiesen — TestLoginWithTOTP_RequiresSecondFactorWhenEnabled: korrektes Passwort ohne Code -> ErrSecondFactorRequired, mit gueltigem Code -> Token. PASS. 2. Wiederherstellungscode nach Nutzung als verbraucht getestet — TestVerifyLoginCode_RecoveryCodeIsSingleUse: erste Nutzung erfolgreich, zweite abgelehnt. PASS. 3. Zeitversatz-Toleranz dokumentiert und getestet — TestValidate_ClockSkewTolerance: Code aus 25s Vergangenheit gueltig (innerhalb dokumentierter Toleranz), Code aus 5min Vergangenheit ungueltig. PASS. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
186 lines
5.7 KiB
Go
186 lines
5.7 KiB
Go
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")
|
|
}
|
|
}
|