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 { 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 { 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' }; }