From b6a941feecbfa264ccaac1254d1b5eca5e65dd91 Mon Sep 17 00:00:00 2001 From: sysops Date: Thu, 27 Aug 2026 22:09:32 +0200 Subject: [PATCH] IAM-03: passwort-reset-einladungs-flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit internal/authtoken: Einmal-Token fuer Passwort-Reset und Einladung, getrennt von internal/auth (Sessions/JWT), da der Vorgang bewusst session-los ist. Store.Create gibt das Klartext-Token NUR an den Aufrufer zurueck (fuer E-Mail-Versand, IAM-08), gespeichert wird ausschliesslich der SHA-256-Hash. Store.Consume markiert ein Token atomar als verwendet (UPDATE ... WHERE used_at IS NULL AND expires_at > now() RETURNING user_id) — Wiederverwendung und Ablauf werden serverseitig in derselben Datenbankoperation durchgesetzt, kein Race zwischen Pruefen und Verbrauchen moeglich. Ungueltig, bereits verwendet und abgelaufen liefern denselben ErrInvalidToken (Akzeptanz- kriterium 3), damit die Antwort keinen der drei Faelle verraet. CompletePasswordReset/CompleteInvitation loesen ein Token ein und setzen das Passwort ueber user.TenantUserStore.SetPasswordHash (IAM-01) — keine Auth-Middleware, keine Session noetig (Akzeptanzkriterium 2). Log-Statements bei Create/Consume enthalten bewusst nur user_id/purpose, niemals das Token selbst. Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS): 1. Wiederverwendung eines bereits eingeloesten Tokens automatisiert abgewiesen — TestConsume_RejectsReuse. PASS. 2. Ablaufzeit serverseitig durchgesetzt — TestConsume_RejectsExpiredToken (Token mit negativer TTL sofort abgelaufen). PASS. 3. Token werden nicht im Klartext geloggt (Stichprobe im Log-Ausgang) — TestCreateAndConsume_NeverLogTokenPlaintext: Log-Buffer nach Create + ungueltigem + gueltigem Consume enthaelt das Klartext-Token nachweislich nicht. PASS. Zusaetzlich: TestCompleteInvitation_SetsPasswordWithoutSession belegt Akzeptanzkriterium 2 konkret (Passwort gesetzt und verifizierbar, keine Session im Ablauf beteiligt). PASS. Co-Authored-By: Claude Sonnet 5 --- internal/authtoken/flow.go | 40 ++++ internal/authtoken/token.go | 103 ++++++++++ internal/authtoken/token_test.go | 187 ++++++++++++++++++ .../tenant/0003_password_tokens.down.sql | 1 + migrations/tenant/0003_password_tokens.up.sql | 12 ++ scripts/reset-test-env.sh | 11 ++ scripts/run-checks.sh | 12 ++ 7 files changed, 366 insertions(+) create mode 100644 internal/authtoken/flow.go create mode 100644 internal/authtoken/token.go create mode 100644 internal/authtoken/token_test.go create mode 100644 migrations/tenant/0003_password_tokens.down.sql create mode 100644 migrations/tenant/0003_password_tokens.up.sql create mode 100755 scripts/reset-test-env.sh create mode 100755 scripts/run-checks.sh diff --git a/internal/authtoken/flow.go b/internal/authtoken/flow.go new file mode 100644 index 0000000..188a6ae --- /dev/null +++ b/internal/authtoken/flow.go @@ -0,0 +1,40 @@ +package authtoken + +import ( + "context" + "fmt" + + "gitea.perlbach24.de/scripte/nexarch/internal/auth" + "gitea.perlbach24.de/scripte/nexarch/internal/user" +) + +// CompletePasswordReset loest ein Reset-Token ein und setzt das neue +// Passwort — komplett OHNE bestehende Session (Akzeptanzkriterium 2 gilt +// analog fuer Reset wie fuer Invite: der Vorgang ersetzt eine Anmeldung). +func CompletePasswordReset(ctx context.Context, tokens *Store, users *user.TenantUserStore, token, newPassword string) error { + return completeWithToken(ctx, tokens, users, token, PurposeReset, newPassword) +} + +// CompleteInvitation loest ein Einladungs-Token ein und setzt das +// Erstpasswort eines neu angelegten Benutzers — ohne bestehende Session +// (Akzeptanzkriterium 2). +func CompleteInvitation(ctx context.Context, tokens *Store, users *user.TenantUserStore, token, initialPassword string) error { + return completeWithToken(ctx, tokens, users, token, PurposeInvite, initialPassword) +} + +func completeWithToken(ctx context.Context, tokens *Store, users *user.TenantUserStore, token string, purpose Purpose, newPassword string) error { + userID, err := tokens.Consume(ctx, token, purpose) + if err != nil { + return err + } + + hash, err := auth.HashPassword(newPassword) + if err != nil { + return fmt.Errorf("passwort hashen: %w", err) + } + + if err := users.SetPasswordHash(ctx, userID, hash); err != nil { + return fmt.Errorf("passwort setzen: %w", err) + } + return nil +} diff --git a/internal/authtoken/token.go b/internal/authtoken/token.go new file mode 100644 index 0000000..06710b0 --- /dev/null +++ b/internal/authtoken/token.go @@ -0,0 +1,103 @@ +// Package authtoken implementiert Core IAM-03: einmal-verwendbare Tokens fuer +// Passwort-Reset und Einladung neuer Benutzer, mit Ablaufzeit — bewusst +// getrennt von internal/auth (Sessions/JWT), da es hier um einen +// vorgelagerten, session-losen Vorgang geht (Akzeptanzkriterium 2: Einladung +// setzt das Erstpasswort OHNE bestehende Session). +package authtoken + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "log/slog" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type Purpose string + +const ( + PurposeReset Purpose = "reset" + PurposeInvite Purpose = "invite" +) + +// ErrInvalidToken deckt sowohl unbekannte als auch bereits verwendete oder +// abgelaufene Tokens ab (Akzeptanzkriterium 3) — bewusst EIN Fehler, damit +// ein Angreifer aus der Antwort nicht ableiten kann, welcher der drei Faelle +// vorliegt. +var ErrInvalidToken = errors.New("authtoken: token ungueltig, bereits verwendet oder abgelaufen") + +type Store struct { + pool *pgxpool.Pool +} + +func NewStore(pool *pgxpool.Pool) *Store { + return &Store{pool: pool} +} + +// Create erzeugt ein neues Einmal-Token und liefert es NUR im Klartext an +// den Aufrufer zurueck (zum Versand per E-Mail, siehe IAM-08) — gespeichert +// wird ausschliesslich der Hash. Es wird niemals geloggt (Akzeptanzkriterium +// 3 / Pruefung 3): das Log-Statement unten enthaelt bewusst nur user_id und +// purpose, nicht token. +func (s *Store) Create(ctx context.Context, userID string, purpose Purpose, ttl time.Duration) (token string, err error) { + token, err = randomToken() + if err != nil { + return "", fmt.Errorf("token erzeugen: %w", err) + } + hash := hashToken(token) + expiresAt := time.Now().Add(ttl) + + _, err = s.pool.Exec(ctx, ` + INSERT INTO password_tokens (user_id, token_hash, purpose, expires_at) + VALUES ($1, $2, $3, $4) + `, userID, hash, string(purpose), expiresAt) + if err != nil { + return "", fmt.Errorf("token speichern: %w", err) + } + + slog.Info("passwort-token erzeugt", "user_id", userID, "purpose", purpose, "laeuft_ab", expiresAt) + return token, nil +} + +// Consume loest ein Token ein: gueltig nur, wenn es existiert, noch nicht +// verwendet und nicht abgelaufen ist. Das Markieren als verwendet geschieht +// atomar ueber die WHERE-Klausel (used_at IS NULL) — zwei gleichzeitige +// Einloesungsversuche koennen sich nicht gegenseitig ueberholen +// (Akzeptanzkriterium 1 / Pruefung 1: nur EINE Einloesung gelingt). +func (s *Store) Consume(ctx context.Context, token string, purpose Purpose) (userID string, err error) { + hash := hashToken(token) + + err = s.pool.QueryRow(ctx, ` + UPDATE password_tokens + SET used_at = now() + WHERE token_hash = $1 AND purpose = $2 AND used_at IS NULL AND expires_at > now() + RETURNING user_id + `, hash, string(purpose)).Scan(&userID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + slog.Warn("token-einloesung abgewiesen", "purpose", purpose, "grund", "ungueltig, bereits verwendet oder abgelaufen") + return "", ErrInvalidToken + } + return "", fmt.Errorf("token einloesen: %w", err) + } + return userID, nil +} + +func randomToken() (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return hex.EncodeToString(buf), nil +} + +func hashToken(token string) []byte { + sum := sha256.Sum256([]byte(token)) + return sum[:] +} diff --git a/internal/authtoken/token_test.go b/internal/authtoken/token_test.go new file mode 100644 index 0000000..ed5e2e6 --- /dev/null +++ b/internal/authtoken/token_test.go @@ -0,0 +1,187 @@ +package authtoken + +import ( + "bytes" + "context" + "errors" + "fmt" + "log/slog" + "os" + "strings" + "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 password_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id), + token_hash BYTEA NOT NULL UNIQUE, purpose TEXT NOT NULL CHECK (purpose IN ('reset','invite')), + expires_at TIMESTAMPTZ NOT NULL, used_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + `); err != nil { + t.Fatalf("schema: %v", err) + } + + userStore := user.NewTenantUserStore(pool) + email := fmt.Sprintf("reset-test-%d@example.com", time.Now().UnixNano()) + u, err := userStore.Create(ctx, email, "Reset Test") + if err != nil { + t.Fatalf("testuser anlegen: %v", err) + } + + cleanup := func() { pool.Close() } + return NewStore(pool), userStore, u.ID, cleanup +} + +// Akzeptanzkriterium 1 + Pruefung 1: Wiederverwendung eines bereits +// eingeloesten Tokens wird abgewiesen. +func TestConsume_RejectsReuse(t *testing.T) { + store, _, userID, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + token, err := store.Create(ctx, userID, PurposeReset, time.Hour) + if err != nil { + t.Fatalf("create: %v", err) + } + + gotUserID, err := store.Consume(ctx, token, PurposeReset) + if err != nil { + t.Fatalf("erste einloesung: %v", err) + } + if gotUserID != userID { + t.Fatalf("user id = %q, want %q", gotUserID, userID) + } + + if _, err := store.Consume(ctx, token, PurposeReset); !errors.Is(err, ErrInvalidToken) { + t.Fatalf("erwartet ErrInvalidToken bei zweiter einloesung, habe %v", err) + } +} + +// Akzeptanzkriterium 1 + 3 + Pruefung 2: Ablaufzeit wird serverseitig +// durchgesetzt. +func TestConsume_RejectsExpiredToken(t *testing.T) { + store, _, userID, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + token, err := store.Create(ctx, userID, PurposeReset, -1*time.Second) // sofort abgelaufen + if err != nil { + t.Fatalf("create: %v", err) + } + + if _, err := store.Consume(ctx, token, PurposeReset); !errors.Is(err, ErrInvalidToken) { + t.Fatalf("erwartet ErrInvalidToken bei abgelaufenem token, habe %v", err) + } +} + +func TestConsume_RejectsUnknownToken(t *testing.T) { + store, _, _, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + if _, err := store.Consume(ctx, "niemals-ausgestelltes-token", PurposeReset); !errors.Is(err, ErrInvalidToken) { + t.Fatalf("erwartet ErrInvalidToken, habe %v", err) + } +} + +func TestConsume_RejectsWrongPurpose(t *testing.T) { + store, _, userID, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + token, err := store.Create(ctx, userID, PurposeInvite, time.Hour) + if err != nil { + t.Fatalf("create: %v", err) + } + if _, err := store.Consume(ctx, token, PurposeReset); !errors.Is(err, ErrInvalidToken) { + t.Fatalf("erwartet ErrInvalidToken bei falschem purpose, habe %v", err) + } +} + +// Akzeptanzkriterium 2: Einladungs-Link setzt das Erstpasswort ohne +// bestehende Session — der Ablauf braucht keinerlei Auth-Middleware/Cookie. +func TestCompleteInvitation_SetsPasswordWithoutSession(t *testing.T) { + store, userStore, userID, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + token, err := store.Create(ctx, userID, PurposeInvite, time.Hour) + if err != nil { + t.Fatalf("create: %v", err) + } + + if err := CompleteInvitation(ctx, store, userStore, token, "neues-erstpasswort-123"); err != nil { + t.Fatalf("complete invitation: %v", err) + } + + u, err := userStore.Get(ctx, userID) + if err != nil { + t.Fatalf("user lesen: %v", err) + } + creds, err := userStore.GetByEmailForAuth(ctx, u.Email) + if err != nil { + t.Fatalf("credentials lesen: %v", err) + } + if !auth.VerifyPassword(creds.PasswordHash, "neues-erstpasswort-123") { + t.Fatal("erwartet, dass das gesetzte erstpasswort verifizierbar ist") + } + + // Token ist danach verbraucht, kann nicht doppelt eingeloest werden. + if err := CompleteInvitation(ctx, store, userStore, token, "irgendwas"); !errors.Is(err, ErrInvalidToken) { + t.Fatalf("erwartet ErrInvalidToken bei erneuter einloesung, habe %v", err) + } +} + +// Akzeptanzkriterium 3 + Pruefung 3: Token werden nicht im Klartext geloggt. +func TestCreateAndConsume_NeverLogTokenPlaintext(t *testing.T) { + store, _, userID, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + var buf bytes.Buffer + prevLogger := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil))) + defer slog.SetDefault(prevLogger) + + token, err := store.Create(ctx, userID, PurposeReset, time.Hour) + if err != nil { + t.Fatalf("create: %v", err) + } + if _, err := store.Consume(ctx, "falsches-token-zum-testen-des-log-pfads", PurposeReset); !errors.Is(err, ErrInvalidToken) { + t.Fatalf("consume (ungueltig): %v", err) + } + if _, err := store.Consume(ctx, token, PurposeReset); err != nil { + t.Fatalf("consume (gueltig): %v", err) + } + + logOutput := buf.String() + if strings.Contains(logOutput, token) { + t.Fatalf("log enthaelt das klartext-token — verstoss gegen akzeptanzkriterium 3:\n%s", logOutput) + } + if logOutput == "" { + t.Fatal("erwartet ueberhaupt log-ausgabe fuer diesen stichprobentest") + } +} diff --git a/migrations/tenant/0003_password_tokens.down.sql b/migrations/tenant/0003_password_tokens.down.sql new file mode 100644 index 0000000..d421678 --- /dev/null +++ b/migrations/tenant/0003_password_tokens.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS password_tokens; diff --git a/migrations/tenant/0003_password_tokens.up.sql b/migrations/tenant/0003_password_tokens.up.sql new file mode 100644 index 0000000..3c50617 --- /dev/null +++ b/migrations/tenant/0003_password_tokens.up.sql @@ -0,0 +1,12 @@ +-- Einmal-Token fuer Passwort-Reset und Einladung (IAM-03, siehe +-- core-kanban/tickets/IAM-03.md). token_hash enthaelt NIEMALS den +-- Klartext-Token, nur dessen SHA-256-Hash. +CREATE TABLE password_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id), + token_hash BYTEA NOT NULL UNIQUE, + purpose TEXT NOT NULL CHECK (purpose IN ('reset', 'invite')), + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); 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