From 11e87f353d7090a6a095e4a14869c093edc4c698 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Sun, 3 May 2026 03:29:09 -0700 Subject: [PATCH] feat(auth): add login-attempts DB layer and lockout service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/db/login-attempts.ts | 38 ++++++++ src/services/lockout.ts | 78 ++++++++++++++++ tests/unit/lockout-service.test.ts | 127 +++++++++++++++++++++++++++ tests/unit/login-attempts-db.test.ts | 72 +++++++++++++++ 4 files changed, 315 insertions(+) create mode 100644 src/db/login-attempts.ts create mode 100644 src/services/lockout.ts create mode 100644 tests/unit/lockout-service.test.ts create mode 100644 tests/unit/login-attempts-db.test.ts diff --git a/src/db/login-attempts.ts b/src/db/login-attempts.ts new file mode 100644 index 0000000..7ab9681 --- /dev/null +++ b/src/db/login-attempts.ts @@ -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); +} diff --git a/src/services/lockout.ts b/src/services/lockout.ts new file mode 100644 index 0000000..4cae347 --- /dev/null +++ b/src/services/lockout.ts @@ -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); + }, + }; +} diff --git a/tests/unit/lockout-service.test.ts b/tests/unit/lockout-service.test.ts new file mode 100644 index 0000000..5d8cddb --- /dev/null +++ b/tests/unit/lockout-service.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import type Database from 'better-sqlite3'; +import { initDb } from '../../src/db/schema.ts'; +import type { Config } from '../../src/config.ts'; +import { createLockoutService } from '../../src/services/lockout.ts'; +import { recordFailure as dbRecordFailure } from '../../src/db/login-attempts.ts'; + +function makeConfig(overrides: Partial = {}): Config { + return { + port: 0, + host: '127.0.0.1', + jwtSecret: 'x', + jwtExpiry: '1h', + dbPath: ':memory:', + uploadDir: '/tmp', + logFile: '/tmp/x.log', + maxFileSize: 0, + baseUrl: '', + cookieSecure: false, + trustProxy: false, + lockoutThreshold: 3, + lockoutBaseSeconds: 10, + lockoutMaxSeconds: 80, + loginMinResponseMs: 0, + loginRateLimitMax: 0, + loginRateLimitWindowSeconds: 0, + ...overrides, + }; +} + +describe('lockout service', () => { + let db: Database.Database; + let nowMs: number; + const now = (): Date => new Date(nowMs); + + beforeEach(() => { + db = initDb(':memory:'); + nowMs = Date.UTC(2026, 0, 1, 0, 0, 0); + }); + + describe('check', () => { + it('returns not-locked when no row exists', () => { + const svc = createLockoutService({ db, config: makeConfig(), now }); + expect(svc.check('alice')).toEqual({ locked: false }); + }); + + it('returns not-locked when locked_until is in the past', () => { + const past = new Date(nowMs - 1000).toISOString(); + dbRecordFailure(db, 'alice', past); + const svc = createLockoutService({ db, config: makeConfig(), now }); + expect(svc.check('alice')).toEqual({ locked: false }); + }); + + it('returns locked with retry-after seconds when locked_until is in the future', () => { + const future = new Date(nowMs + 30_000).toISOString(); + dbRecordFailure(db, 'alice', future); + const svc = createLockoutService({ db, config: makeConfig(), now }); + expect(svc.check('alice')).toEqual({ locked: true, retryAfterSeconds: 30 }); + }); + + it('rounds up sub-second remainder so retry-after is never 0', () => { + const future = new Date(nowMs + 100).toISOString(); + dbRecordFailure(db, 'alice', future); + const svc = createLockoutService({ db, config: makeConfig(), now }); + expect(svc.check('alice').retryAfterSeconds).toBe(1); + }); + }); + + describe('recordFailure', () => { + it('does not lock under threshold', () => { + const svc = createLockoutService({ db, config: makeConfig(), now }); + const r1 = svc.recordFailure('alice'); + expect(r1).toEqual({ locked: false, durationSeconds: 0, failedCount: 1 }); + const r2 = svc.recordFailure('alice'); + expect(r2).toEqual({ locked: false, durationSeconds: 0, failedCount: 2 }); + }); + + it('locks with base duration at threshold', () => { + const svc = createLockoutService({ db, config: makeConfig(), now }); + svc.recordFailure('alice'); + svc.recordFailure('alice'); + const r3 = svc.recordFailure('alice'); + expect(r3.locked).toBe(true); + expect(r3.durationSeconds).toBe(10); + expect(r3.failedCount).toBe(3); + }); + + it('doubles duration past threshold', () => { + const svc = createLockoutService({ db, config: makeConfig(), now }); + svc.recordFailure('alice'); // 1 + svc.recordFailure('alice'); // 2 + expect(svc.recordFailure('alice').durationSeconds).toBe(10); // 3 -> base + expect(svc.recordFailure('alice').durationSeconds).toBe(20); // 4 + expect(svc.recordFailure('alice').durationSeconds).toBe(40); // 5 + expect(svc.recordFailure('alice').durationSeconds).toBe(80); // 6 -> cap + expect(svc.recordFailure('alice').durationSeconds).toBe(80); // 7 -> still cap + }); + + it('persists locked_until reachable via check', () => { + const svc = createLockoutService({ db, config: makeConfig(), now }); + svc.recordFailure('alice'); + svc.recordFailure('alice'); + svc.recordFailure('alice'); + const status = svc.check('alice'); + expect(status.locked).toBe(true); + expect(status.retryAfterSeconds).toBe(10); + }); + }); + + describe('recordSuccess', () => { + it('clears the attempt row', () => { + const svc = createLockoutService({ db, config: makeConfig(), now }); + svc.recordFailure('alice'); + svc.recordFailure('alice'); + svc.recordSuccess('alice'); + // Next failure starts at 1, no lock + const r = svc.recordFailure('alice'); + expect(r.failedCount).toBe(1); + expect(r.locked).toBe(false); + }); + + it('is a no-op for unknown username', () => { + const svc = createLockoutService({ db, config: makeConfig(), now }); + expect(() => svc.recordSuccess('ghost')).not.toThrow(); + }); + }); +}); diff --git a/tests/unit/login-attempts-db.test.ts b/tests/unit/login-attempts-db.test.ts new file mode 100644 index 0000000..fa300d1 --- /dev/null +++ b/tests/unit/login-attempts-db.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import type Database from 'better-sqlite3'; +import { initDb } from '../../src/db/schema.ts'; +import { + getLoginAttempt, + recordFailure, + resetLoginAttempts, +} from '../../src/db/login-attempts.ts'; + +describe('login-attempts db', () => { + let db: Database.Database; + + beforeEach(() => { + db = initDb(':memory:'); + }); + + it('returns undefined for an unknown username', () => { + expect(getLoginAttempt(db, 'ghost')).toBeUndefined(); + }); + + it('inserts a row on first failure with count=1 and no lock', () => { + const row = recordFailure(db, 'alice', null); + expect(row.username).toBe('alice'); + expect(row.failed_count).toBe(1); + expect(row.last_failed_at).toBeTruthy(); + expect(row.locked_until).toBeNull(); + }); + + it('increments failed_count on subsequent failures', () => { + recordFailure(db, 'alice', null); + recordFailure(db, 'alice', null); + const row = recordFailure(db, 'alice', null); + expect(row.failed_count).toBe(3); + }); + + it('persists locked_until when supplied', () => { + const lockedUntil = new Date(Date.now() + 30_000).toISOString(); + const row = recordFailure(db, 'alice', lockedUntil); + expect(row.locked_until).toBe(lockedUntil); + }); + + it('updates locked_until on subsequent failures', () => { + recordFailure(db, 'alice', null); + const newLock = new Date(Date.now() + 60_000).toISOString(); + const row = recordFailure(db, 'alice', newLock); + expect(row.failed_count).toBe(2); + expect(row.locked_until).toBe(newLock); + }); + + it('resetLoginAttempts deletes the row', () => { + recordFailure(db, 'alice', null); + resetLoginAttempts(db, 'alice'); + expect(getLoginAttempt(db, 'alice')).toBeUndefined(); + }); + + it('reset on a missing username is a no-op', () => { + expect(() => resetLoginAttempts(db, 'ghost')).not.toThrow(); + }); + + it('tracks failures for non-existent users (no FK to users table)', () => { + const row = recordFailure(db, 'never-existed-user', null); + expect(row.failed_count).toBe(1); + }); + + it('getLoginAttempt returns the stored row', () => { + recordFailure(db, 'alice', null); + recordFailure(db, 'alice', null); + const row = getLoginAttempt(db, 'alice'); + expect(row?.username).toBe('alice'); + expect(row?.failed_count).toBe(2); + }); +});