screentinker/server/lib/login-lockout.js
ScreenTinker 9130aa5f7d feat(auth): bound password login per account, not only per IP
The only throttle on POST /api/auth/login was the per-IP limiter in server.js. That
bounds one noisy source and nothing else: it does not bound a distributed attempt, and
it is only as accurate as a deployment's proxy configuration. Nothing counted failures
against the account actually being attacked, and nothing cleared such a count on success
because no such count existed.

lib/login-lockout.js mirrors lib/totp-lockout.js and lib/pair-lockout.js so there is one
lockout idiom here rather than three. 10 failed passwords lock an account for 15 minutes.

Keyed on user.id, never on the submitted email: the email is attacker-supplied and
unbounded, so keying on it would let anyone grow the Map without limit — the same class
of bug fixed elsewhere in this campaign. A user id only exists for a real account, so the
key space is bounded by the user table and needs no eviction sweep, exactly like
totp-lockout.

A locked account returns the SAME 401 and body as a wrong password. A distinct 429 would
tell an attacker "this account exists and is under attack", turning login into an
account-existence oracle; the test asserts the locked response is byte-identical to both
the wrong-password and unknown-account responses. The trade is that a locked-out
legitimate user sees the generic message, so the trip is recorded in activity_log
(auth:login_locked) for the operator instead.

The counter is cleared as soon as the password verifies — before the TOTP and
email-verification branches, which return early and never reach issueSession, so a reset
placed there would never fire for those accounts. SSO paths do not share this code and
are unaffected.

Frontend needs no change: login.js renders any non-ok body's `error` string verbatim, and
the body is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 14:07:16 -05:00

46 lines
2.1 KiB
JavaScript

'use strict';
// Per-ACCOUNT brute-force lockout for POST /api/auth/login. Same shape as
// lib/totp-lockout.js (#100) and lib/pair-lockout.js (#87) — deliberately, so there is one
// recognisable lockout idiom in this codebase rather than three.
//
// WHY per-account and not just per-IP: the existing login throttle in server.js is keyed on
// the client IP, which is the right control for a single noisy source but bounds nothing
// against a distributed one — and IP attribution is only as good as the deployment's proxy
// configuration. A counter tied to the account being attacked is independent of where the
// attempts come from.
//
// KEYED ON user.id, never on the submitted email. The email is attacker-supplied and
// unbounded, so keying on it would let anyone grow this Map without limit — the same
// mistake this campaign fixed elsewhere. A user id only exists for a real account, so the
// key space is bounded by the user table and needs no eviction sweep (matching
// totp-lockout, which is bounded the same way).
//
// In-memory; resets on restart. That is a deliberate trade, not an oversight: a restart
// forgiving an in-progress lockout is preferable to persisting one, and the per-IP limiter
// still applies across restarts.
const MAX_FAILS = 10; // failed passwords before the account is locked
const LOCKOUT_MS = 15 * 60 * 1000; // how long it is then locked
const failures = new Map(); // user.id -> { count, lockedUntil }
function isLocked(key, now = Date.now()) {
const rec = failures.get(key);
return !!(rec && rec.lockedUntil > now);
}
function recordFailure(key, now = Date.now()) {
const rec = failures.get(key) || { count: 0, lockedUntil: 0 };
rec.count += 1;
if (rec.count >= MAX_FAILS) { rec.lockedUntil = now + LOCKOUT_MS; rec.count = 0; }
failures.set(key, rec);
return rec;
}
// A correct password clears the key — including for accounts that then go on to a TOTP or
// email-verification step, since the password itself has been proven at that point.
function reset(key) { failures.delete(key); }
module.exports = { isLocked, recordFailure, reset, MAX_FAILS, LOCKOUT_MS };