Skip to content

Brute-force lockout (Phase 12)

What

Per-email failed-login throttle on POST /auditforge/auth/login. Five failed attempts within a 15-minute window lock the email address for 15 minutes. Locked accounts get 423 Locked with the unlock timestamp.

Bounded password-guessing without making the auth endpoint slower in normal use.

Threshold tuning

Knob Value Rationale
LOCKOUT_FAILURE_THRESHOLD 5 Allows fat-finger typos on a partner's password without immediate lockout; blocks credential-stuffing within seconds.
LOCKOUT_ATTEMPT_WINDOW_MINUTES 15 Long enough that 4 failures spread across a workday don't accumulate; short enough that an active attack hits the threshold quickly.
LOCKOUT_DURATION_MINUTES 15 Recovery without admin intervention for fat-finger lockouts; long enough to make brute-force economically unattractive.

A genuine attacker brute-forcing at one guess per second hits the lockout in 5 seconds, then waits 15 minutes for another 5-attempt window. Sustained guessing rate: 0.0056 guesses/second. At that rate, an 8-character random alphanumeric password (~62^8 = 2.18×10^14 possibilities) takes ~1.2 billion years to exhaust the keyspace.

Where the gate fires

The lockout check is the first thing the login handler does — before password hashing, before user lookup. This means:

  • A locked account doesn't burn argon2 CPU on each guess (denial-of-service mitigation)
  • Email enumeration via timing is still hard: locked emails get 423 immediately, valid-but-wrong-password emails get 401 after argon2; the timing difference is meaningful but can be normalized by adding a sleep in 423 (currently not done — open follow-up)

Where the gate clears

Event Effect
Successful full login (password + MFA when applicable) Counter cleared for that email
MFA code required but not provided Not counted as a failure (caller has password, just needs second factor)
Lock window naturally expires Counter resets to zero on next attempt

Persistence

Failed-attempt counters live at s3://{bucket}/auditforge/login_attempts.json with the same lazy-load + best-effort upload pattern as EngagementStore / SessionStore. A task restart keeps lockouts intact.

In-memory cache is authoritative during a task lifetime; S3 writes happen on each mutation. If S3 upload fails, local file is still written and the lockout still works for the duration of the task.

API behavior

POST /auditforge/auth/login
{ "email": "partner@firm.com", "password": "wrong" }

After 5 failures:

423 Locked
{
  "detail": {
    "locked": true,
    "locked_until": "2026-05-09T01:30:00Z",
    "message": "Too many failed attempts. Account locked for 15 minutes."
  }
}

The frontend can parse detail.locked_until to display a "try again in N minutes" message instead of a generic error.

Threat model

Attack Mitigation
Online password brute-force, single account Hard-capped at 5/15min — economically unattractive
Credential stuffing (breach reuse, known username) Same as above; the user list isn't enumerable, so attacker needs valid emails first
Lockout-as-DoS (attacker locks legitimate user out) Open follow-up — IP-based throttling alongside email-based; today the partner has to wait or the firm admin can clear the lockout (recovery endpoint TBD)
Distributed brute-force across IPs Email-based bound still holds; without IP throttling, attacker can target many users simultaneously but each user is still bounded

Open follow-ups

  • Admin-recovery endpoint for clearing a specific email's lockout (locked-out partner contacts admin; admin clears via POST /admin/login-lockout/{email}/clear)
  • IP-based throttling alongside email-based, to mitigate distributed credential stuffing
  • Constant-time response padding so 423 vs 401 timing isn't observable
  • Audit log entry on each lockout (today: only logged at app-level WARNING; not in the engagement audit log because login isn't engagement-scoped)
  • Email notification to the user when their account is locked (deferred — needs Resend wiring; SES sandbox blocks it today)

Code

  • app/auditforge/users.pyLoginAttempt dataclass + LoginAttemptStore class with status() / register_failure() / register_success() methods; constants for threshold/window/duration; S3-backed lazy-load
  • app/auditforge_endpoints.pyauth_login calls lockout.status() first (raises 423 if locked), lockout.register_failure() on bad password / bad TOTP / bad backup code, lockout.register_success() after full login

Verification

End-to-end: 5 wrong-password POSTs → 6th attempt returns 423; correct password before lockout clears the counter; timer-based unlock works after 15 minutes.