feat(auth): add shared login handler with constant-time clamp
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:
2026-05-03 03:34:15 -07:00
parent 11e87f353d
commit ad36b23061
5 changed files with 425 additions and 0 deletions

View File

@@ -12,9 +12,26 @@ interface FileNotFoundParams {
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>;
}
@@ -34,6 +51,12 @@ export function createLogger(logFile: string): Logger {
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}"`),
};

View File

@@ -0,0 +1,90 @@
import type Database from 'better-sqlite3';
import type { Config } from '../config.ts';
import type { Logger } from '../middleware/logging.ts';
import type { LockoutService } from './lockout.ts';
import { getUserByUsername } from '../db/users.ts';
import { verifyPassword } from './auth.ts';
import { verifyAgainstDummy } from './dummy-hash.ts';
interface UserRow {
id: number;
username: string;
password_hash: string;
created_at: string;
}
export type LoginResult =
| { kind: 'success'; user: UserRow }
| { kind: 'bad_credentials' }
| { kind: 'locked'; retryAfterSeconds: number }
| { kind: 'bad_request' };
export interface LoginHandlerDeps {
db: Database.Database;
config: Config;
logger: Logger;
lockout: LockoutService;
}
export interface LoginInput {
username: string;
password: string;
ip: string;
userAgent: string;
}
function canonicalize(username: string): string {
return username.trim().toLowerCase();
}
async function clamp(startMs: number, minMs: number): Promise<void> {
const elapsed = Date.now() - startMs;
const remaining = minMs - elapsed;
if (remaining > 0) {
await new Promise((resolve) => setTimeout(resolve, remaining));
}
}
export async function attemptLogin(
deps: LoginHandlerDeps,
input: LoginInput,
): Promise<LoginResult> {
const { db, config, logger, lockout } = deps;
const start = Date.now();
if (!input.username || !input.password) {
// No clamp on bad_request — it's a programmer/format error from the caller,
// not a credential test, so timing isn't sensitive.
return { kind: 'bad_request' };
}
const username = canonicalize(input.username);
const logBase = { ip: input.ip, userAgent: input.userAgent, username };
const lockStatus = lockout.check(username);
if (lockStatus.locked) {
await logger.authLockedAttempt({ ...logBase, retryAfterSeconds: lockStatus.retryAfterSeconds! });
await clamp(start, config.loginMinResponseMs);
return { kind: 'locked', retryAfterSeconds: lockStatus.retryAfterSeconds! };
}
const user = getUserByUsername(db, username);
const valid = user
? await verifyPassword(input.password, user.password_hash)
: await verifyAgainstDummy(input.password);
if (user && valid) {
lockout.recordSuccess(username);
await logger.authSuccess(logBase);
await clamp(start, config.loginMinResponseMs);
return { kind: 'success', user };
}
const failure = lockout.recordFailure(username);
if (failure.locked) {
await logger.authLockoutTriggered({ ...logBase, durationSeconds: failure.durationSeconds });
}
await logger.authFailure(logBase);
await clamp(start, config.loginMinResponseMs);
return { kind: 'bad_credentials' };
}