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:
@@ -158,10 +158,50 @@ async function api(path, opts) {
|
||||
const r = await fetch(API + path, opts);
|
||||
let body = null;
|
||||
try { body = await r.json(); } catch (e) { body = {}; }
|
||||
if (!r.ok) throw new Error(body.error || ('HTTP ' + r.status));
|
||||
if (!r.ok) {
|
||||
// Preserve status + body on the Error so callers can distinguish
|
||||
// 429-with-retry_after (account lockout) from a generic auth error.
|
||||
const err = new Error(body.error || ('HTTP ' + r.status));
|
||||
err.status = r.status;
|
||||
err.body = body || {};
|
||||
throw err;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ACCOUNT LOCKOUT UI
|
||||
// ============================================================
|
||||
|
||||
let lockoutTimer = null;
|
||||
|
||||
// Called when the backend responds with 429 + retry_after on /login or
|
||||
// /reauth. Disables the auth form and displays a live countdown in
|
||||
// #authHint. When the countdown reaches 0, the form is re-enabled.
|
||||
function showLockoutCountdown(seconds) {
|
||||
if (lockoutTimer) { clearInterval(lockoutTimer); lockoutTimer = null; }
|
||||
const hint = $('#authHint');
|
||||
const btn = $('#loginBtn');
|
||||
const fmt = (s) => {
|
||||
if (s >= 3600) return Math.ceil(s / 3600) + ' h';
|
||||
if (s >= 60) return Math.ceil(s / 60) + ' min';
|
||||
return s + ' s';
|
||||
};
|
||||
const tick = () => {
|
||||
if (seconds <= 0) {
|
||||
clearInterval(lockoutTimer); lockoutTimer = null;
|
||||
if (hint) hint.textContent = 'You can try again now.';
|
||||
if (btn) btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
if (hint) hint.textContent = 'Account locked — try again in ' + fmt(seconds);
|
||||
seconds--;
|
||||
};
|
||||
if (btn) btn.disabled = true;
|
||||
tick(); // show first frame immediately
|
||||
lockoutTimer = setInterval(tick, 1000);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// TOAST
|
||||
// ============================================================
|
||||
@@ -242,9 +282,18 @@ async function doLogin(e) {
|
||||
toast('Welcome back, ' + u);
|
||||
await enterApp();
|
||||
} catch (err) {
|
||||
// 429 with retry_after = account lockout. Show countdown in the
|
||||
// auth hint instead of a generic error toast, and keep the login
|
||||
// button disabled until the lockout expires.
|
||||
if (err.status === 429 && err.body && err.body.retry_after) {
|
||||
showLockoutCountdown(err.body.retry_after);
|
||||
return; // do NOT re-enable the button in finally
|
||||
}
|
||||
toast(err.message, 'error');
|
||||
} finally {
|
||||
$('#loginBtn').disabled = false;
|
||||
// Only re-enable when not in lockout (showLockoutCountdown manages
|
||||
// the button itself for the lockout case).
|
||||
if (!lockoutTimer) $('#loginBtn').disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,11 +350,25 @@ function lockVault() {
|
||||
state.trashed = [];
|
||||
state.locked = true;
|
||||
showAuth();
|
||||
$('#loginUsername').value = state.username;
|
||||
$('#loginUsername').readOnly = true;
|
||||
$('#authHint').textContent = 'Vault locked — enter master password to unlock';
|
||||
|
||||
// Two UI variants for the auth screen:
|
||||
// - We know the username (user was logged in before lock) →
|
||||
// pre-fill it as readonly so the user only types the master pw.
|
||||
// - We don't know the username (lock fired before any login — e.g.
|
||||
// tray "Lock vault" clicked on a fresh session, or Win+L right
|
||||
// after launch) → show a normal fresh login (editable username).
|
||||
if (state.username) {
|
||||
$('#loginUsername').value = state.username;
|
||||
$('#loginUsername').readOnly = true;
|
||||
$('#authHint').textContent = 'Vault locked — enter master password to unlock';
|
||||
$('#loginPassword').focus();
|
||||
} else {
|
||||
$('#loginUsername').value = '';
|
||||
$('#loginUsername').readOnly = false;
|
||||
$('#authHint').textContent = '';
|
||||
$('#loginUsername').focus();
|
||||
}
|
||||
$('#loginPassword').value = '';
|
||||
$('#loginPassword').focus();
|
||||
}
|
||||
|
||||
// Unlock flow: validate master pw via /reauth (which uses current session),
|
||||
@@ -326,6 +389,12 @@ async function doUnlock(p) {
|
||||
await enterApp();
|
||||
return true;
|
||||
} catch (err) {
|
||||
// Account lockout (too many wrong master pw attempts): show
|
||||
// countdown in the auth hint, keep the form disabled.
|
||||
if (err.status === 429 && err.body && err.body.retry_after) {
|
||||
showLockoutCountdown(err.body.retry_after);
|
||||
return false;
|
||||
}
|
||||
if (err.message === 'Invalid password') {
|
||||
toast('Wrong master password', 'error');
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user