feat(auth): add login_attempts schema, lockout config, dummy-hash helper
All checks were successful
Deploy to Homelab / deploy (push) Successful in 29s

Lays the foundation for brute-force defense: per-username attempt tracking
table, configurable lockout/rate-limit thresholds, and a memoized dummy
bcrypt hash so unknown-user paths can be timed identically to wrong-password
paths in a later step.

Adds @fastify/rate-limit dependency for upcoming per-IP rate-limit on
login routes.
This commit is contained in:
2026-05-03 03:26:26 -07:00
parent d30f40ca71
commit f4eaf88495
6 changed files with 102 additions and 0 deletions

View File

@@ -10,6 +10,12 @@ export interface Config {
baseUrl: string;
cookieSecure: boolean;
trustProxy: boolean;
lockoutThreshold: number;
lockoutBaseSeconds: number;
lockoutMaxSeconds: number;
loginMinResponseMs: number;
loginRateLimitMax: number;
loginRateLimitWindowSeconds: number;
}
export function loadConfig(): Config {
@@ -30,5 +36,11 @@ export function loadConfig(): Config {
baseUrl: process.env.BASE_URL ?? 'http://localhost:3000',
cookieSecure: process.env.COOKIE_SECURE === 'true',
trustProxy: process.env.TRUST_PROXY === 'true',
lockoutThreshold: parseInt(process.env.LOCKOUT_THRESHOLD ?? '5', 10),
lockoutBaseSeconds: parseInt(process.env.LOCKOUT_BASE_SECONDS ?? '30', 10),
lockoutMaxSeconds: parseInt(process.env.LOCKOUT_MAX_SECONDS ?? '3600', 10),
loginMinResponseMs: parseInt(process.env.LOGIN_MIN_RESPONSE_MS ?? '350', 10),
loginRateLimitMax: parseInt(process.env.LOGIN_RATE_LIMIT_MAX ?? '10', 10),
loginRateLimitWindowSeconds: parseInt(process.env.LOGIN_RATE_LIMIT_WINDOW_SECONDS ?? '60', 10),
};
}

View File

@@ -22,6 +22,16 @@ export function initDb(dbPath: string): Database.Database {
stored_name TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS login_attempts (
username TEXT PRIMARY KEY,
failed_count INTEGER NOT NULL DEFAULT 0,
last_failed_at TEXT,
locked_until TEXT
);
CREATE INDEX IF NOT EXISTS idx_login_attempts_locked_until
ON login_attempts(locked_until);
`);
return db;

View File

@@ -0,0 +1,25 @@
import bcrypt from 'bcrypt';
import { hashPassword } from './auth.ts';
let cachedHash: Promise<string> | null = null;
function getDummyHash(): Promise<string> {
if (!cachedHash) {
// Hash a value no caller will ever submit (cryptographically random
// string generated once at module init). Cost factor matches real users
// because hashPassword uses the same SALT_ROUNDS.
const seed = `dummy:${Date.now()}:${Math.random()}:${process.pid}`;
cachedHash = hashPassword(seed);
}
return cachedHash;
}
export async function verifyAgainstDummy(password: string): Promise<boolean> {
const hash = await getDummyHash();
await bcrypt.compare(password, hash);
return false;
}
export function _resetDummyHashForTests(): void {
cachedHash = null;
}