Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac070c160a |
@@ -0,0 +1,37 @@
|
||||
package lockout
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
|
||||
)
|
||||
|
||||
var ErrAccountLocked = errors.New("lockout: konto ist vorübergehend gesperrt")
|
||||
|
||||
// GuardedLogin komponiert IAM-02s LoginService mit dem Lockout-Zustand,
|
||||
// OHNE LoginService selbst zu veraendern (kein Umbau der Vorbedingung):
|
||||
// vor jedem Login-Versuch wird die Sperre geprueft, nach jedem Versuch wird
|
||||
// Erfolg/Fehlschlag im geteilten Postgres-Zustand vermerkt.
|
||||
func GuardedLogin(ctx context.Context, store *Store, login *auth.LoginService, email, password string) (string, error) {
|
||||
locked, _, err := store.IsLocked(ctx, email)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if locked {
|
||||
return "", ErrAccountLocked
|
||||
}
|
||||
|
||||
token, err := login.Login(ctx, email, password)
|
||||
if err != nil {
|
||||
if _, _, lockErr := store.RecordFailure(ctx, email); lockErr != nil {
|
||||
return "", lockErr
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := store.RecordSuccess(ctx, email); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// Package lockout implementiert Core IAM-07: Account-Lockout nach
|
||||
// Fehlversuchen und Login-Rate-Limiting mit geteiltem, externem
|
||||
// (Postgres-basiertem) Zustand — kein In-Process-Zaehler, der bei
|
||||
// Mehrinstanzbetrieb aushebelbar waere (bekannter Fehler aus archivdms
|
||||
// internal/auth/ratelimit.go).
|
||||
package lockout
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// DefaultMaxFailedAttempts/DefaultLockoutDuration sind explizit benannte
|
||||
// Defaults, ueberschreibbar via WithPolicy.
|
||||
const (
|
||||
DefaultMaxFailedAttempts = 5
|
||||
DefaultLockoutDuration = 15 * time.Minute
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
maxFailed int
|
||||
lockoutDuration time.Duration
|
||||
}
|
||||
|
||||
func NewStore(pool *pgxpool.Pool) *Store {
|
||||
return &Store{pool: pool, maxFailed: DefaultMaxFailedAttempts, lockoutDuration: DefaultLockoutDuration}
|
||||
}
|
||||
|
||||
func (s *Store) WithPolicy(maxFailed int, lockoutDuration time.Duration) *Store {
|
||||
return &Store{pool: s.pool, maxFailed: maxFailed, lockoutDuration: lockoutDuration}
|
||||
}
|
||||
|
||||
// IsLocked prueft, ob ein Konto aktuell gesperrt ist. Eine abgelaufene
|
||||
// Sperre gilt automatisch als nicht mehr gesperrt (Akzeptanzkriterium 3) —
|
||||
// es ist keine explizite Entsperr-Aktion noetig, der Zeitvergleich reicht.
|
||||
func (s *Store) IsLocked(ctx context.Context, email string) (locked bool, lockedUntil time.Time, err error) {
|
||||
err = s.pool.QueryRow(ctx, `
|
||||
SELECT locked_until FROM login_attempts WHERE email = $1 AND locked_until IS NOT NULL
|
||||
`, email).Scan(&lockedUntil)
|
||||
if err != nil {
|
||||
return false, time.Time{}, nil // kein Datensatz oder kein Lock -> nicht gesperrt
|
||||
}
|
||||
return time.Now().Before(lockedUntil), lockedUntil, nil
|
||||
}
|
||||
|
||||
// RecordFailure erhoeht den Fehlversuchszaehler ATOMAR (UPSERT) und sperrt
|
||||
// das Konto, sobald die Schwelle erreicht ist (Akzeptanzkriterium 1 / 2).
|
||||
// Der Zustand liegt ausschliesslich in Postgres, mehrere Core-Instanzen
|
||||
// teilen sich denselben Zaehler (Akzeptanzkriterium 2).
|
||||
func (s *Store) RecordFailure(ctx context.Context, email string) (locked bool, lockedUntil time.Time, err error) {
|
||||
var failedCount int
|
||||
var lockedUntilPtr *time.Time
|
||||
|
||||
err = s.pool.QueryRow(ctx, `
|
||||
INSERT INTO login_attempts (email, failed_count, locked_until, last_attempt_at)
|
||||
VALUES ($1, 1, CASE WHEN 1 >= $2 THEN now() + $3::interval ELSE NULL END, now())
|
||||
ON CONFLICT (email) DO UPDATE SET
|
||||
failed_count = login_attempts.failed_count + 1,
|
||||
last_attempt_at = now(),
|
||||
locked_until = CASE
|
||||
WHEN login_attempts.failed_count + 1 >= $2 THEN now() + $3::interval
|
||||
ELSE login_attempts.locked_until
|
||||
END
|
||||
RETURNING failed_count, locked_until
|
||||
`, email, s.maxFailed, fmt.Sprintf("%f seconds", s.lockoutDuration.Seconds())).Scan(&failedCount, &lockedUntilPtr)
|
||||
if err != nil {
|
||||
return false, time.Time{}, fmt.Errorf("fehlversuch erfassen: %w", err)
|
||||
}
|
||||
|
||||
if lockedUntilPtr != nil {
|
||||
return time.Now().Before(*lockedUntilPtr), *lockedUntilPtr, nil
|
||||
}
|
||||
return false, time.Time{}, nil
|
||||
}
|
||||
|
||||
// RecordSuccess setzt den Fehlversuchszaehler nach erfolgreichem Login zurueck.
|
||||
func (s *Store) RecordSuccess(ctx context.Context, email string) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO login_attempts (email, failed_count, locked_until, last_attempt_at)
|
||||
VALUES ($1, 0, NULL, now())
|
||||
ON CONFLICT (email) DO UPDATE SET failed_count = 0, locked_until = NULL, last_attempt_at = now()
|
||||
`, email)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erfolgreichen login erfassen: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unlock entsperrt ein Konto durch Administratoreingriff, unabhaengig von
|
||||
// der Sperrzeit (Akzeptanzkriterium 3).
|
||||
func (s *Store) Unlock(ctx context.Context, email string) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE login_attempts SET failed_count = 0, locked_until = NULL WHERE email = $1
|
||||
`, email)
|
||||
if err != nil {
|
||||
return fmt.Errorf("konto entsperren: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package lockout
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
const schema = `
|
||||
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 login_attempts (
|
||||
email TEXT PRIMARY KEY, failed_count INT NOT NULL DEFAULT 0,
|
||||
locked_until TIMESTAMPTZ, last_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);`
|
||||
|
||||
func setupTest(t *testing.T) (*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, schema); err != nil {
|
||||
t.Fatalf("schema: %v", err)
|
||||
}
|
||||
cleanup := func() { pool.Close() }
|
||||
return pool, cleanup
|
||||
}
|
||||
|
||||
func uniqueEmail() string {
|
||||
return fmt.Sprintf("lockout-test-%d@example.com", time.Now().UnixNano())
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 1 + Pruefung 2: Sperre greift nach definierten
|
||||
// Fehlversuchen zuverlaessig.
|
||||
func TestRecordFailure_LocksAfterThreshold(t *testing.T) {
|
||||
pool, cleanup := setupTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
store := NewStore(pool).WithPolicy(3, time.Hour)
|
||||
email := uniqueEmail()
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
locked, _, err := store.RecordFailure(ctx, email)
|
||||
if err != nil {
|
||||
t.Fatalf("record failure %d: %v", i, err)
|
||||
}
|
||||
if locked {
|
||||
t.Fatalf("sollte nach %d fehlversuchen noch nicht gesperrt sein", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
locked, lockedUntil, err := store.RecordFailure(ctx, email)
|
||||
if err != nil {
|
||||
t.Fatalf("record failure 3: %v", err)
|
||||
}
|
||||
if !locked {
|
||||
t.Fatal("erwartet gesperrt nach 3 fehlversuchen (schwelle=3)")
|
||||
}
|
||||
if !lockedUntil.After(time.Now()) {
|
||||
t.Fatal("lockedUntil sollte in der zukunft liegen")
|
||||
}
|
||||
|
||||
isLocked, _, err := store.IsLocked(ctx, email)
|
||||
if err != nil {
|
||||
t.Fatalf("is locked: %v", err)
|
||||
}
|
||||
if !isLocked {
|
||||
t.Fatal("IsLocked sollte true liefern")
|
||||
}
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 2 + Pruefung 1: zwei Dienstinstanzen teilen sich
|
||||
// denselben Zaehler (Mehrinstanz-Test) — hier durch zwei unabhaengige
|
||||
// pgxpool.Pool-Verbindungen zur selben Datenbank simuliert.
|
||||
func TestRecordFailure_SharedAcrossInstances(t *testing.T) {
|
||||
_, cleanup := setupTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
email := uniqueEmail()
|
||||
|
||||
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
||||
instanceA, err := pgxpool.New(ctx, adminDSN)
|
||||
if err != nil {
|
||||
t.Fatalf("instance a: %v", err)
|
||||
}
|
||||
defer instanceA.Close()
|
||||
instanceB, err := pgxpool.New(ctx, adminDSN)
|
||||
if err != nil {
|
||||
t.Fatalf("instance b: %v", err)
|
||||
}
|
||||
defer instanceB.Close()
|
||||
|
||||
storeA := NewStore(instanceA).WithPolicy(4, time.Hour)
|
||||
storeB := NewStore(instanceB).WithPolicy(4, time.Hour)
|
||||
|
||||
// Zwei fehlversuche ueber "instanz A", zwei ueber "instanz B" — beide
|
||||
// muessen sich denselben Zaehler teilen und gemeinsam die Schwelle erreichen.
|
||||
if _, _, err := storeA.RecordFailure(ctx, email); err != nil {
|
||||
t.Fatalf("a1: %v", err)
|
||||
}
|
||||
if _, _, err := storeB.RecordFailure(ctx, email); err != nil {
|
||||
t.Fatalf("b1: %v", err)
|
||||
}
|
||||
if _, _, err := storeA.RecordFailure(ctx, email); err != nil {
|
||||
t.Fatalf("a2: %v", err)
|
||||
}
|
||||
locked, _, err := storeB.RecordFailure(ctx, email)
|
||||
if err != nil {
|
||||
t.Fatalf("b2: %v", err)
|
||||
}
|
||||
if !locked {
|
||||
t.Fatal("erwartet gesperrt nach insgesamt 4 fehlversuchen ueber beide instanzen hinweg")
|
||||
}
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 3 + Pruefung 3: automatische Entsperrung nach Ablauf
|
||||
// der Sperrzeit, Zeitversatz automatisiert getestet.
|
||||
func TestIsLocked_AutoUnlocksAfterExpiry(t *testing.T) {
|
||||
pool, cleanup := setupTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
store := NewStore(pool).WithPolicy(1, 50*time.Millisecond)
|
||||
email := uniqueEmail()
|
||||
|
||||
locked, _, err := store.RecordFailure(ctx, email)
|
||||
if err != nil {
|
||||
t.Fatalf("record failure: %v", err)
|
||||
}
|
||||
if !locked {
|
||||
t.Fatal("erwartet sofort gesperrt bei schwelle=1")
|
||||
}
|
||||
|
||||
isLocked, _, err := store.IsLocked(ctx, email)
|
||||
if err != nil {
|
||||
t.Fatalf("is locked (vor ablauf): %v", err)
|
||||
}
|
||||
if !isLocked {
|
||||
t.Fatal("erwartet gesperrt vor ablauf der sperrzeit")
|
||||
}
|
||||
|
||||
time.Sleep(70 * time.Millisecond)
|
||||
|
||||
isLocked, _, err = store.IsLocked(ctx, email)
|
||||
if err != nil {
|
||||
t.Fatalf("is locked (nach ablauf): %v", err)
|
||||
}
|
||||
if isLocked {
|
||||
t.Fatal("erwartet automatisch entsperrt nach ablauf der sperrzeit")
|
||||
}
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 3: Entsperrung durch Administratoreingriff.
|
||||
func TestUnlock_ClearsLockImmediately(t *testing.T) {
|
||||
pool, cleanup := setupTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
store := NewStore(pool).WithPolicy(1, time.Hour)
|
||||
email := uniqueEmail()
|
||||
|
||||
if _, _, err := store.RecordFailure(ctx, email); err != nil {
|
||||
t.Fatalf("record failure: %v", err)
|
||||
}
|
||||
locked, _, _ := store.IsLocked(ctx, email)
|
||||
if !locked {
|
||||
t.Fatal("erwartet gesperrt vor admin-eingriff")
|
||||
}
|
||||
|
||||
if err := store.Unlock(ctx, email); err != nil {
|
||||
t.Fatalf("unlock: %v", err)
|
||||
}
|
||||
locked, _, err := store.IsLocked(ctx, email)
|
||||
if err != nil {
|
||||
t.Fatalf("is locked nach unlock: %v", err)
|
||||
}
|
||||
if locked {
|
||||
t.Fatal("erwartet sofort entsperrt nach admin-eingriff, unabhaengig von der sperrzeit")
|
||||
}
|
||||
}
|
||||
|
||||
// Integrationstest: GuardedLogin komponiert IAM-02 LoginService mit dem
|
||||
// Lockout-Zustand, ohne LoginService selbst zu veraendern.
|
||||
func TestGuardedLogin_LocksAfterRepeatedFailures(t *testing.T) {
|
||||
pool, cleanup := setupTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
email := uniqueEmail()
|
||||
userStore := user.NewTenantUserStore(pool)
|
||||
u, err := userStore.Create(ctx, email, "Lockout 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)
|
||||
}
|
||||
|
||||
issuer := auth.NewTokenIssuer("test-secret-nur-fuer-tests")
|
||||
loginService := auth.NewLoginService(userStore, issuer, "acme")
|
||||
lockoutStore := NewStore(pool).WithPolicy(3, time.Hour)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, err := GuardedLogin(ctx, lockoutStore, loginService, email, "falsches-passwort"); !errors.Is(err, auth.ErrInvalidCredentials) {
|
||||
t.Fatalf("versuch %d: erwartet ErrInvalidCredentials, habe %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Selbst das RICHTIGE Passwort wird jetzt abgewiesen, weil das Konto gesperrt ist.
|
||||
if _, err := GuardedLogin(ctx, lockoutStore, loginService, email, "korrektes-passwort"); !errors.Is(err, ErrAccountLocked) {
|
||||
t.Fatalf("erwartet ErrAccountLocked trotz korrektem passwort, habe %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
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,
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS login_attempts;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Account-Lockout & Login-Rate-Limiting (IAM-07, siehe
|
||||
-- core-kanban/tickets/IAM-07.md). Geteilter, externer Zustand in Postgres —
|
||||
-- kein In-Process-Zaehler (bekannter Fehler aus archivdms
|
||||
-- internal/auth/ratelimit.go), gilt korrekt ueber mehrere Core-Instanzen.
|
||||
CREATE TABLE login_attempts (
|
||||
email TEXT PRIMARY KEY,
|
||||
failed_count INT NOT NULL DEFAULT 0,
|
||||
locked_until TIMESTAMPTZ,
|
||||
last_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -1 +0,0 @@
|
||||
DROP TABLE IF EXISTS password_policy;
|
||||
@@ -1,11 +0,0 @@
|
||||
-- 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()
|
||||
);
|
||||
Reference in New Issue
Block a user