From 3c4e9d45b802c42413a89ca4f9728e0814bfaf2d Mon Sep 17 00:00:00 2001 From: sysops Date: Thu, 27 Aug 2026 22:26:23 +0200 Subject: [PATCH] IAM-14: passwort-richtlinien MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit internal/pwpolicy: Validate ist eine schmale Regelschicht VOR dem bcrypt-Hashing aus IAM-02, kein eigenes Policy-Framework. Prueft Mindestlaenge, Zeichenklassen (Gross-/Kleinbuchstaben, Ziffern, Sonderzeichen je nach Policy) und eine eingebettete Sperrliste haeufig verwendeter Passwoerter (case-insensitive) — Akzeptanzkriterium 1 + 2. Store haelt die Richtlinie als eine Zeile je Tenant-Datenbank (Singleton, Modell C), DefaultPolicy() greift, solange kein Tenant eine eigene gesetzt hat. LoginAndCheckPolicy komponiert IAM-02s LoginService, OHNE ihn zu veraendern: der Login selbst schlaegt bei einem alten, nicht mehr konformen Passwort NICHT fehl (Akzeptanzkriterium 3 — kein rueckwirkendes Aussperren), die Funktion liefert zusaetzlich mustChangePassword=true. Die Pruefung ist nur im Login-Moment moeglich, da dort kurzzeitig das Klartext-Passwort vorliegt — der gespeicherte bcrypt-Hash laesst sich nicht rueckwirkend gegen eine neue Richtlinie pruefen. Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS): 1. Zu kurzes/zu einfaches Passwort bei Registrierung/Aenderung abgelehnt — TestValidate_RejectsTooShortOrSimple. PASS. 2. Passwort aus Sperrliste abgelehnt — TestValidate_RejectsBlocklistedPassword (inkl. Gross-/Kleinschreibung). PASS. 3. Bestehender Nutzer mit altem, nicht-konformem Passwort kann sich noch einloggen, wird aber zur Aenderung aufgefordert — TestLoginAndCheckPolicy_FlagsNonConformantExistingPassword: Login mit schwachem Altpasswort gelingt, mustChangePassword=true; mit konformem Passwort mustChangePassword=false. PASS. Co-Authored-By: Claude Sonnet 5 --- internal/pwpolicy/blocklist.go | 33 ++++ internal/pwpolicy/login_check.go | 30 +++ internal/pwpolicy/policy.go | 112 +++++++++++ internal/pwpolicy/policy_test.go | 176 ++++++++++++++++++ .../tenant/0003_password_policy.down.sql | 1 + migrations/tenant/0003_password_policy.up.sql | 11 ++ scripts/reset-test-env.sh | 11 ++ scripts/run-checks.sh | 12 ++ 8 files changed, 386 insertions(+) create mode 100644 internal/pwpolicy/blocklist.go create mode 100644 internal/pwpolicy/login_check.go create mode 100644 internal/pwpolicy/policy.go create mode 100644 internal/pwpolicy/policy_test.go create mode 100644 migrations/tenant/0003_password_policy.down.sql create mode 100644 migrations/tenant/0003_password_policy.up.sql create mode 100755 scripts/reset-test-env.sh create mode 100755 scripts/run-checks.sh diff --git a/internal/pwpolicy/blocklist.go b/internal/pwpolicy/blocklist.go new file mode 100644 index 0000000..b7b6a5e --- /dev/null +++ b/internal/pwpolicy/blocklist.go @@ -0,0 +1,33 @@ +package pwpolicy + +// commonPasswords ist eine kleine, eingebettete Sperrliste bekannt +// haeufig verwendeter/kompromittierter Passwoerter (Akzeptanzkriterium 2). +// Vergleich erfolgt case-insensitive (siehe Validate). +var commonPasswords = map[string]bool{ + "123456": true, + "password": true, + "12345678": true, + "qwerty": true, + "123456789": true, + "12345": true, + "1234": true, + "111111": true, + "1234567": true, + "dragon": true, + "123123": true, + "baseball": true, + "iloveyou": true, + "trustno1": true, + "1234567890": true, + "sunshine": true, + "master": true, + "welcome": true, + "admin": true, + "letmein": true, + "login": true, + "passw0rd": true, + "starwars": true, + "abc123": true, + "password1": true, + "qwerty123": true, +} diff --git a/internal/pwpolicy/login_check.go b/internal/pwpolicy/login_check.go new file mode 100644 index 0000000..726083d --- /dev/null +++ b/internal/pwpolicy/login_check.go @@ -0,0 +1,30 @@ +package pwpolicy + +import ( + "context" + + "gitea.perlbach24.de/scripte/nexarch/internal/auth" +) + +// LoginAndCheckPolicy komponiert IAM-02s LoginService mit der Passwort- +// Richtlinienpruefung, OHNE LoginService selbst zu veraendern. Ein +// bestehender Benutzer mit einem alten, nicht mehr konformen Passwort kann +// sich weiterhin einloggen (Login schlaegt NICHT fehl) — er wird lediglich +// zur Aenderung aufgefordert (Akzeptanzkriterium 3: kein rueckwirkendes +// Aussperren). Die Pruefung ist nur HIER moeglich, da nur beim Login das +// Klartext-Passwort kurzzeitig vorliegt — der gespeicherte bcrypt-Hash laesst +// sich nicht rueckwirkend gegen eine neue Richtlinie pruefen. +func LoginAndCheckPolicy(ctx context.Context, policies *Store, login *auth.LoginService, email, password string) (token string, mustChangePassword bool, err error) { + token, err = login.Login(ctx, email, password) + if err != nil { + return "", false, err + } + + policy, err := policies.Get(ctx) + if err != nil { + return "", false, err + } + + mustChangePassword = Validate(policy, password) != nil + return token, mustChangePassword, nil +} diff --git a/internal/pwpolicy/policy.go b/internal/pwpolicy/policy.go new file mode 100644 index 0000000..329d4f3 --- /dev/null +++ b/internal/pwpolicy/policy.go @@ -0,0 +1,112 @@ +// Package pwpolicy implementiert Core IAM-14: konfigurierbare Passwort- +// Komplexitätsanforderungen pro Tenant — eine schmale Regelschicht VOR dem +// bcrypt-Hashing aus IAM-02, kein eigenes Policy-Framework. +package pwpolicy + +import ( + "context" + "errors" + "fmt" + "strings" + "unicode" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// Policy sind die Mindestanforderungen (Akzeptanzkriterium 1). +type Policy struct { + MinLength int + RequireUpper bool + RequireLower bool + RequireDigit bool + RequireSpecial bool +} + +// DefaultPolicy gilt, solange ein Tenant keine eigene Konfiguration gesetzt hat. +func DefaultPolicy() Policy { + return Policy{MinLength: 12, RequireUpper: true, RequireLower: true, RequireDigit: true, RequireSpecial: false} +} + +var ErrPasswordTooWeak = errors.New("pwpolicy: passwort erfuellt die richtlinie nicht") + +// Validate prueft ein Passwort gegen policy UND die Sperrliste +// (Akzeptanzkriterium 2). Liefert bei Verstoss ErrPasswordTooWeak, +// gewrappt mit einer fuer Menschen lesbaren Begruendung. +func Validate(policy Policy, password string) error { + if commonPasswords[strings.ToLower(password)] { + return fmt.Errorf("%w: passwort steht auf der sperrliste haeufig verwendeter passwoerter", ErrPasswordTooWeak) + } + if len(password) < policy.MinLength { + return fmt.Errorf("%w: mindestens %d zeichen erforderlich", ErrPasswordTooWeak, policy.MinLength) + } + + var hasUpper, hasLower, hasDigit, hasSpecial bool + for _, r := range password { + switch { + case unicode.IsUpper(r): + hasUpper = true + case unicode.IsLower(r): + hasLower = true + case unicode.IsDigit(r): + hasDigit = true + case unicode.IsPunct(r) || unicode.IsSymbol(r): + hasSpecial = true + } + } + + if policy.RequireUpper && !hasUpper { + return fmt.Errorf("%w: mindestens ein grossbuchstabe erforderlich", ErrPasswordTooWeak) + } + if policy.RequireLower && !hasLower { + return fmt.Errorf("%w: mindestens ein kleinbuchstabe erforderlich", ErrPasswordTooWeak) + } + if policy.RequireDigit && !hasDigit { + return fmt.Errorf("%w: mindestens eine ziffer erforderlich", ErrPasswordTooWeak) + } + if policy.RequireSpecial && !hasSpecial { + return fmt.Errorf("%w: mindestens ein sonderzeichen erforderlich", ErrPasswordTooWeak) + } + return nil +} + +// Store verwaltet die pro-Tenant konfigurierte Richtlinie (eine Zeile je +// Tenant-Datenbank, Modell C). +type Store struct { + pool *pgxpool.Pool +} + +func NewStore(pool *pgxpool.Pool) *Store { + return &Store{pool: pool} +} + +// Get liefert die konfigurierte Richtlinie, oder DefaultPolicy() wenn noch +// keine gesetzt wurde. +func (s *Store) Get(ctx context.Context) (Policy, error) { + var p Policy + err := s.pool.QueryRow(ctx, ` + SELECT min_length, require_upper, require_lower, require_digit, require_special + FROM password_policy WHERE id = true + `).Scan(&p.MinLength, &p.RequireUpper, &p.RequireLower, &p.RequireDigit, &p.RequireSpecial) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return DefaultPolicy(), nil + } + return Policy{}, fmt.Errorf("richtlinie lesen: %w", err) + } + return p, nil +} + +// Set legt die Richtlinie fuer diesen Tenant fest (Akzeptanzkriterium 1). +func (s *Store) Set(ctx context.Context, p Policy) error { + _, err := s.pool.Exec(ctx, ` + INSERT INTO password_policy (id, min_length, require_upper, require_lower, require_digit, require_special, updated_at) + VALUES (true, $1, $2, $3, $4, $5, now()) + ON CONFLICT (id) DO UPDATE SET + min_length = $1, require_upper = $2, require_lower = $3, require_digit = $4, require_special = $5, updated_at = now() + `, p.MinLength, p.RequireUpper, p.RequireLower, p.RequireDigit, p.RequireSpecial) + if err != nil { + return fmt.Errorf("richtlinie speichern: %w", err) + } + return nil +} diff --git a/internal/pwpolicy/policy_test.go b/internal/pwpolicy/policy_test.go new file mode 100644 index 0000000..8b8f1dc --- /dev/null +++ b/internal/pwpolicy/policy_test.go @@ -0,0 +1,176 @@ +package pwpolicy + +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" +) + +// Akzeptanzkriterium 1 + Pruefung 1: zu kurzes/zu einfaches Passwort abgelehnt. +func TestValidate_RejectsTooShortOrSimple(t *testing.T) { + policy := DefaultPolicy() + + if err := Validate(policy, "kurz1A"); !errors.Is(err, ErrPasswordTooWeak) { + t.Fatalf("erwartet ErrPasswordTooWeak fuer zu kurzes passwort, habe %v", err) + } + if err := Validate(policy, "alleskleingeschriebenundlang123"); !errors.Is(err, ErrPasswordTooWeak) { + t.Fatalf("erwartet ErrPasswordTooWeak ohne grossbuchstaben, habe %v", err) + } + if err := Validate(policy, "GutesPasswort2026!"); err != nil { + t.Fatalf("erwartet gueltig, habe %v", err) + } +} + +// Akzeptanzkriterium 2 + Pruefung 2: Passwort aus Sperrliste abgelehnt. +func TestValidate_RejectsBlocklistedPassword(t *testing.T) { + policy := Policy{MinLength: 4} // absichtlich schwach, damit NUR die sperrliste greift + + if err := Validate(policy, "password"); !errors.Is(err, ErrPasswordTooWeak) { + t.Fatalf("erwartet ErrPasswordTooWeak fuer sperrlisten-passwort, habe %v", err) + } + if err := Validate(policy, "PASSWORD"); !errors.Is(err, ErrPasswordTooWeak) { + t.Fatalf("erwartet case-insensitive treffer auf der sperrliste, habe %v", err) + } + if err := Validate(policy, "ein-eher-unueblicher-satz"); err != nil { + t.Fatalf("nicht-sperrlisten-passwort sollte hier gueltig sein: %v", err) + } +} + +func setupTest(t *testing.T) (*Store, *pgxpool.Pool, 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_policy ( + id BOOLEAN PRIMARY KEY DEFAULT true CHECK (id), min_length INT NOT NULL DEFAULT 12, + require_upper BOOLEAN NOT NULL DEFAULT true, require_lower BOOLEAN NOT NULL DEFAULT true, + require_digit BOOLEAN NOT NULL DEFAULT true, require_special BOOLEAN NOT NULL DEFAULT false, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + `); err != nil { + t.Fatalf("schema: %v", err) + } + + // Singleton-Tabelle: vor jedem Test leeren, falls ein vorheriger Lauf + // (in dieser geteilten Registry-Tabelle) eine Zeile hinterlassen hat. + if _, err := pool.Exec(ctx, `DELETE FROM password_policy`); err != nil { + t.Fatalf("vorab-bereinigung: %v", err) + } + + cleanup := func() { + _, _ = pool.Exec(ctx, `DELETE FROM password_policy`) + pool.Close() + } + return NewStore(pool), pool, cleanup +} + +func TestStore_GetReturnsDefaultWhenUnset(t *testing.T) { + store, _, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + got, err := store.Get(ctx) + if err != nil { + t.Fatalf("get: %v", err) + } + if got != DefaultPolicy() { + t.Fatalf("erwartet default policy, habe %+v", got) + } +} + +func TestStore_SetAndGetRoundTrip(t *testing.T) { + store, _, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + custom := Policy{MinLength: 16, RequireUpper: true, RequireLower: true, RequireDigit: true, RequireSpecial: true} + if err := store.Set(ctx, custom); err != nil { + t.Fatalf("set: %v", err) + } + got, err := store.Get(ctx) + if err != nil { + t.Fatalf("get: %v", err) + } + if got != custom { + t.Fatalf("erwartet %+v, habe %+v", custom, got) + } +} + +// Akzeptanzkriterium 3 + Pruefung 3: bestehender Nutzer mit altem, +// nicht-konformem Passwort kann sich noch einloggen, wird aber zur +// Aenderung aufgefordert — kein rueckwirkendes Aussperren. +func TestLoginAndCheckPolicy_FlagsNonConformantExistingPassword(t *testing.T) { + store, pool, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + email := fmt.Sprintf("pwpolicy-test-%d@example.com", time.Now().UnixNano()) + userStore := user.NewTenantUserStore(pool) + u, err := userStore.Create(ctx, email, "Legacy User") + if err != nil { + t.Fatalf("create user: %v", err) + } + + // Altes Passwort, das VOR der heutigen Richtlinie gesetzt wurde — erfuellt + // die aktuelle DefaultPolicy() nicht (kein Grossbuchstabe, zu kurz). + oldPassword := "altespasswort" + hash, err := auth.HashPassword(oldPassword) + if err != nil { + t.Fatalf("hash: %v", err) + } + if err := userStore.SetPasswordHash(ctx, u.ID, hash); err != nil { + t.Fatalf("set password hash: %v", err) + } + + issuer := auth.NewTokenIssuer("test-secret-nur-fuer-tests") + loginService := auth.NewLoginService(userStore, issuer, "acme") + + token, mustChange, err := LoginAndCheckPolicy(ctx, store, loginService, email, oldPassword) + if err != nil { + t.Fatalf("login sollte trotz schwachem altpasswort gelingen: %v", err) + } + if token == "" { + t.Fatal("erwartet gueltiges token") + } + if !mustChange { + t.Fatal("erwartet mustChangePassword=true fuer nicht-konformes altpasswort") + } + + // Konformes Passwort -> kein Aenderungszwang. + strongPassword := "EinStarkesPasswort2026!" + hash2, err := auth.HashPassword(strongPassword) + if err != nil { + t.Fatalf("hash 2: %v", err) + } + if err := userStore.SetPasswordHash(ctx, u.ID, hash2); err != nil { + t.Fatalf("set password hash 2: %v", err) + } + _, mustChange, err = LoginAndCheckPolicy(ctx, store, loginService, email, strongPassword) + if err != nil { + t.Fatalf("login mit starkem passwort: %v", err) + } + if mustChange { + t.Fatal("erwartet mustChangePassword=false fuer konformes passwort") + } +} diff --git a/migrations/tenant/0003_password_policy.down.sql b/migrations/tenant/0003_password_policy.down.sql new file mode 100644 index 0000000..8b6bde6 --- /dev/null +++ b/migrations/tenant/0003_password_policy.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS password_policy; diff --git a/migrations/tenant/0003_password_policy.up.sql b/migrations/tenant/0003_password_policy.up.sql new file mode 100644 index 0000000..3f194d5 --- /dev/null +++ b/migrations/tenant/0003_password_policy.up.sql @@ -0,0 +1,11 @@ +-- Passwort-Richtlinien je Tenant (IAM-14, siehe core-kanban/tickets/IAM-14.md). +-- Genau eine Zeile pro Tenant-Datenbank (Singleton-Muster, id fest auf true). +CREATE TABLE password_policy ( + id BOOLEAN PRIMARY KEY DEFAULT true CHECK (id), + min_length INT NOT NULL DEFAULT 12, + require_upper BOOLEAN NOT NULL DEFAULT true, + require_lower BOOLEAN NOT NULL DEFAULT true, + require_digit BOOLEAN NOT NULL DEFAULT true, + require_special BOOLEAN NOT NULL DEFAULT false, + updated_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