// 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 }