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.
91 lines
2.7 KiB
TypeScript
91 lines
2.7 KiB
TypeScript
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' };
|
|
}
|