Files
Zaki 40b3154a34 feat: MFA tools, single-instance, tray polish, prefs persistence
Session highlights:

- feat(prefs): DPAPI-backed key/value store (PM.UserPrefs) — fixes
  rememberedUsername being lost across reboots due to the random
  ephemeral HTTP port changing the localStorage origin every launch.
  Bridge cmd://prefs/{get,set} round-trips through Delphi.

- feat(tray): icon visible from startup (NIM_ADD at constructor, not
  at first minimize). Tray context menu themed via uxtheme!135
  SetPreferredAppMode so it follows the app's dark/light setting.

- feat(single-instance): named mutex + RegisterWindowMessage broadcast.
  Second launch posts WM_PMSHOW to HWND_BROADCAST and exits; the
  running bridge restores the window from tray. Mutex lives in Local\
  namespace so distinct Windows users can still each run one.

- feat(mfa): Authenticator sidebar view (live TOTP codes for every
  entry with a secret) + standalone TOTP generator modal (paste
  base32 / otpauth:// URI, or generate a random 20-byte secret).

- feat(sidebar): Folders / Tags / Tools sections collapsible with
  chevron toggle. Badge counts stay visible when collapsed. State
  persisted in settings_json (synced across devices).

- feat(autofill): hotkey when vault is locked now restores the app
  and focuses the master password input instead of no-op'ing
  silently. Cleaner UX for the common "I hit Ctrl+Shift+L but the
  vault was locked" path.

- feat(quick-unlock): when enabled, skip lockVault on Windows lock /
  sleep. Rationale: the DPAPI blob already gates access via the
  Windows account, so re-locking on top of the OS lock is redundant.
  Idle auto-lock still fires (separate opt-in).

- fix(quick-unlock): re-sync state.quickUnlockEnabled from DPAPI
  source-of-truth at boot, instead of trusting (now-volatile)
  localStorage.

- docs: CLAUDE.md updated with all new modules, bridge commands,
  and the port-ephemeral pitfall.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 21:31:39 +01:00

322 lines
9.8 KiB
ObjectPascal

unit PM.RateLimit;
{
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, System.JSON,
Data.DB,
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;
begin
// api.php trusts X-Forwarded-For (security flaw H1 in audit). Since this
// server is loopback-only and not behind a proxy, prefer the actual peer IP.
Result := ARequest.RemoteIP;
if Result = '' then Result := 'unknown';
end;
function CheckRateLimit(const AIP: string): Integer;
var
LQ: TFDQuery;
begin
DB.Lock;
try
LQ := TFDQuery.Create(nil);
try
LQ.Connection := DB.Connection;
LQ.SQL.Text :=
'SELECT COUNT(*) AS cnt FROM login_attempts ' +
'WHERE ip = :ip ' +
'AND attempted_at > datetime(''now'', ''-15 minutes'')';
LQ.ParamByName('ip').AsString := AIP;
LQ.Open;
Result := LQ.FieldByName('cnt').AsInteger;
finally
LQ.Free;
end;
finally
DB.Unlock;
end;
end;
procedure RecordAttempt(const AIP: string);
var
LQ: TFDQuery;
begin
DB.Lock;
try
LQ := TFDQuery.Create(nil);
try
LQ.Connection := DB.Connection;
LQ.SQL.Text := 'INSERT INTO login_attempts (ip) VALUES (:ip)';
LQ.ParamByName('ip').AsString := AIP;
LQ.ExecSQL;
finally
LQ.Free;
end;
finally
DB.Unlock;
end;
end;
procedure ClearAttempts(const AIP: string);
var
LQ: TFDQuery;
begin
DB.Lock;
try
LQ := TFDQuery.Create(nil);
try
LQ.Connection := DB.Connection;
LQ.SQL.Text := 'DELETE FROM login_attempts WHERE ip = :ip';
LQ.ParamByName('ip').AsString := AIP;
LQ.ExecSQL;
finally
LQ.Free;
end;
finally
DB.Unlock;
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;
// julianday() returns a real number of days since the Julian epoch
// — the standard SQLite idiom for date arithmetic. Multiplying by
// 86400 gives seconds. The WHERE clause filters out rows where the
// lockout is NULL or already expired, so an empty result set means
// "not locked" — no separate NULL handling needed.
LQ.SQL.Text :=
'SELECT CAST((julianday(locked_until) - julianday(''now'')) * 86400 ' +
' AS INTEGER) AS remaining ' +
'FROM account_lockouts ' +
'WHERE username = :u AND locked_until > datetime(''now'')';
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
// Try UPDATE first; if no row exists, INSERT a fresh one. This avoids
// SQLite UPSERT (ON CONFLICT DO UPDATE), which requires SQLite 3.24+
// and which FireDAC's parameter preprocessor mangles on some versions.
// Safe under our outer DB.Lock — no race between the UPDATE and INSERT.
LQ := TFDQuery.Create(nil);
try
LQ.Connection := DB.Connection;
LQ.SQL.Text :=
'UPDATE account_lockouts SET ' +
' failed_count = failed_count + 1, ' +
' last_attempt_at = CURRENT_TIMESTAMP, ' +
' last_attempt_ip = :ip ' +
'WHERE username = :u';
LQ.ParamByName('u').AsString := AUsername;
LQ.ParamByName('ip').AsString := AIP;
LQ.ExecSQL;
if LQ.RowsAffected = 0 then
begin
LQ.SQL.Text :=
'INSERT INTO account_lockouts ' +
' (username, failed_count, last_attempt_at, last_attempt_ip) ' +
'VALUES (:u, 1, CURRENT_TIMESTAMP, :ip)';
LQ.ParamByName('u').AsString := AUsername;
LQ.ParamByName('ip').AsString := AIP;
LQ.ExecSQL;
end;
finally
LQ.Free;
end;
// Read the new failed_count to decide if the backoff threshold is crossed.
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;
// Build the datetime modifier in Delphi (e.g. "+60 seconds") and
// pass it as a single string parameter — avoids SQL-side string
// concatenation which FireDAC's preprocessor can choke on.
LQ.SQL.Text :=
'UPDATE account_lockouts SET ' +
' locked_until = datetime(''now'', :modifier) ' +
'WHERE username = :u';
LQ.ParamByName('modifier').AsString := Format('+%d seconds', [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.