Files
nanodrop/src/middleware/logging.ts
Brendan Chen ad36b23061
All checks were successful
Deploy to Homelab / deploy (push) Successful in 18s
feat(auth): add shared login handler with constant-time clamp
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.
2026-05-03 03:34:15 -07:00

64 lines
2.0 KiB
TypeScript

import { appendFile } from 'fs/promises';
interface AuthLogParams {
ip: string;
userAgent: string;
username: string;
}
interface FileNotFoundParams {
ip: string;
userAgent: string;
fileId: string;
}
interface AuthLockoutTriggeredParams extends AuthLogParams {
durationSeconds: number;
}
interface AuthLockedAttemptParams extends AuthLogParams {
retryAfterSeconds: number;
}
interface AuthRateLimitedParams {
ip: string;
userAgent: string;
route: string;
}
export interface Logger {
authSuccess(params: AuthLogParams): Promise<void>;
authFailure(params: AuthLogParams): Promise<void>;
authLockoutTriggered(params: AuthLockoutTriggeredParams): Promise<void>;
authLockedAttempt(params: AuthLockedAttemptParams): Promise<void>;
authRateLimited(params: AuthRateLimitedParams): Promise<void>;
fileNotFound(params: FileNotFoundParams): Promise<void>;
}
export function createLogger(logFile: string): Logger {
function timestamp(): string {
return new Date().toISOString();
}
async function write(line: string): Promise<void> {
await appendFile(logFile, line + '\n');
}
function authLine(event: string, { ip, userAgent, username }: AuthLogParams): string {
return `[${timestamp()}] ${event} ip=${ip} user-agent="${userAgent}" username="${username}"`;
}
return {
authSuccess: (params) => write(authLine('AUTH_SUCCESS', params)),
authFailure: (params) => write(authLine('AUTH_FAILURE', params)),
authLockoutTriggered: ({ durationSeconds, ...auth }) =>
write(`${authLine('AUTH_LOCKOUT_TRIGGERED', auth)} duration_seconds=${durationSeconds}`),
authLockedAttempt: ({ retryAfterSeconds, ...auth }) =>
write(`${authLine('AUTH_LOCKED_ATTEMPT', auth)} retry_after_seconds=${retryAfterSeconds}`),
authRateLimited: ({ ip, userAgent, route }) =>
write(`[${timestamp()}] AUTH_RATE_LIMITED ip=${ip} user-agent="${userAgent}" route="${route}"`),
fileNotFound: ({ ip, userAgent, fileId }) =>
write(`[${timestamp()}] FILE_NOT_FOUND ip=${ip} user-agent="${userAgent}" file_id="${fileId}"`),
};
}