123 lines
5.7 KiB
TypeScript
123 lines
5.7 KiB
TypeScript
import { randomBytes, randomUUID, scryptSync, timingSafeEqual } from 'node:crypto';
|
|
import { getGameDb, persist, type GameDb } from '@/lib/db/client';
|
|
import type { PlayerRow } from './db/schema';
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
|
|
// Hash a plaintext password into the "salt$hex" scrypt digest verifyPassword() checks — the same
|
|
// call shape as the seeded admin's digest (v0.72.0): 16-byte hex salt passed to scryptSync as a
|
|
// plain string, 32-byte hash. Used by /api/register and the test mint hook so every stored hash
|
|
// verifies through verifyPassword().
|
|
export function hashPassword(password: string): string {
|
|
const salt = randomBytes(16).toString('hex');
|
|
return `${salt}$${scryptSync(password, salt, 32).toString('hex')}`;
|
|
}
|
|
|
|
// 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 a session id to a live (non-expired) user, or null.
|
|
async function resolveSession(inst: GameDb, sessionId: string): Promise<AuthUser | null> {
|
|
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 };
|
|
}
|
|
|
|
// 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;
|
|
return resolveSession(await getGameDb(), sessionId);
|
|
}
|
|
|
|
export type AuthGuard =
|
|
| { ok: true; inst: GameDb; user: AuthUser; player: PlayerRow }
|
|
| { ok: false; response: Response };
|
|
|
|
// v1.1.0 (ISS-107): guard for every game route — resolves the session to its user AND that
|
|
// user's players row (joined on username, so the seeded kamilkrzywda maps to id 'local').
|
|
// 401 when the session is missing/unknown/expired; 500 if the account has no player row
|
|
// (register and the mint hook always create both together — a miss means broken data).
|
|
async function finishAuth(user: AuthUser | null): Promise<AuthGuard> {
|
|
if (!user) return { ok: false, response: Response.json({ error: 'authentication required' }, { status: 401 }) };
|
|
const inst = await getGameDb();
|
|
const res = inst.raw.exec('SELECT id, provider, username, cash, reputation FROM players WHERE username = ?', [user.username]);
|
|
const row = res[0]?.values?.[0];
|
|
if (!row) return { ok: false, response: Response.json({ error: 'player row missing for this account' }, { status: 500 }) };
|
|
return {
|
|
ok: true,
|
|
inst,
|
|
user,
|
|
player: { id: String(row[0]), provider: String(row[1]), username: String(row[2]), cash: Number(row[3]), reputation: Number(row[4]) },
|
|
};
|
|
}
|
|
|
|
export async function requireAuth(req: Request): Promise<AuthGuard> {
|
|
return finishAuth(await currentUser(req));
|
|
}
|
|
|
|
// /api/events only — EventSource cannot set headers, so the SSE route also accepts the session as a
|
|
// ?session= query parameter (private infra; every other game route is header-only).
|
|
export async function requireAuthSse(req: Request): Promise<AuthGuard> {
|
|
const sessionId = req.headers.get(SESSION_HEADER) ?? new URL(req.url).searchParams.get('session');
|
|
if (!sessionId) return finishAuth(null);
|
|
return finishAuth(await resolveSession(await getGameDb(), sessionId));
|
|
}
|
|
|
|
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 };
|
|
}
|