// apps/hq/src/auth.ts import { randomBytes } from 'node:crypto' import type { RequestHandler } from 'express' import { hashPin, verifyPin } from '@sims/auth' import { uuidv7 } from '@sims/domain' import { writeAudit } from './audit' import type { DB } from './db' const SESSION_DAYS = 14 // D24 (red-team) — brute-force lockout policy. const MAX_FAILED = 8 // consecutive failures before the account locks const LOCK_MINUTES = 15 // how long a locked account stays barred // A fixed dummy hash so a login for a NON-existent email still spends one scrypt // verification — constant time, no email-enumeration oracle. Computed once at load. const DUMMY_HASH = hashPin('x'.repeat(24)) export async function createStaff(db: DB, input: { email: string; displayName: string; role: 'owner' | 'manager' | 'staff'; password: string username?: string // D25: login identifier; defaults to email so existing callers/tests are unchanged }): Promise<{ id: string }> { if (input.password.length < 8) throw new Error('Password must be at least 8 characters') const id = uuidv7() const username = (input.username ?? input.email).trim().toLowerCase() const { salt, hash } = hashPin(input.password) // generic scrypt; PIN digit policy not applied await db.run( `INSERT INTO staff_user (id, username, email, display_name, role, pw_salt, pw_hash) VALUES (?, ?, ?, ?, ?, ?, ?)`, id, username, input.email.toLowerCase(), input.displayName, input.role, salt, hash, ) await writeAudit(db, 'system', 'create', 'staff_user', id, undefined, { username, email: input.email, role: input.role }) return { id } } interface StaffRow { id: string; email: string; display_name: string; role: string pw_salt: string; pw_hash: string; active: number failed_count: number; locked_until: string | null must_change_password: number } export type LoginResult = | { ok: true; token: string; staff: { id: string; displayName: string; role: string; mustChangePassword: boolean } } | { ok: false; reason: 'invalid' | 'locked' } /** * Authenticate, with brute-force lockout (D24). Always spends exactly one scrypt * verification — a real one for an existing account, a dummy for a missing one — so * response time never reveals whether an email exists. After MAX_FAILED consecutive * failures the account locks for LOCK_MINUTES; a success clears the counter. The generic * 'invalid' reason is returned for both wrong-password and unknown-email. */ export async function login(db: DB, identifier: string, password: string): Promise { // D25: log in by username. Existing accounts backfilled username=email, so an email // string still matches; a legacy email fallback keeps any un-backfilled row working. const key = identifier.trim().toLowerCase() const row = await db.get( `SELECT * FROM staff_user WHERE (username=? OR email=?) AND active=1`, key, key, ) const now = Date.now() // Locked? Still spend a scrypt op (constant time) before returning. if (row && row.locked_until !== null && Date.parse(row.locked_until) > now) { verifyPin(password, { salt: DUMMY_HASH.salt, hash: DUMMY_HASH.hash }) return { ok: false, reason: 'locked' } } const stored = row ? { salt: row.pw_salt, hash: row.pw_hash } : DUMMY_HASH const passwordOk = verifyPin(password, stored) if (!row || !passwordOk) { if (row) { // Count the failure; lock once the threshold is crossed. const failed = row.failed_count + 1 const lockUntil = failed >= MAX_FAILED ? new Date(now + LOCK_MINUTES * 60_000).toISOString() : null await db.run(`UPDATE staff_user SET failed_count=?, locked_until=? WHERE id=?`, failed, lockUntil, row.id) } return { ok: false, reason: 'invalid' } } const token = randomBytes(32).toString('hex') const expires = new Date(now + SESSION_DAYS * 86_400_000).toISOString() await db.transaction(async () => { await db.run(`UPDATE staff_user SET failed_count=0, locked_until=NULL WHERE id=?`, row.id) await db.run(`INSERT INTO session (token, staff_id, expires_at) VALUES (?, ?, ?)`, token, row.id, expires) await writeAudit(db, row.id, 'login', 'staff_user', row.id) }) return { ok: true, token, staff: { id: row.id, displayName: row.display_name, role: row.role, mustChangePassword: row.must_change_password === 1 }, } } export async function verifySession(db: DB, token: string): Promise<{ id: string; role: string } | null> { // u.active=1: a deactivated employee's unexpired token must die immediately. const row = await db.get<{ id: string; role: string }>( `SELECT s.staff_id AS id, u.role FROM session s JOIN staff_user u ON u.id = s.staff_id WHERE s.token=? AND s.expires_at > ? AND u.active = 1`, token, new Date().toISOString(), ) return row ?? null } export const requireAuth: RequestHandler = (req, res, next) => { void (async () => { const db = req.app.locals['db'] as DB const token = (req.headers.authorization ?? '').replace(/^Bearer /, '') const staff = token ? await verifySession(db, token) : null if (!staff) { res.status(401).json({ ok: false, error: 'Not signed in' }); return } res.locals['staff'] = staff next() })() } export const requireOwner: RequestHandler = (_req, res, next) => { if ((res.locals['staff'] as { role: string }).role !== 'owner') { res.status(403).json({ ok: false, error: 'Owner only' }); return } next() } /** * Gate for owner|manager (D24 red-team): financially destructive or externally-visible * actions — cancel a document, raise a credit note, mint a public share link — are * reserved for managerial roles, not open to every signed-in staff user. */ export const requireManagerial: RequestHandler = (_req, res, next) => { if (!isManagerial((res.locals['staff'] as { role: string }).role)) { res.status(403).json({ ok: false, error: 'Owner or manager only' }); return } next() } /** True for the managerial roles (owner|manager) allowed to see/act beyond their own rows. */ export const isManagerial = (role: string): boolean => role === 'owner' || role === 'manager' /** * The shared server-side role gate for owner-scoped queries. Input comes from * res.locals.staff — never from the request body. Staff are forced to their own * rows and any widening param is ignored; owner/manager are unfiltered by default * and may narrow via `requestedOwnerId`. Returns the employee id every owner-scoped * query must filter by, or undefined for no filter. */ export function ownerScope( viewer: { id: string; role: string }, requestedOwnerId?: string, ): string | undefined { if (!isManagerial(viewer.role)) return viewer.id return requestedOwnerId !== undefined && requestedOwnerId !== '' ? requestedOwnerId : undefined }