feat(auth): per-account brute-force lockout with exponential backoff
Existing protection was per-IP only (login_attempts table). On a loopback
deployment everyone hits 127.0.0.1, so the per-IP counter is mostly
ornamental — the real attacker is on the same machine. Adds a second
defense layer that tracks failures per username with an exponential
backoff schedule.
Schema:
account_lockouts (username TEXT PK, failed_count INT,
locked_until DATETIME, last_attempt_at, last_attempt_ip)
Backoff after threshold (4+ failures):
1, 2, 3 failures → no lockout (grace window for typos)
4th → 60 s
5th → 5 min
6th → 15 min
7th → 1 h
8th → 6 h
9th and beyond → 24 h (capped)
Counter resets to 0 on successful login or reauth. Old non-locked rows
older than 30 days are pruned by CleanupExpired alongside the existing
sessions / audit_log / login_attempts cleanups.
Wiring:
- HandleLogin / HandleReauth both check RejectIfAccountLocked() before
touching the users table. Lockout responses are 429 with JSON body
{ error, retry_after } and a Retry-After header.
- Failed attempts are recorded against the username even when the user
doesn't exist, preventing account enumeration via differential
"is this account locked?" probes.
- PBKDF2 hash comparison was already constant-time (ConstantTimeEquals);
no change there.
Client (js/app.js):
- api() now preserves response status + body on Error so callers can
distinguish 429-lockout from other errors.
- New showLockoutCountdown(seconds) renders a live "Account locked —
try again in Xm Ys" message in #authHint, disables #loginBtn until
the countdown reaches 0, then re-enables it.
- doLogin / doUnlock both branch on err.status === 429 + retry_after
to call showLockoutCountdown instead of a generic error toast.
Known limitation: an attacker can DoS-lock arbitrary usernames by
spamming /login with that name. This is intentional — the alternative
(per-(username,IP) tracking) would let attackers enumerate accounts.
DoS-lock is acceptable; auth bypass is not.
This commit is contained in:
@@ -132,6 +132,21 @@ begin
|
||||
' ip TEXT NOT NULL,' +
|
||||
' attempted_at DATETIME DEFAULT CURRENT_TIMESTAMP' +
|
||||
')');
|
||||
// Per-username lockout state, complementing the per-IP login_attempts
|
||||
// counter. On a loopback-only deployment the per-IP counter is mostly
|
||||
// useless (everyone hits 127.0.0.1), so the per-username counter is the
|
||||
// real defense against brute-force.
|
||||
// - failed_count: total failures since the last successful auth
|
||||
// - locked_until: timestamp the account becomes available again (NULL = not locked)
|
||||
// - last_attempt_at / _ip: forensic info for the audit log
|
||||
FConn.ExecSQL(
|
||||
'CREATE TABLE IF NOT EXISTS account_lockouts (' +
|
||||
' username TEXT PRIMARY KEY,' +
|
||||
' failed_count INTEGER NOT NULL DEFAULT 0,' +
|
||||
' locked_until DATETIME,' +
|
||||
' last_attempt_at DATETIME DEFAULT CURRENT_TIMESTAMP,' +
|
||||
' last_attempt_ip TEXT' +
|
||||
')');
|
||||
FConn.ExecSQL(
|
||||
'CREATE TABLE IF NOT EXISTS audit_log (' +
|
||||
' id INTEGER PRIMARY KEY AUTOINCREMENT,' +
|
||||
@@ -209,6 +224,13 @@ begin
|
||||
FConn.ExecSQL('DELETE FROM sessions WHERE expires_at < datetime(''now'')');
|
||||
FConn.ExecSQL('DELETE FROM login_attempts WHERE attempted_at < datetime(''now'', ''-15 minutes'')');
|
||||
FConn.ExecSQL('DELETE FROM audit_log WHERE created_at < datetime(''now'', ''-30 days'')');
|
||||
// Account lockout entries: prune rows that are no longer locked AND haven't
|
||||
// been touched in 30 days (the user clearly isn't being attacked anymore).
|
||||
// Active lockouts and recent attempts are preserved.
|
||||
FConn.ExecSQL(
|
||||
'DELETE FROM account_lockouts ' +
|
||||
'WHERE (locked_until IS NULL OR locked_until < datetime(''now'')) ' +
|
||||
'AND last_attempt_at < datetime(''now'', ''-30 days'')');
|
||||
FConn.ExecSQL('DELETE FROM passkey_challenges WHERE created_at < datetime(''now'', ''-10 minutes'')');
|
||||
end;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user