feat(auth): add login-attempts DB layer and lockout service
All checks were successful
Deploy to Homelab / deploy (push) Successful in 18s

Persists per-username failed-attempt counts and computed locked_until
timestamps. Lockout service computes exponential-backoff durations
(min(base * 2^(count-threshold), max)) with auto-unlock once locked_until
passes. Successful login deletes the row, resetting the counter.

Pure DB-keyed lockout — survives server restarts and shares state across
both login routes (HTML and JSON) when wired in a later step.
This commit is contained in:
2026-05-03 03:29:09 -07:00
parent f4eaf88495
commit 11e87f353d
4 changed files with 315 additions and 0 deletions

38
src/db/login-attempts.ts Normal file
View File

@@ -0,0 +1,38 @@
import type Database from 'better-sqlite3';
export interface LoginAttemptRow {
username: string;
failed_count: number;
last_failed_at: string | null;
locked_until: string | null;
}
export function getLoginAttempt(
db: Database.Database,
username: string,
): LoginAttemptRow | undefined {
const stmt = db.prepare('SELECT * FROM login_attempts WHERE username = ?');
return stmt.get(username) as LoginAttemptRow | undefined;
}
export function recordFailure(
db: Database.Database,
username: string,
lockedUntilIso: string | null,
): LoginAttemptRow {
const stmt = db.prepare(`
INSERT INTO login_attempts (username, failed_count, last_failed_at, locked_until)
VALUES (?, 1, datetime('now'), ?)
ON CONFLICT(username) DO UPDATE SET
failed_count = failed_count + 1,
last_failed_at = datetime('now'),
locked_until = excluded.locked_until
RETURNING *
`);
return stmt.get(username, lockedUntilIso) as LoginAttemptRow;
}
export function resetLoginAttempts(db: Database.Database, username: string): void {
const stmt = db.prepare('DELETE FROM login_attempts WHERE username = ?');
stmt.run(username);
}

78
src/services/lockout.ts Normal file
View File

@@ -0,0 +1,78 @@
import type Database from 'better-sqlite3';
import type { Config } from '../config.ts';
import {
getLoginAttempt,
recordFailure as dbRecordFailure,
resetLoginAttempts,
} from '../db/login-attempts.ts';
export interface LockoutCheckResult {
locked: boolean;
retryAfterSeconds?: number;
}
export interface LockoutFailureResult {
locked: boolean;
durationSeconds: number;
failedCount: number;
}
export interface LockoutService {
check(username: string): LockoutCheckResult;
recordFailure(username: string): LockoutFailureResult;
recordSuccess(username: string): void;
}
interface LockoutDeps {
db: Database.Database;
config: Config;
now?: () => Date;
}
function computeDurationSeconds(failedCount: number, config: Config): number {
const { lockoutThreshold, lockoutBaseSeconds, lockoutMaxSeconds } = config;
if (failedCount < lockoutThreshold) return 0;
const exponent = failedCount - lockoutThreshold;
const raw = lockoutBaseSeconds * 2 ** exponent;
return Math.min(lockoutMaxSeconds, raw);
}
export function createLockoutService(deps: LockoutDeps): LockoutService {
const { db, config } = deps;
const now = deps.now ?? ((): Date => new Date());
return {
check(username: string): LockoutCheckResult {
const row = getLoginAttempt(db, username);
if (!row?.locked_until) return { locked: false };
const lockedUntilMs = Date.parse(row.locked_until);
const remainingMs = lockedUntilMs - now().getTime();
if (remainingMs <= 0) return { locked: false };
return { locked: true, retryAfterSeconds: Math.ceil(remainingMs / 1000) };
},
recordFailure(username: string): LockoutFailureResult {
const existing = getLoginAttempt(db, username);
const nextCount = (existing?.failed_count ?? 0) + 1;
const durationSeconds = computeDurationSeconds(nextCount, config);
const lockedUntilIso =
durationSeconds > 0
? new Date(now().getTime() + durationSeconds * 1000).toISOString()
: null;
dbRecordFailure(db, username, lockedUntilIso);
return {
locked: durationSeconds > 0,
durationSeconds,
failedCount: nextCount,
};
},
recordSuccess(username: string): void {
resetLoginAttempts(db, username);
},
};
}