diff --git a/delphi-backend/Handlers/PM.Handler.Auth.pas b/delphi-backend/Handlers/PM.Handler.Auth.pas index fe8b087..2c8ca4a 100644 --- a/delphi-backend/Handlers/PM.Handler.Auth.pas +++ b/delphi-backend/Handlers/PM.Handler.Auth.pas @@ -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; diff --git a/delphi-backend/Source/PM.Database.pas b/delphi-backend/Source/PM.Database.pas index 7d9b316..5390e2b 100644 --- a/delphi-backend/Source/PM.Database.pas +++ b/delphi-backend/Source/PM.Database.pas @@ -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; diff --git a/delphi-backend/Source/PM.RateLimit.pas b/delphi-backend/Source/PM.RateLimit.pas index 61f2e2d..0994fd8 100644 --- a/delphi-backend/Source/PM.RateLimit.pas +++ b/delphi-backend/Source/PM.RateLimit.pas @@ -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. diff --git a/js/app.js b/js/app.js index 9ff5da8..9802627 100644 --- a/js/app.js +++ b/js/app.js @@ -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 {