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:
@@ -171,6 +171,11 @@ begin
|
||||
LBody.Free;
|
||||
end;
|
||||
|
||||
// Per-username lockout check — runs BEFORE touching the users table, so
|
||||
// attackers can't probe account existence via timing differences between
|
||||
// "locked" and "not found" responses.
|
||||
if RejectIfAccountLocked(AResponse, LUser) then Exit;
|
||||
|
||||
DB.Lock;
|
||||
try
|
||||
LQ := TFDQuery.Create(nil);
|
||||
@@ -182,7 +187,13 @@ begin
|
||||
LQ.Open;
|
||||
if LQ.IsEmpty then
|
||||
begin
|
||||
// Unknown username — still record the failure against this username
|
||||
// so attackers can't enumerate accounts by observing which usernames
|
||||
// can be locked vs not. TCriticalSection is reentrant for the same
|
||||
// thread, so calling RecordAttempt/RecordFailedAccountAttempt from
|
||||
// inside our DB.Lock block is safe (they re-acquire the same lock).
|
||||
RecordAttempt(LIP);
|
||||
RecordFailedAccountAttempt(LUser, LIP);
|
||||
TJSONHelper.SendError(AResponse, 401, 'Invalid credentials');
|
||||
Exit;
|
||||
end;
|
||||
@@ -208,6 +219,7 @@ begin
|
||||
begin
|
||||
// Not implemented in Delphi backend yet
|
||||
RecordAttempt(LIP);
|
||||
RecordFailedAccountAttempt(LUser, LIP);
|
||||
LogAudit(LUserId, 'failed_login_bcrypt', LIP);
|
||||
TJSONHelper.SendError(AResponse, 501,
|
||||
'This account was created with bcrypt (PHP). The Delphi backend does ' +
|
||||
@@ -218,12 +230,14 @@ begin
|
||||
if not LValid then
|
||||
begin
|
||||
RecordAttempt(LIP);
|
||||
RecordFailedAccountAttempt(LUser, LIP);
|
||||
LogAudit(LUserId, 'failed_login', LIP);
|
||||
TJSONHelper.SendError(AResponse, 401, 'Invalid credentials');
|
||||
Exit;
|
||||
end;
|
||||
|
||||
ClearAttempts(LIP);
|
||||
ClearAccountLockout(LUser);
|
||||
DeleteAllUserSessions(LUserId);
|
||||
EnsureDefaultFolders(LUserId);
|
||||
CreateSession(LUserId, LToken, LCSRF);
|
||||
@@ -264,7 +278,7 @@ procedure HandleReauth(ARequest: TIdHTTPRequestInfo;
|
||||
var
|
||||
LUserId: Integer;
|
||||
LBody: TJSONObject;
|
||||
LPwd, LStoredHash, LSalt, LAlgo, LIP, LComputed: string;
|
||||
LUser, LPwd, LStoredHash, LSalt, LAlgo, LIP, LComputed: string;
|
||||
LQ: TFDQuery;
|
||||
LValid: Boolean;
|
||||
begin
|
||||
@@ -294,7 +308,9 @@ begin
|
||||
LQ := TFDQuery.Create(nil);
|
||||
try
|
||||
LQ.Connection := DB.Connection;
|
||||
LQ.SQL.Text := 'SELECT password_hash, salt, hash_algo FROM users WHERE id = :uid';
|
||||
// Pull username too — needed for the per-account lockout calls.
|
||||
LQ.SQL.Text :=
|
||||
'SELECT username, password_hash, salt, hash_algo FROM users WHERE id = :uid';
|
||||
LQ.ParamByName('uid').AsInteger := LUserId;
|
||||
LQ.Open;
|
||||
if LQ.IsEmpty then
|
||||
@@ -303,6 +319,7 @@ begin
|
||||
TJSONHelper.SendError(AResponse, 401, 'User not found');
|
||||
Exit;
|
||||
end;
|
||||
LUser := LQ.FieldByName('username').AsString;
|
||||
LStoredHash := LQ.FieldByName('password_hash').AsString;
|
||||
LSalt := LQ.FieldByName('salt').AsString;
|
||||
LAlgo := LQ.FieldByName('hash_algo').AsString;
|
||||
@@ -314,6 +331,12 @@ begin
|
||||
DB.Unlock;
|
||||
end;
|
||||
|
||||
// Check account lockout AFTER we have the username. Even though the user
|
||||
// is already authenticated by their session token, the master-pw re-prompt
|
||||
// is itself brute-forceable (e.g. attacker hijacked a session and now tries
|
||||
// to escalate by guessing the master pw to unlock the JS crypto key).
|
||||
if RejectIfAccountLocked(AResponse, LUser) then Exit;
|
||||
|
||||
LValid := False;
|
||||
if SameText(LAlgo, 'pbkdf2') then
|
||||
begin
|
||||
@@ -324,12 +347,14 @@ begin
|
||||
if not LValid then
|
||||
begin
|
||||
RecordAttempt(LIP);
|
||||
RecordFailedAccountAttempt(LUser, LIP);
|
||||
LogAudit(LUserId, 'failed_reauth', LIP);
|
||||
TJSONHelper.SendError(AResponse, 401, 'Invalid password');
|
||||
Exit;
|
||||
end;
|
||||
|
||||
ClearAttempts(LIP);
|
||||
ClearAccountLockout(LUser);
|
||||
LogAudit(LUserId, 'reauth', LIP);
|
||||
TJSONHelper.SendOK(AResponse, 'OK');
|
||||
end;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -1,21 +1,58 @@
|
||||
unit PM.RateLimit;
|
||||
|
||||
{
|
||||
Mirrors api.php checkRateLimit / recordAttempt / clearAttempts.
|
||||
15-minute window. Caller decides the threshold (5 for register, 10 for login).
|
||||
Two-layer brute-force protection:
|
||||
|
||||
1) Per-IP rate limit (login_attempts table, 15-minute window).
|
||||
Useful for non-loopback deployments and as a defense-in-depth layer.
|
||||
|
||||
2) Per-username lockout (account_lockouts table, exponential backoff).
|
||||
The real defense for a loopback-only setup where everyone shares
|
||||
127.0.0.1. After 4 consecutive failed attempts for a given username,
|
||||
the account is locked for an increasing duration (1m, 5m, 15m, 1h,
|
||||
6h, 24h-capped). Counter resets on successful authentication.
|
||||
|
||||
IMPORTANT: failed attempts are recorded even when the username doesn't
|
||||
exist in the users table. This prevents attackers from enumerating
|
||||
existing accounts by observing which usernames trigger a lockout.
|
||||
Trade-off: an attacker can DoS-lock arbitrary usernames, but they can
|
||||
never bypass authentication this way.
|
||||
}
|
||||
|
||||
interface
|
||||
|
||||
uses
|
||||
System.SysUtils, FireDAC.Comp.Client, FireDAC.Stan.Param, IdCustomHTTPServer,
|
||||
System.SysUtils, System.JSON,
|
||||
FireDAC.Comp.Client, FireDAC.Stan.Param, IdCustomHTTPServer,
|
||||
PM.Database;
|
||||
|
||||
function GetClientIP(ARequest: TIdHTTPRequestInfo): string;
|
||||
|
||||
// ---- Per-IP rate limit (legacy) ----
|
||||
function CheckRateLimit(const AIP: string): Integer;
|
||||
procedure RecordAttempt(const AIP: string);
|
||||
procedure ClearAttempts(const AIP: string);
|
||||
|
||||
// ---- Per-username account lockout ----
|
||||
|
||||
// Seconds remaining until the account is unlocked. 0 means not locked.
|
||||
function GetAccountLockoutRemaining(const AUsername: string): Integer;
|
||||
|
||||
// Records a failed authentication attempt against AUsername (whether or not
|
||||
// the user exists), applies exponential backoff, and returns the new
|
||||
// failed_count. Caller should pass this back to the audit log.
|
||||
function RecordFailedAccountAttempt(const AUsername, AIP: string): Integer;
|
||||
|
||||
// Resets the failed counter and clears any lockout for AUsername. Call on
|
||||
// successful login / reauth.
|
||||
procedure ClearAccountLockout(const AUsername: string);
|
||||
|
||||
// One-stop helper for handlers: if the account is currently locked, sends
|
||||
// a 429 with a JSON body { error, retry_after } and returns True. Caller
|
||||
// should Exit immediately. Returns False if not locked (caller proceeds).
|
||||
function RejectIfAccountLocked(AResponse: TIdHTTPResponseInfo;
|
||||
const AUsername: string): Boolean;
|
||||
|
||||
implementation
|
||||
|
||||
function GetClientIP(ARequest: TIdHTTPRequestInfo): string;
|
||||
@@ -91,4 +128,180 @@ begin
|
||||
end;
|
||||
end;
|
||||
|
||||
// =============================================================================
|
||||
// Per-username lockout with exponential backoff
|
||||
// =============================================================================
|
||||
|
||||
// Exponential backoff schedule. Index = failed_count value AFTER this attempt.
|
||||
// First 3 failures give no lockout — a real user typing their master pw
|
||||
// wrong once or twice shouldn't be punished. From the 4th failure on, the
|
||||
// duration ramps up sharply. Capped at 24 h regardless of further failures.
|
||||
function ComputeBackoffSeconds(AFailedCount: Integer): Integer;
|
||||
begin
|
||||
case AFailedCount of
|
||||
0..3: Result := 0; // grace window
|
||||
4: Result := 60; // 1 minute
|
||||
5: Result := 300; // 5 minutes
|
||||
6: Result := 900; // 15 minutes
|
||||
7: Result := 3600; // 1 hour
|
||||
8: Result := 21600; // 6 hours
|
||||
else
|
||||
Result := 86400; // 24 hours — capped, regardless of count
|
||||
end;
|
||||
end;
|
||||
|
||||
function GetAccountLockoutRemaining(const AUsername: string): Integer;
|
||||
var
|
||||
LQ: TFDQuery;
|
||||
begin
|
||||
Result := 0;
|
||||
if AUsername = '' then Exit;
|
||||
|
||||
DB.Lock;
|
||||
try
|
||||
LQ := TFDQuery.Create(nil);
|
||||
try
|
||||
LQ.Connection := DB.Connection;
|
||||
// CAST(strftime(...) - strftime(...) AS INTEGER) gives seconds remaining.
|
||||
// If locked_until is NULL or in the past, the SELECT returns 0.
|
||||
LQ.SQL.Text :=
|
||||
'SELECT MAX(0, CAST(' +
|
||||
' (strftime(''%s'', locked_until) - strftime(''%s'', ''now''))' +
|
||||
' AS INTEGER)) AS remaining ' +
|
||||
'FROM account_lockouts WHERE username = :u';
|
||||
LQ.ParamByName('u').AsString := AUsername;
|
||||
LQ.Open;
|
||||
if not LQ.IsEmpty then
|
||||
Result := LQ.FieldByName('remaining').AsInteger;
|
||||
finally
|
||||
LQ.Free;
|
||||
end;
|
||||
finally
|
||||
DB.Unlock;
|
||||
end;
|
||||
end;
|
||||
|
||||
function RecordFailedAccountAttempt(const AUsername, AIP: string): Integer;
|
||||
var
|
||||
LQ: TFDQuery;
|
||||
LNewCount, LBackoff: Integer;
|
||||
begin
|
||||
Result := 0;
|
||||
if AUsername = '' then Exit;
|
||||
|
||||
DB.Lock;
|
||||
try
|
||||
// Step 1: upsert + increment in a single statement. SQLite's
|
||||
// ON CONFLICT(...) DO UPDATE handles the "row already exists" case
|
||||
// atomically without a separate SELECT/UPDATE race.
|
||||
LQ := TFDQuery.Create(nil);
|
||||
try
|
||||
LQ.Connection := DB.Connection;
|
||||
LQ.SQL.Text :=
|
||||
'INSERT INTO account_lockouts ' +
|
||||
' (username, failed_count, last_attempt_at, last_attempt_ip) ' +
|
||||
'VALUES (:u, 1, CURRENT_TIMESTAMP, :ip) ' +
|
||||
'ON CONFLICT(username) DO UPDATE SET ' +
|
||||
' failed_count = failed_count + 1, ' +
|
||||
' last_attempt_at = CURRENT_TIMESTAMP, ' +
|
||||
' last_attempt_ip = excluded.last_attempt_ip';
|
||||
LQ.ParamByName('u').AsString := AUsername;
|
||||
LQ.ParamByName('ip').AsString := AIP;
|
||||
LQ.ExecSQL;
|
||||
finally
|
||||
LQ.Free;
|
||||
end;
|
||||
|
||||
// Step 2: read the new failed_count and apply backoff if the threshold
|
||||
// is crossed. Done in two queries because SQLite's RETURNING clause
|
||||
// requires 3.35+ and we want to support older versions.
|
||||
LQ := TFDQuery.Create(nil);
|
||||
try
|
||||
LQ.Connection := DB.Connection;
|
||||
LQ.SQL.Text :=
|
||||
'SELECT failed_count FROM account_lockouts WHERE username = :u';
|
||||
LQ.ParamByName('u').AsString := AUsername;
|
||||
LQ.Open;
|
||||
if LQ.IsEmpty then Exit;
|
||||
LNewCount := LQ.FieldByName('failed_count').AsInteger;
|
||||
finally
|
||||
LQ.Free;
|
||||
end;
|
||||
Result := LNewCount;
|
||||
|
||||
LBackoff := ComputeBackoffSeconds(LNewCount);
|
||||
if LBackoff > 0 then
|
||||
begin
|
||||
LQ := TFDQuery.Create(nil);
|
||||
try
|
||||
LQ.Connection := DB.Connection;
|
||||
LQ.SQL.Text :=
|
||||
'UPDATE account_lockouts SET ' +
|
||||
' locked_until = datetime(''now'', ''+'' || :sec || '' seconds'') ' +
|
||||
'WHERE username = :u';
|
||||
LQ.ParamByName('sec').AsInteger := LBackoff;
|
||||
LQ.ParamByName('u').AsString := AUsername;
|
||||
LQ.ExecSQL;
|
||||
finally
|
||||
LQ.Free;
|
||||
end;
|
||||
end;
|
||||
finally
|
||||
DB.Unlock;
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure ClearAccountLockout(const AUsername: string);
|
||||
var
|
||||
LQ: TFDQuery;
|
||||
begin
|
||||
if AUsername = '' then Exit;
|
||||
|
||||
DB.Lock;
|
||||
try
|
||||
LQ := TFDQuery.Create(nil);
|
||||
try
|
||||
LQ.Connection := DB.Connection;
|
||||
// Hard delete on success: keeps the table small, and there's no
|
||||
// value in remembering past failures once the user has proven they
|
||||
// know the password.
|
||||
LQ.SQL.Text := 'DELETE FROM account_lockouts WHERE username = :u';
|
||||
LQ.ParamByName('u').AsString := AUsername;
|
||||
LQ.ExecSQL;
|
||||
finally
|
||||
LQ.Free;
|
||||
end;
|
||||
finally
|
||||
DB.Unlock;
|
||||
end;
|
||||
end;
|
||||
|
||||
function RejectIfAccountLocked(AResponse: TIdHTTPResponseInfo;
|
||||
const AUsername: string): Boolean;
|
||||
var
|
||||
LRemaining: Integer;
|
||||
LBody: TJSONObject;
|
||||
begin
|
||||
LRemaining := GetAccountLockoutRemaining(AUsername);
|
||||
Result := LRemaining > 0;
|
||||
if not Result then Exit;
|
||||
|
||||
// 429 Too Many Requests with a structured body so the client can show
|
||||
// a countdown timer ("Try again in X seconds") instead of a generic
|
||||
// error toast. Standard Retry-After header included as a fallback for
|
||||
// HTTP-aware tooling.
|
||||
AResponse.ResponseNo := 429;
|
||||
AResponse.ContentType := 'application/json; charset=utf-8';
|
||||
AResponse.CustomHeaders.Values['Retry-After'] := IntToStr(LRemaining);
|
||||
|
||||
LBody := TJSONObject.Create;
|
||||
try
|
||||
LBody.AddPair('error', 'Account temporarily locked due to repeated failed attempts');
|
||||
LBody.AddPair('retry_after', TJSONNumber.Create(LRemaining));
|
||||
AResponse.ContentText := LBody.ToJSON;
|
||||
finally
|
||||
LBody.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
end.
|
||||
|
||||
@@ -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();
|
||||
|
||||
// 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').value = '';
|
||||
$('#loginPassword').focus();
|
||||
} else {
|
||||
$('#loginUsername').value = '';
|
||||
$('#loginUsername').readOnly = false;
|
||||
$('#authHint').textContent = '';
|
||||
$('#loginUsername').focus();
|
||||
}
|
||||
$('#loginPassword').value = '';
|
||||
}
|
||||
|
||||
// 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