feat(auth): add login-attempts DB layer and lockout service
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:
2026-05-03 03:29:09 -07:00
parent f4eaf88495
commit 11e87f353d
4 changed files with 315 additions and 0 deletions

View 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();
});
});
});

View 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);
});
});