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:
38
src/db/login-attempts.ts
Normal file
38
src/db/login-attempts.ts
Normal 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
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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
127
tests/unit/lockout-service.test.ts
Normal file
127
tests/unit/lockout-service.test.ts
Normal file
@@ -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> = {}): 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
72
tests/unit/login-attempts-db.test.ts
Normal file
72
tests/unit/login-attempts-db.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user