feat(auth): add login-attempts DB layer and lockout service
All checks were successful
Deploy to Homelab / deploy (push) Successful in 18s
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:
78
src/services/lockout.ts
Normal file
78
src/services/lockout.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user