From f4eaf88495fae578ae09cdf0c5136f283f53a760 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Sun, 3 May 2026 03:26:26 -0700 Subject: [PATCH] feat(auth): add login_attempts schema, lockout config, dummy-hash helper 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. --- package-lock.json | 22 ++++++++++++++++++++++ package.json | 1 + src/config.ts | 12 ++++++++++++ src/db/schema.ts | 10 ++++++++++ src/services/dummy-hash.ts | 25 +++++++++++++++++++++++++ tests/unit/config.test.ts | 32 ++++++++++++++++++++++++++++++++ 6 files changed, 102 insertions(+) create mode 100644 src/services/dummy-hash.ts diff --git a/package-lock.json b/package-lock.json index 58e38d7..a3dde2a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@fastify/formbody": "^8.0.2", "@fastify/jwt": "^10.0.0", "@fastify/multipart": "^9.4.0", + "@fastify/rate-limit": "^10.3.0", "@fastify/static": "^9.0.0", "bcrypt": "^6.0.0", "better-sqlite3": "^12.6.2", @@ -705,6 +706,27 @@ "ipaddr.js": "^2.1.0" } }, + "node_modules/@fastify/rate-limit": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-10.3.0.tgz", + "integrity": "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "fastify-plugin": "^5.0.0", + "toad-cache": "^3.7.0" + } + }, "node_modules/@fastify/send": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz", diff --git a/package.json b/package.json index 667d161..674b1fe 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@fastify/formbody": "^8.0.2", "@fastify/jwt": "^10.0.0", "@fastify/multipart": "^9.4.0", + "@fastify/rate-limit": "^10.3.0", "@fastify/static": "^9.0.0", "bcrypt": "^6.0.0", "better-sqlite3": "^12.6.2", diff --git a/src/config.ts b/src/config.ts index 2a7c051..63f93f1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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), }; } diff --git a/src/db/schema.ts b/src/db/schema.ts index 97f50c9..02b2b61 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -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; diff --git a/src/services/dummy-hash.ts b/src/services/dummy-hash.ts new file mode 100644 index 0000000..a4f7946 --- /dev/null +++ b/src/services/dummy-hash.ts @@ -0,0 +1,25 @@ +import bcrypt from 'bcrypt'; +import { hashPassword } from './auth.ts'; + +let cachedHash: Promise | null = null; + +function getDummyHash(): Promise { + 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 { + const hash = await getDummyHash(); + await bcrypt.compare(password, hash); + return false; +} + +export function _resetDummyHashForTests(): void { + cachedHash = null; +} diff --git a/tests/unit/config.test.ts b/tests/unit/config.test.ts index d7c0756..f0b80e9 100644 --- a/tests/unit/config.test.ts +++ b/tests/unit/config.test.ts @@ -23,6 +23,12 @@ describe('config', () => { delete process.env.BASE_URL; delete process.env.COOKIE_SECURE; delete process.env.TRUST_PROXY; + delete process.env.LOCKOUT_THRESHOLD; + delete process.env.LOCKOUT_BASE_SECONDS; + delete process.env.LOCKOUT_MAX_SECONDS; + delete process.env.LOGIN_MIN_RESPONSE_MS; + delete process.env.LOGIN_RATE_LIMIT_MAX; + delete process.env.LOGIN_RATE_LIMIT_WINDOW_SECONDS; const { loadConfig } = await import('../../src/config.ts'); const config = loadConfig(); @@ -37,6 +43,12 @@ describe('config', () => { expect(config.baseUrl).toBe('http://localhost:3000'); expect(config.cookieSecure).toBe(false); expect(config.trustProxy).toBe(false); + expect(config.lockoutThreshold).toBe(5); + expect(config.lockoutBaseSeconds).toBe(30); + expect(config.lockoutMaxSeconds).toBe(3600); + expect(config.loginMinResponseMs).toBe(350); + expect(config.loginRateLimitMax).toBe(10); + expect(config.loginRateLimitWindowSeconds).toBe(60); }); it('reads values from env vars', async () => { @@ -60,6 +72,26 @@ describe('config', () => { expect(config.maxFileSize).toBe(52428800); }); + it('reads lockout and rate-limit values from env vars', async () => { + process.env.JWT_SECRET = 'my-secret'; + process.env.LOCKOUT_THRESHOLD = '3'; + process.env.LOCKOUT_BASE_SECONDS = '15'; + process.env.LOCKOUT_MAX_SECONDS = '900'; + process.env.LOGIN_MIN_RESPONSE_MS = '50'; + process.env.LOGIN_RATE_LIMIT_MAX = '20'; + process.env.LOGIN_RATE_LIMIT_WINDOW_SECONDS = '120'; + + const { loadConfig } = await import('../../src/config.ts'); + const config = loadConfig(); + + expect(config.lockoutThreshold).toBe(3); + expect(config.lockoutBaseSeconds).toBe(15); + expect(config.lockoutMaxSeconds).toBe(900); + expect(config.loginMinResponseMs).toBe(50); + expect(config.loginRateLimitMax).toBe(20); + expect(config.loginRateLimitWindowSeconds).toBe(120); + }); + it('throws when JWT_SECRET is missing', async () => { delete process.env.JWT_SECRET; const { loadConfig } = await import('../../src/config.ts');