// 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" "gitea.perlbach24.de/scripte/nexarch/internal/audit" ) // 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 audit *audit.Log tenantSlug string } 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, audit: s.audit, tenantSlug: s.tenantSlug} } // WithAudit liefert einen Store, der Fehlversuche sowie Sperrung/Entsperrung // zusaetzlich im zentralen, unveraenderlichen Audit-Log protokolliert // (AUD-06) — login_attempts liegt in der Tenant-Datenbank, audit_events in // der Registry-Datenbank, daher ein eigener, an die Registry gebundener // audit.Log UND der Tenant-Slug (fuer den Tenant-Bezug im Event) noetig. // Rein additiv, log == nil bleibt gueltig (z.B. bestehende Tests). func (s *Store) WithAudit(log *audit.Log, tenantSlug string) *Store { return &Store{pool: s.pool, maxFailed: s.maxFailed, lockoutDuration: s.lockoutDuration, audit: log, tenantSlug: tenantSlug} } // 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) } locked = lockedUntilPtr != nil && time.Now().Before(*lockedUntilPtr) if s.audit != nil { action := "auth.login_failed" if locked { action = "auth.account_locked" } _ = s.audit.Record(ctx, audit.Event{ TenantSlug: s.tenantSlug, Actor: email, Action: action, Target: email, Metadata: map[string]any{"failed_count": failedCount}, }) } if lockedUntilPtr != nil { return locked, *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) } if s.audit != nil { _ = s.audit.Record(ctx, audit.Event{ TenantSlug: s.tenantSlug, Actor: "admin", Action: "auth.account_unlocked", Target: email, }) } return nil }