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¶
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.py—LoginAttemptdataclass +LoginAttemptStoreclass withstatus()/register_failure()/register_success()methods; constants for threshold/window/duration; S3-backed lazy-loadapp/auditforge_endpoints.py—auth_logincallslockout.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.
Related¶
- 21-per-user-auth.md — argon2 password hashing baseline
- 24-totp-mfa.md — TOTP layer; lockout protects the password gate that comes before MFA