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:
@@ -50,6 +50,48 @@ describe('middleware/logging', () => {
|
||||
expect(existsSync(logFile)).toBe(true);
|
||||
});
|
||||
|
||||
it('writes AUTH_LOCKOUT_TRIGGERED log entry with duration', async () => {
|
||||
const logger = createLogger(logFile);
|
||||
await logger.authLockoutTriggered({
|
||||
ip: '1.2.3.4',
|
||||
userAgent: 'TestAgent/1.0',
|
||||
username: 'alice',
|
||||
durationSeconds: 60,
|
||||
});
|
||||
const content = readFileSync(logFile, 'utf-8');
|
||||
expect(content).toMatch(/AUTH_LOCKOUT_TRIGGERED/);
|
||||
expect(content).toMatch(/ip=1\.2\.3\.4/);
|
||||
expect(content).toMatch(/username="alice"/);
|
||||
expect(content).toMatch(/duration_seconds=60/);
|
||||
});
|
||||
|
||||
it('writes AUTH_LOCKED_ATTEMPT log entry with retry-after', async () => {
|
||||
const logger = createLogger(logFile);
|
||||
await logger.authLockedAttempt({
|
||||
ip: '1.2.3.4',
|
||||
userAgent: 'TestAgent/1.0',
|
||||
username: 'alice',
|
||||
retryAfterSeconds: 25,
|
||||
});
|
||||
const content = readFileSync(logFile, 'utf-8');
|
||||
expect(content).toMatch(/AUTH_LOCKED_ATTEMPT/);
|
||||
expect(content).toMatch(/username="alice"/);
|
||||
expect(content).toMatch(/retry_after_seconds=25/);
|
||||
});
|
||||
|
||||
it('writes AUTH_RATE_LIMITED log entry with route', async () => {
|
||||
const logger = createLogger(logFile);
|
||||
await logger.authRateLimited({
|
||||
ip: '9.9.9.9',
|
||||
userAgent: 'curl/7.0',
|
||||
route: '/api/v1/auth/login',
|
||||
});
|
||||
const content = readFileSync(logFile, 'utf-8');
|
||||
expect(content).toMatch(/AUTH_RATE_LIMITED/);
|
||||
expect(content).toMatch(/ip=9\.9\.9\.9/);
|
||||
expect(content).toMatch(/route="\/api\/v1\/auth\/login"/);
|
||||
});
|
||||
|
||||
it('appends multiple entries', async () => {
|
||||
const logger = createLogger(logFile);
|
||||
await logger.authSuccess({ ip: '1.1.1.1', userAgent: 'a', username: 'u1' });
|
||||
|
||||
Reference in New Issue
Block a user