feat(auth): add shared login handler with constant-time clamp
All checks were successful
Deploy to Homelab / deploy (push) Successful in 18s
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.
This commit is contained in:
212
tests/unit/login-handler.test.ts
Normal file
212
tests/unit/login-handler.test.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { mkdtempSync, rmSync, readFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import type Database from 'better-sqlite3';
|
||||
import { initDb } from '../../src/db/schema.ts';
|
||||
import { createUser } from '../../src/db/users.ts';
|
||||
import { hashPassword } from '../../src/services/auth.ts';
|
||||
import { createLogger } from '../../src/middleware/logging.ts';
|
||||
import { createLockoutService } from '../../src/services/lockout.ts';
|
||||
import { attemptLogin } from '../../src/services/login-handler.ts';
|
||||
import { _resetDummyHashForTests } from '../../src/services/dummy-hash.ts';
|
||||
import type { Config } from '../../src/config.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: 2,
|
||||
lockoutBaseSeconds: 60,
|
||||
lockoutMaxSeconds: 600,
|
||||
loginMinResponseMs: 50,
|
||||
loginRateLimitMax: 0,
|
||||
loginRateLimitWindowSeconds: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('login handler', () => {
|
||||
let db: Database.Database;
|
||||
let logDir: string;
|
||||
let logFile: string;
|
||||
let config: Config;
|
||||
|
||||
beforeEach(async () => {
|
||||
_resetDummyHashForTests();
|
||||
db = initDb(':memory:');
|
||||
logDir = mkdtempSync(join(tmpdir(), 'nanodrop-handler-'));
|
||||
logFile = join(logDir, 'test.log');
|
||||
config = makeConfig({ logFile });
|
||||
const passwordHash = await hashPassword('correct-pw');
|
||||
createUser(db, { username: 'alice', passwordHash });
|
||||
// Warm dummy hash so timing assertions don't include the first cold compute.
|
||||
const { verifyAgainstDummy } = await import('../../src/services/dummy-hash.ts');
|
||||
await verifyAgainstDummy('warmup');
|
||||
});
|
||||
|
||||
function buildDeps() {
|
||||
const logger = createLogger(logFile);
|
||||
const lockout = createLockoutService({ db, config });
|
||||
return { db, config, logger, lockout };
|
||||
}
|
||||
|
||||
it('returns success for valid credentials and resets lockout', async () => {
|
||||
const deps = buildDeps();
|
||||
const result = await attemptLogin(deps, {
|
||||
username: 'alice',
|
||||
password: 'correct-pw',
|
||||
ip: '1.1.1.1',
|
||||
userAgent: 'ua',
|
||||
});
|
||||
expect(result.kind).toBe('success');
|
||||
if (result.kind === 'success') {
|
||||
expect(result.user.username).toBe('alice');
|
||||
}
|
||||
const log = readFileSync(logFile, 'utf-8');
|
||||
expect(log).toMatch(/AUTH_SUCCESS/);
|
||||
});
|
||||
|
||||
it('returns bad_credentials and logs failure on wrong password', async () => {
|
||||
const deps = buildDeps();
|
||||
const result = await attemptLogin(deps, {
|
||||
username: 'alice',
|
||||
password: 'wrong',
|
||||
ip: '1.1.1.1',
|
||||
userAgent: 'ua',
|
||||
});
|
||||
expect(result.kind).toBe('bad_credentials');
|
||||
const log = readFileSync(logFile, 'utf-8');
|
||||
expect(log).toMatch(/AUTH_FAILURE/);
|
||||
expect(log).toMatch(/username="alice"/);
|
||||
});
|
||||
|
||||
it('returns bad_credentials for unknown user (still runs bcrypt against dummy)', async () => {
|
||||
const deps = buildDeps();
|
||||
const start = Date.now();
|
||||
const result = await attemptLogin(deps, {
|
||||
username: 'ghost',
|
||||
password: 'whatever',
|
||||
ip: '1.1.1.1',
|
||||
userAgent: 'ua',
|
||||
});
|
||||
const elapsed = Date.now() - start;
|
||||
expect(result.kind).toBe('bad_credentials');
|
||||
// Must spend bcrypt-comparable time even for unknown user.
|
||||
expect(elapsed).toBeGreaterThan(20);
|
||||
const log = readFileSync(logFile, 'utf-8');
|
||||
expect(log).toMatch(/AUTH_FAILURE/);
|
||||
expect(log).toMatch(/username="ghost"/);
|
||||
}, 10_000);
|
||||
|
||||
it('canonicalizes username (lowercase + trim) before lookup and lockout', async () => {
|
||||
const deps = buildDeps();
|
||||
const result = await attemptLogin(deps, {
|
||||
username: ' ALICE ',
|
||||
password: 'correct-pw',
|
||||
ip: '1.1.1.1',
|
||||
userAgent: 'ua',
|
||||
});
|
||||
expect(result.kind).toBe('success');
|
||||
});
|
||||
|
||||
it('returns bad_request when username or password is empty', async () => {
|
||||
const deps = buildDeps();
|
||||
const r1 = await attemptLogin(deps, { username: '', password: 'x', ip: '1', userAgent: 'ua' });
|
||||
expect(r1.kind).toBe('bad_request');
|
||||
const r2 = await attemptLogin(deps, { username: 'alice', password: '', ip: '1', userAgent: 'ua' });
|
||||
expect(r2.kind).toBe('bad_request');
|
||||
});
|
||||
|
||||
it('triggers lockout at threshold and logs AUTH_LOCKOUT_TRIGGERED', async () => {
|
||||
const deps = buildDeps();
|
||||
await attemptLogin(deps, { username: 'alice', password: 'wrong', ip: '1.1.1.1', userAgent: 'ua' });
|
||||
const second = await attemptLogin(deps, { username: 'alice', password: 'wrong', ip: '1.1.1.1', userAgent: 'ua' });
|
||||
expect(second.kind).toBe('bad_credentials');
|
||||
const log = readFileSync(logFile, 'utf-8');
|
||||
expect(log).toMatch(/AUTH_LOCKOUT_TRIGGERED/);
|
||||
expect(log).toMatch(/duration_seconds=60/);
|
||||
});
|
||||
|
||||
it('returns locked + retry-after on subsequent attempt; correct password still rejected', async () => {
|
||||
const deps = buildDeps();
|
||||
await attemptLogin(deps, { username: 'alice', password: 'wrong', ip: '1.1.1.1', userAgent: 'ua' });
|
||||
await attemptLogin(deps, { username: 'alice', password: 'wrong', ip: '1.1.1.1', userAgent: 'ua' });
|
||||
|
||||
const blocked = await attemptLogin(deps, {
|
||||
username: 'alice',
|
||||
password: 'correct-pw',
|
||||
ip: '1.1.1.1',
|
||||
userAgent: 'ua',
|
||||
});
|
||||
expect(blocked.kind).toBe('locked');
|
||||
if (blocked.kind === 'locked') {
|
||||
expect(blocked.retryAfterSeconds).toBeGreaterThan(0);
|
||||
expect(blocked.retryAfterSeconds).toBeLessThanOrEqual(60);
|
||||
}
|
||||
|
||||
const log = readFileSync(logFile, 'utf-8');
|
||||
expect(log).toMatch(/AUTH_LOCKED_ATTEMPT/);
|
||||
// Even though password was correct, no AUTH_SUCCESS for this attempt.
|
||||
const successCount = (log.match(/AUTH_SUCCESS/g) ?? []).length;
|
||||
expect(successCount).toBe(0);
|
||||
});
|
||||
|
||||
it('does NOT call bcrypt when account is locked (short-circuits)', async () => {
|
||||
const deps = buildDeps();
|
||||
// Lock the account.
|
||||
await attemptLogin(deps, { username: 'alice', password: 'wrong', ip: '1', userAgent: 'ua' });
|
||||
await attemptLogin(deps, { username: 'alice', password: 'wrong', ip: '1', userAgent: 'ua' });
|
||||
|
||||
// Locked attempt with constant-time clamp at 50ms — should be roughly clamp duration,
|
||||
// not bcrypt time (250ms+).
|
||||
const start = Date.now();
|
||||
await attemptLogin(deps, { username: 'alice', password: 'correct-pw', ip: '1', userAgent: 'ua' });
|
||||
const elapsed = Date.now() - start;
|
||||
// Clamp is 50ms; allow generous slack but assert well under bcrypt cost.
|
||||
expect(elapsed).toBeLessThan(150);
|
||||
});
|
||||
|
||||
it('respects loginMinResponseMs clamp on bad_credentials', async () => {
|
||||
config = makeConfig({ logFile, loginMinResponseMs: 200 });
|
||||
const deps = buildDeps();
|
||||
const start = Date.now();
|
||||
await attemptLogin(deps, { username: 'alice', password: 'wrong', ip: '1', userAgent: 'ua' });
|
||||
const elapsed = Date.now() - start;
|
||||
expect(elapsed).toBeGreaterThanOrEqual(190);
|
||||
}, 10_000);
|
||||
|
||||
it('successful login between failures resets the counter', async () => {
|
||||
const deps = buildDeps();
|
||||
await attemptLogin(deps, { username: 'alice', password: 'wrong', ip: '1', userAgent: 'ua' });
|
||||
await attemptLogin(deps, { username: 'alice', password: 'correct-pw', ip: '1', userAgent: 'ua' });
|
||||
const r = await attemptLogin(deps, { username: 'alice', password: 'wrong', ip: '1', userAgent: 'ua' });
|
||||
// After reset, this is failure #1 — well under threshold of 2, so no lock yet.
|
||||
expect(r.kind).toBe('bad_credentials');
|
||||
// No AUTH_LOCKOUT_TRIGGERED in log.
|
||||
const log = readFileSync(logFile, 'utf-8');
|
||||
expect(log).not.toMatch(/AUTH_LOCKOUT_TRIGGERED/);
|
||||
});
|
||||
|
||||
it('unknown username also accumulates lockout', async () => {
|
||||
const deps = buildDeps();
|
||||
await attemptLogin(deps, { username: 'ghost', password: 'x', ip: '1', userAgent: 'ua' });
|
||||
await attemptLogin(deps, { username: 'ghost', password: 'x', ip: '1', userAgent: 'ua' });
|
||||
const r = await attemptLogin(deps, { username: 'ghost', password: 'x', ip: '1', userAgent: 'ua' });
|
||||
expect(r.kind).toBe('locked');
|
||||
}, 10_000);
|
||||
|
||||
// Cleanup
|
||||
beforeEach(() => {
|
||||
return () => rmSync(logDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user