From f224b5c9bea095dd274f4f335ceb666ff62c243f Mon Sep 17 00:00:00 2001 From: sysops Date: Thu, 27 Aug 2026 22:34:34 +0200 Subject: [PATCH 1/2] IAM-04: zwei-faktor-authentifizierung-totp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/totp/login.go | 42 ++++++ internal/totp/store.go | 163 +++++++++++++++++++++++ internal/totp/store_test.go | 185 +++++++++++++++++++++++++++ internal/totp/totp.go | 104 +++++++++++++++ internal/totp/totp_test.go | 79 ++++++++++++ migrations/tenant/0003_totp.down.sql | 3 + migrations/tenant/0003_totp.up.sql | 23 ++++ scripts/reset-test-env.sh | 11 ++ scripts/run-checks.sh | 12 ++ 9 files changed, 622 insertions(+) create mode 100644 internal/totp/login.go create mode 100644 internal/totp/store.go create mode 100644 internal/totp/store_test.go create mode 100644 internal/totp/totp.go create mode 100644 internal/totp/totp_test.go create mode 100644 migrations/tenant/0003_totp.down.sql create mode 100644 migrations/tenant/0003_totp.up.sql create mode 100755 scripts/reset-test-env.sh create mode 100755 scripts/run-checks.sh diff --git a/internal/totp/login.go b/internal/totp/login.go new file mode 100644 index 0000000..dff0045 --- /dev/null +++ b/internal/totp/login.go @@ -0,0 +1,42 @@ +package totp + +import ( + "context" + "errors" + + "gitea.perlbach24.de/scripte/nexarch/internal/auth" + "gitea.perlbach24.de/scripte/nexarch/internal/user" +) + +var ErrSecondFactorRequired = errors.New("totp: zweiter faktor erforderlich") + +// LoginWithTOTP komponiert IAM-02s LoginService mit der 2FA-Pruefung, ohne +// LoginService selbst zu veraendern. Ist 2FA fuer den Benutzer aktiv, MUSS +// totpCode gueltig sein (Akzeptanzkriterium 2) — ein leerer/falscher Code +// wird zuverlaessig abgewiesen, auch bei korrektem Passwort. +func LoginWithTOTP(ctx context.Context, users *user.TenantUserStore, totpStore *Store, login *auth.LoginService, email, password, totpCode string) (token string, err error) { + creds, err := users.GetByEmailForAuth(ctx, email) + if err != nil { + return "", auth.ErrInvalidCredentials + } + + enabled, err := totpStore.IsEnabled(ctx, creds.User.ID) + if err != nil { + return "", err + } + + if enabled { + if totpCode == "" { + return "", ErrSecondFactorRequired + } + ok, err := totpStore.VerifyLoginCode(ctx, creds.User.ID, totpCode) + if err != nil { + return "", err + } + if !ok { + return "", ErrSecondFactorRequired + } + } + + return login.Login(ctx, email, password) +} diff --git a/internal/totp/store.go b/internal/totp/store.go new file mode 100644 index 0000000..04cea71 --- /dev/null +++ b/internal/totp/store.go @@ -0,0 +1,163 @@ +package totp + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +var ( + ErrInvalidCode = errors.New("totp: code ungueltig") + ErrNotConfirmed = errors.New("totp: einrichtung noch nicht bestaetigt") + ErrRecoveryCodeUsed = errors.New("totp: wiederherstellungscode bereits verwendet oder ungueltig") +) + +const RecoveryCodeCount = 10 + +type Store struct { + pool *pgxpool.Pool +} + +func NewStore(pool *pgxpool.Pool) *Store { + return &Store{pool: pool} +} + +// BeginSetup erzeugt ein neues Secret fuer einen Benutzer und speichert es +// als NOCH NICHT bestaetigt — der Code muss vor Aktivierung einmal +// verifiziert werden (Akzeptanzkriterium 1). +func (s *Store) BeginSetup(ctx context.Context, userID, issuer, accountName string) (secret, provisioningURI string, err error) { + secret, err = GenerateSecret() + if err != nil { + return "", "", err + } + + _, err = s.pool.Exec(ctx, ` + INSERT INTO totp_credentials (user_id, secret, confirmed) + VALUES ($1, $2, false) + ON CONFLICT (user_id) DO UPDATE SET secret = $2, confirmed = false + `, userID, secret) + if err != nil { + return "", "", fmt.Errorf("totp-einrichtung speichern: %w", err) + } + + return secret, ProvisioningURI(issuer, accountName, secret), nil +} + +// ConfirmSetup verifiziert den ersten eingegebenen Code und aktiviert 2FA +// erst dann (Akzeptanzkriterium 1). Bei Erfolg werden einmalig +// Wiederherstellungscodes erzeugt und im Klartext zurueckgegeben — gespeichert +// wird nur deren Hash. +func (s *Store) ConfirmSetup(ctx context.Context, userID, code string) (recoveryCodes []string, err error) { + var secret string + if err := s.pool.QueryRow(ctx, ` + SELECT secret FROM totp_credentials WHERE user_id = $1 + `, userID).Scan(&secret); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotConfirmed + } + return nil, fmt.Errorf("secret lesen: %w", err) + } + + ok, err := Validate(secret, code, time.Now()) + if err != nil { + return nil, err + } + if !ok { + return nil, ErrInvalidCode + } + + if _, err := s.pool.Exec(ctx, `UPDATE totp_credentials SET confirmed = true WHERE user_id = $1`, userID); err != nil { + return nil, fmt.Errorf("bestaetigung speichern: %w", err) + } + + recoveryCodes, err = s.generateRecoveryCodes(ctx, userID) + if err != nil { + return nil, err + } + return recoveryCodes, nil +} + +func (s *Store) generateRecoveryCodes(ctx context.Context, userID string) ([]string, error) { + codes := make([]string, RecoveryCodeCount) + for i := range codes { + buf := make([]byte, 8) + if _, err := rand.Read(buf); err != nil { + return nil, fmt.Errorf("wiederherstellungscode erzeugen: %w", err) + } + code := hex.EncodeToString(buf) + codes[i] = code + + hash := hashRecoveryCode(code) + if _, err := s.pool.Exec(ctx, ` + INSERT INTO totp_recovery_codes (user_id, code_hash) VALUES ($1, $2) + `, userID, hash); err != nil { + return nil, fmt.Errorf("wiederherstellungscode speichern: %w", err) + } + } + return codes, nil +} + +// IsEnabled prueft, ob 2FA fuer einen Benutzer aktiv (bestaetigt) ist. +func (s *Store) IsEnabled(ctx context.Context, userID string) (bool, error) { + var confirmed bool + err := s.pool.QueryRow(ctx, `SELECT confirmed FROM totp_credentials WHERE user_id = $1`, userID).Scan(&confirmed) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return false, nil + } + return false, fmt.Errorf("status lesen: %w", err) + } + return confirmed, nil +} + +// VerifyLoginCode akzeptiert entweder einen gueltigen TOTP-Code oder einen +// noch nicht verwendeten Wiederherstellungscode (Akzeptanzkriterium 2 + 3). +func (s *Store) VerifyLoginCode(ctx context.Context, userID, code string) (bool, error) { + var secret string + var confirmed bool + err := s.pool.QueryRow(ctx, `SELECT secret, confirmed FROM totp_credentials WHERE user_id = $1`, userID).Scan(&secret, &confirmed) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) || !confirmed { + return false, nil + } + return false, fmt.Errorf("secret lesen: %w", err) + } + if !confirmed { + return false, nil + } + + if ok, err := Validate(secret, code, time.Now()); err != nil { + return false, err + } else if ok { + return true, nil + } + + return s.consumeRecoveryCode(ctx, userID, code) +} + +// consumeRecoveryCode markiert einen Wiederherstellungscode atomar als +// verwendet (WHERE used_at IS NULL) — ein bereits verbrauchter Code kann +// nicht doppelt eingeloest werden (Akzeptanzkriterium 3 / Pruefung 2). +func (s *Store) consumeRecoveryCode(ctx context.Context, userID, code string) (bool, error) { + hash := hashRecoveryCode(code) + tag, err := s.pool.Exec(ctx, ` + UPDATE totp_recovery_codes SET used_at = now() + WHERE user_id = $1 AND code_hash = $2 AND used_at IS NULL + `, userID, hash) + if err != nil { + return false, fmt.Errorf("wiederherstellungscode einloesen: %w", err) + } + return tag.RowsAffected() == 1, nil +} + +func hashRecoveryCode(code string) []byte { + sum := sha256.Sum256([]byte(code)) + return sum[:] +} diff --git a/internal/totp/store_test.go b/internal/totp/store_test.go new file mode 100644 index 0000000..9fb32c8 --- /dev/null +++ b/internal/totp/store_test.go @@ -0,0 +1,185 @@ +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") + } +} diff --git a/internal/totp/totp.go b/internal/totp/totp.go new file mode 100644 index 0000000..e69160d --- /dev/null +++ b/internal/totp/totp.go @@ -0,0 +1,104 @@ +// Package totp implementiert Core IAM-04: TOTP-basierte Zwei-Faktor- +// Authentifizierung (RFC 6238, aufbauend auf HOTP RFC 4226) mit stdlib +// crypto/hmac+sha1 — kein externes Modul noetig. +package totp + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha1" + "crypto/subtle" + "encoding/base32" + "encoding/binary" + "fmt" + "net/url" + "time" +) + +// StepSeconds ist das TOTP-Zeitfenster (RFC-6238-Standard: 30 Sekunden). +const StepSeconds = 30 + +// DefaultSkewSteps ist die dokumentierte Zeitversatz-Toleranz +// (Akzeptanzkriterium/Pruefung 3): +/- 1 Schritt = +/- 30 Sekunden um die +// aktuelle Serverzeit, insgesamt ein 90-Sekunden-Fenster. Grosszuegig genug +// fuer leicht abweichende Client-Uhren, eng genug um ein TOTP-Fenster nicht +// unbrauchbar lange gueltig zu lassen. +const DefaultSkewSteps = 1 + +// GenerateSecret erzeugt ein neues, zufaelliges TOTP-Secret (20 Byte, wie von +// Authenticator-Apps erwartet), Base32-kodiert ohne Padding. +func GenerateSecret() (string, error) { + buf := make([]byte, 20) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("secret erzeugen: %w", err) + } + return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf), nil +} + +// ProvisioningURI liefert die otpauth://-URI fuer den QR-Code +// (Akzeptanzkriterium 1) — das eigentliche QR-Rendering ist Frontend-Sache +// (IAM-08), hier wird nur die standardkonforme URI erzeugt. +func ProvisioningURI(issuer, accountName, secret string) string { + label := url.PathEscape(issuer) + ":" + url.PathEscape(accountName) + q := url.Values{} + q.Set("secret", secret) + q.Set("issuer", issuer) + q.Set("algorithm", "SHA1") + q.Set("digits", "6") + q.Set("period", fmt.Sprintf("%d", StepSeconds)) + return fmt.Sprintf("otpauth://totp/%s?%s", label, q.Encode()) +} + +// GenerateCode berechnet den 6-stelligen TOTP-Code fuer secret zum Zeitpunkt t. +func GenerateCode(secret string, t time.Time) (string, error) { + key, err := decodeSecret(secret) + if err != nil { + return "", err + } + counter := uint64(t.Unix() / StepSeconds) + return hotp(key, counter), nil +} + +// Validate prueft code gegen secret mit der dokumentierten Zeitversatz- +// Toleranz (DefaultSkewSteps) um t. Vergleich ist timing-safe. +func Validate(secret, code string, t time.Time) (bool, error) { + key, err := decodeSecret(secret) + if err != nil { + return false, err + } + counter := int64(t.Unix() / StepSeconds) + + for delta := -DefaultSkewSteps; delta <= DefaultSkewSteps; delta++ { + candidate := hotp(key, uint64(counter+int64(delta))) + if subtle.ConstantTimeCompare([]byte(candidate), []byte(code)) == 1 { + return true, nil + } + } + return false, nil +} + +func decodeSecret(secret string) ([]byte, error) { + key, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret) + if err != nil { + return nil, fmt.Errorf("secret dekodieren: %w", err) + } + return key, nil +} + +// hotp implementiert RFC 4226 mit HMAC-SHA1, 6 Ziffern. +func hotp(key []byte, counter uint64) string { + buf := make([]byte, 8) + binary.BigEndian.PutUint64(buf, counter) + + mac := hmac.New(sha1.New, key) + mac.Write(buf) + sum := mac.Sum(nil) + + offset := sum[len(sum)-1] & 0x0f + code := (uint32(sum[offset]&0x7f) << 24) | + (uint32(sum[offset+1]) << 16) | + (uint32(sum[offset+2]) << 8) | + uint32(sum[offset+3]) + + return fmt.Sprintf("%06d", code%1000000) +} diff --git a/internal/totp/totp_test.go b/internal/totp/totp_test.go new file mode 100644 index 0000000..feb8236 --- /dev/null +++ b/internal/totp/totp_test.go @@ -0,0 +1,79 @@ +package totp + +import ( + "testing" + "time" +) + +func TestGenerateAndValidateCode_RoundTrip(t *testing.T) { + secret, err := GenerateSecret() + if err != nil { + t.Fatalf("generate secret: %v", err) + } + now := time.Now() + code, err := GenerateCode(secret, now) + if err != nil { + t.Fatalf("generate code: %v", err) + } + if len(code) != 6 { + t.Fatalf("erwartet 6-stelligen code, habe %q", code) + } + + ok, err := Validate(secret, code, now) + if err != nil { + t.Fatalf("validate: %v", err) + } + if !ok { + t.Fatal("erwartet gueltigen code") + } + + wrongCode := "000000" + if code == wrongCode { + wrongCode = "111111" + } + ok, err = Validate(secret, wrongCode, now) + if err != nil { + t.Fatalf("validate (falsch): %v", err) + } + if ok { + t.Fatal("erwartet ungueltig fuer falschen code") + } +} + +// Akzeptanzkriterium/Pruefung 3: Zeitversatz-Toleranz dokumentiert (siehe +// DefaultSkewSteps = 1 Schritt = 30s) und getestet. +func TestValidate_ClockSkewTolerance(t *testing.T) { + secret, err := GenerateSecret() + if err != nil { + t.Fatalf("generate secret: %v", err) + } + now := time.Now() + + // Code aus dem VORHERIGEN Zeitfenster (bis zu 30s in der Vergangenheit) + // muss innerhalb der dokumentierten Toleranz noch akzeptiert werden. + pastCode, err := GenerateCode(secret, now.Add(-25*time.Second)) + if err != nil { + t.Fatalf("generate code (past): %v", err) + } + ok, err := Validate(secret, pastCode, now) + if err != nil { + t.Fatalf("validate (past): %v", err) + } + if !ok { + t.Fatal("erwartet gueltig innerhalb der dokumentierten skew-toleranz (25s)") + } + + // Code weit ausserhalb der Toleranz (mehrere Schritte in der + // Vergangenheit) muss abgelehnt werden. + farPastCode, err := GenerateCode(secret, now.Add(-5*time.Minute)) + if err != nil { + t.Fatalf("generate code (far past): %v", err) + } + ok, err = Validate(secret, farPastCode, now) + if err != nil { + t.Fatalf("validate (far past): %v", err) + } + if ok { + t.Fatal("erwartet ungueltig weit ausserhalb der skew-toleranz (5min)") + } +} diff --git a/migrations/tenant/0003_totp.down.sql b/migrations/tenant/0003_totp.down.sql new file mode 100644 index 0000000..93abf7c --- /dev/null +++ b/migrations/tenant/0003_totp.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS totp_policy; +DROP TABLE IF EXISTS totp_recovery_codes; +DROP TABLE IF EXISTS totp_credentials; diff --git a/migrations/tenant/0003_totp.up.sql b/migrations/tenant/0003_totp.up.sql new file mode 100644 index 0000000..ea24404 --- /dev/null +++ b/migrations/tenant/0003_totp.up.sql @@ -0,0 +1,23 @@ +-- Zwei-Faktor-Authentifizierung TOTP (IAM-04, siehe core-kanban/tickets/IAM-04.md). +CREATE TABLE 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() +); + +-- code_hash enthaelt NIEMALS den Klartext-Wiederherstellungscode, nur dessen +-- SHA-256-Hash. used_at markiert einmalige Verwendbarkeit (Akzeptanzkriterium 3). +CREATE TABLE 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() +); + +-- Erzwingbare Aktivierungspflicht pro Tenant (Singleton-Zeile, Modell C). +CREATE TABLE totp_policy ( + id BOOLEAN PRIMARY KEY DEFAULT true CHECK (id), + required BOOLEAN NOT NULL DEFAULT false +); diff --git a/scripts/reset-test-env.sh b/scripts/reset-test-env.sh new file mode 100755 index 0000000..fab5903 --- /dev/null +++ b/scripts/reset-test-env.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail +PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}" +ROLE="nexarch_test" +export PGPASSWORD="$PASS" +psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenants CASCADE;" +dbs=$(psql -h localhost -U "$ROLE" -d postgres -tAc "SELECT datname FROM pg_database WHERE datname LIKE 'tenant\_%' ESCAPE '\'") +for db in $dbs; do + psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP DATABASE IF EXISTS \"${db}\";" +done +echo "Testumgebung zurueckgesetzt: registry-tabelle + $(echo "$dbs" | grep -c . || true) tenant-datenbank(en) entfernt." diff --git a/scripts/run-checks.sh b/scripts/run-checks.sh new file mode 100755 index 0000000..1c28c3c --- /dev/null +++ b/scripts/run-checks.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail +PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}" +cd "$(dirname "$0")/.." +NEXARCH_TEST_DB_PASSWORD="$PASS" bash scripts/reset-test-env.sh +export TEST_ADMIN_DSN="postgresql://nexarch_test:${PASS}@localhost:5432/postgres?sslmode=disable" +echo "== go build ==" +go build ./... +echo "== go vet ==" +go vet ./... +echo "== go test (-p 1) ==" +go test ./... -p 1 -count=1 From 1b4c3db9b88ecac0158f5f50d653041e5cc8abdf Mon Sep 17 00:00:00 2001 From: sysops Date: Fri, 28 Aug 2026 22:54:02 +0200 Subject: [PATCH 2/2] DEVLOG: Sessionlog-Eintrag (Auto-Hook) --- DEVLOG.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/DEVLOG.md b/DEVLOG.md index 6a9fd56..fda258b 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -129,3 +129,21 @@ Keine Commits in dieser Session. - migrations/0001_tenant_registry.sql | 10 ++++++++++ --- +## 2026-08-28 22:48 – 22:51 (3m) +**Beschreibung:** Claude Code Session +**Projekt:** nexarch + +### Commits +- c344dea TEN-08: tenant-loeschung-unter-retention-vorbehalt-gobd (RetentionChecker-Schnittstelle gegen Archive RET-03/CMP-06, ProcessDueDeletions haelt gesperrte Tenants zurueck) + +### Geänderte Dateien +- internal/tenant/lifecycle.go | 45 ++++++++++++++++++++++++++++++++++++++++----- +- internal/tenant/lifecycle_test.go | 4 +++- +- internal/tenant/registry.go | 7 +++++-- +- internal/tenant/retention.go | 34 ++++++++++++++++++++++++++++++++++ +- internal/tenant/retention_test.go | 164 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- internal/tenant/tenant.go | 5 +++++ +- migrations/0004_tenant_retention_block.down.sql | 2 ++ +- migrations/0004_tenant_retention_block.up.sql | 6 ++++++ + +---