v1.1.0: session guard on all game routes + register + sign-in screen (ISS-107)
CI / build + unit + e2e (pull_request) Failing after 26m20s
CI / build + unit + e2e (pull_request) Failing after 26m20s
This commit is contained in:
+54
-6
@@ -1,5 +1,6 @@
|
||||
import { randomUUID, scryptSync, timingSafeEqual } from 'node:crypto';
|
||||
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
|
||||
@@ -32,6 +33,15 @@ export function verifyPassword(password: string, storedHash: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
// 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> {
|
||||
@@ -46,11 +56,8 @@ export async function createSession(inst: GameDb, userId: string): Promise<strin
|
||||
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();
|
||||
// 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()],
|
||||
@@ -60,6 +67,47 @@ export async function currentUser(req: Request): Promise<AuthUser | 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.
|
||||
|
||||
Reference in New Issue
Block a user