75 lines
3.3 KiB
TypeScript
75 lines
3.3 KiB
TypeScript
import { randomUUID, scryptSync, timingSafeEqual } from 'node:crypto';
|
|
import { getGameDb, persist, type GameDb } from '@/lib/db/client';
|
|
|
|
// v0.71.0 — session auth for the admin panel (and later multiplayer). Sessions are opaque
|
|
// UUIDs in a DB table; the client stores one in localStorage ('flight.session') and sends it
|
|
// as the X-Flight-Session header on every authenticated request. No JWT, no cookies: this is
|
|
// a single-origin dev tool surface, not a public auth system.
|
|
|
|
export const SESSION_HEADER = 'x-flight-session';
|
|
export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days from creation
|
|
|
|
// Fixed digest compared against when the username is unknown, so every failed login runs one
|
|
// full scrypt and the timing shape stays uniform (no user-existence oracle).
|
|
export const DUMMY_HASH_FOR_UNKNOWN_USER = '0123456789abcdef0123456789abcdef$b9813a9a95c67878bd5b2c703bce6d57416b5d7e48496dfbf603bc4c6d59137f';
|
|
|
|
export interface AuthUser {
|
|
id: string;
|
|
username: string;
|
|
admin: boolean;
|
|
}
|
|
|
|
// Verify a plaintext password against a stored "salt$hex" scrypt digest (16-byte hex salt,
|
|
// 32-byte hash). Malformed digests never match.
|
|
export function verifyPassword(password: string, storedHash: string): boolean {
|
|
const [saltHex, hashHex] = storedHash.split('$');
|
|
if (!saltHex || !hashHex || hashHex.length !== 64) return false;
|
|
const computed = scryptSync(password, saltHex, 32).toString('hex');
|
|
try {
|
|
return timingSafeEqual(Buffer.from(computed, 'hex'), Buffer.from(hashHex, 'hex'));
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Mint a session row for the user and lazily prune expired sessions. Persists so a restart
|
|
// doesn't silently log everyone out mid-session.
|
|
export async function createSession(inst: GameDb, userId: string): Promise<string> {
|
|
const id = randomUUID();
|
|
inst.raw.run('DELETE FROM sessions WHERE expires_at < ?', [Date.now()]);
|
|
inst.raw.run('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)', [
|
|
id,
|
|
userId,
|
|
Date.now() + SESSION_TTL_MS,
|
|
]);
|
|
await persist(inst);
|
|
return id;
|
|
}
|
|
|
|
// Resolve the X-Flight-Session header to a live (non-expired) user, or null.
|
|
export async function currentUser(req: Request): Promise<AuthUser | null> {
|
|
const sessionId = req.headers.get(SESSION_HEADER);
|
|
if (!sessionId) return null;
|
|
const inst = await getGameDb();
|
|
const res = inst.raw.exec(
|
|
'SELECT u.id, u.username, u.admin FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.id = ? AND s.expires_at > ?',
|
|
[sessionId, Date.now()],
|
|
);
|
|
const row = res[0]?.values?.[0];
|
|
if (!row) return null;
|
|
return { id: String(row[0]), username: String(row[1]), admin: Number(row[2]) === 1 };
|
|
}
|
|
|
|
export type AdminGuard = { ok: true; inst: GameDb; user: AuthUser } | { ok: false; response: Response };
|
|
|
|
// Guard for every /api/admin/* route — runs BEFORE any user input is touched.
|
|
// No/expired session → 401, valid non-admin session → 403.
|
|
export async function requireAdmin(req: Request): Promise<AdminGuard> {
|
|
const user = await currentUser(req);
|
|
if (!user) return { ok: false, response: Response.json({ error: 'authentication required' }, { status: 401 }) };
|
|
if (!user.admin) {
|
|
return { ok: false, response: Response.json({ error: 'admin access required' }, { status: 403 }) };
|
|
}
|
|
return { ok: true, inst: await getGameDb(), user };
|
|
}
|