All checks were successful
Deploy to Homelab / deploy (push) Successful in 18s
Single attemptLogin() orchestrates lockout check, bcrypt verify (against real or dummy hash), success/failure logging, and a configurable minimum response-time clamp. Both login routes will share this — no duplication. Adds three logger events for the operator's escalation pipeline: AUTH_LOCKOUT_TRIGGERED, AUTH_LOCKED_ATTEMPT, and AUTH_RATE_LIMITED. fail2ban filters can pick these up to escalate persistent attackers from in-app lockout to IP ban. Constant-time defense: unknown users still pay bcrypt cost (via dummy hash) and the clamp ensures locked-vs-unknown-vs-wrong-password aren't distinguishable by response time. Username is canonicalized (lowercase + trim) before lookup so attackers can't bypass lockout via case variation.
59 lines
2.2 KiB
TypeScript
59 lines
2.2 KiB
TypeScript
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import bcrypt from 'bcrypt';
|
|
import { verifyAgainstDummy, _resetDummyHashForTests } from '../../src/services/dummy-hash.ts';
|
|
import { hashPassword, verifyPassword } from '../../src/services/auth.ts';
|
|
|
|
describe('dummy hash', () => {
|
|
beforeEach(() => {
|
|
_resetDummyHashForTests();
|
|
});
|
|
|
|
it('always returns false', async () => {
|
|
expect(await verifyAgainstDummy('whatever')).toBe(false);
|
|
expect(await verifyAgainstDummy('')).toBe(false);
|
|
expect(await verifyAgainstDummy('admin')).toBe(false);
|
|
});
|
|
|
|
it('takes comparable time to verifying a real bcrypt hash (within 5x)', async () => {
|
|
// Warm dummy hash so the cache is hot.
|
|
await verifyAgainstDummy('warmup');
|
|
const realHash = await hashPassword('actual-password');
|
|
|
|
const start1 = Date.now();
|
|
await verifyPassword('actual-password', realHash);
|
|
const realMs = Date.now() - start1;
|
|
|
|
const start2 = Date.now();
|
|
await verifyAgainstDummy('any-password');
|
|
const dummyMs = Date.now() - start2;
|
|
|
|
// Both should be in the same ballpark — bcrypt cost factor is the same.
|
|
// Generous bound to avoid flakes on slow CI.
|
|
expect(dummyMs).toBeGreaterThan(realMs / 5);
|
|
expect(dummyMs).toBeLessThan(realMs * 5);
|
|
}, 10_000);
|
|
|
|
it('memoizes the dummy hash across calls', async () => {
|
|
// First call computes, subsequent calls reuse — covered by cache hit
|
|
// being noticeably faster than a fresh hash. Just assert the function
|
|
// is callable repeatedly without error.
|
|
await verifyAgainstDummy('a');
|
|
await verifyAgainstDummy('b');
|
|
await verifyAgainstDummy('c');
|
|
expect(true).toBe(true);
|
|
});
|
|
|
|
it('runs a real bcrypt comparison (does not short-circuit)', async () => {
|
|
// Spy by counting bcrypt.compare calls would be nice, but bcrypt
|
|
// is a compiled module. Indirect check: the call must actually take
|
|
// bcrypt-comparison time after warmup.
|
|
await verifyAgainstDummy('warmup');
|
|
const start = Date.now();
|
|
await verifyAgainstDummy('test');
|
|
const elapsed = Date.now() - start;
|
|
// bcrypt 12 rounds takes >50ms on any modern CPU
|
|
expect(elapsed).toBeGreaterThan(20);
|
|
expect(bcrypt).toBeDefined();
|
|
}, 10_000);
|
|
});
|