You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/apps/hq/src/auth.ts

149 lines
6.7 KiB
TypeScript

// 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<LoginResult> {
// 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<StaffRow>(
`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
}