diff --git a/migrations/036_users.sql b/migrations/036_users.sql new file mode 100644 index 0000000..d82a477 --- /dev/null +++ b/migrations/036_users.sql @@ -0,0 +1,12 @@ +-- Locally-issued player accounts. GUPI mints the JWT for now; external_id is +-- reserved so a glitch.university key-exchange can later link/migrate an account +-- without changing how playthroughs bind (they key off the JWT sub = users.id). +CREATE TABLE osint.users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + handle TEXT NOT NULL UNIQUE CHECK (handle ~ '^[a-z0-9_.-]{3,32}$'), + password_hash TEXT NOT NULL, + display_name TEXT NOT NULL, + avatar_url TEXT, + external_id TEXT UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/server/auth.ts b/server/auth.ts index 7982c84..bdc4b17 100644 --- a/server/auth.ts +++ b/server/auth.ts @@ -49,6 +49,13 @@ export function requireAdmin(req: Request, res: Response, next: NextFunction) { next() } +// Mint a player token (path A: GUPI is the issuer for now). Verification is +// issuer-agnostic — a glitch.university token with the same sub verifies identically. +export function signPlayerToken(user: { id: string; displayName: string }) { + if (!process.env.JWT_SECRET) throw new Error('JWT_SECRET is required') + return jwt.sign({ sub: user.id, name: user.displayName, role: 'player' }, process.env.JWT_SECRET, { expiresIn: '30d' }) +} + export function createDevelopmentAdminToken() { if (process.env.NODE_ENV === 'production') throw new Error('Development sessions are disabled in production') if (!process.env.JWT_SECRET) throw new Error('JWT_SECRET is required') diff --git a/server/index.ts b/server/index.ts index 3793364..8c4fc0e 100644 --- a/server/index.ts +++ b/server/index.ts @@ -8,7 +8,8 @@ import { fileURLToPath } from 'node:url' import multer from 'multer' import pg from 'pg' import type { CaseState } from '../src/types.js' -import { authenticateJwt, createDevelopmentAdminToken, hasAdminClaim, requireAdmin, resolvePlayerName, resolveUserId } from './auth.js' +import { authenticateJwt, createDevelopmentAdminToken, hasAdminClaim, requireAdmin, resolvePlayerName, resolveUserId, signPlayerToken } from './auth.js' +import { createUserRepository } from './userRepository.js' import { submitCaseReport } from './caseReports.js' import { createLevelRepository } from './levelRepository.js' import { createEvidenceJudgeFromEnv } from './evidenceJudge.js' @@ -33,6 +34,8 @@ const evidenceJudge = createEvidenceJudgeFromEnv() const levels = createLevelRepository(pool, editingEnabled, objectStorage, evidenceJudge) const narrative = createNarrativeRepository(pool, objectStorage) const storyGraph = createStoryGraphRepository(pool) +const users = createUserRepository(pool) +const AUTH_COOKIE = { httpOnly: true, sameSite: 'lax' as const, path: '/', maxAge: 30 * 24 * 60 * 60 * 1000 } const STORY_NODE_TYPES: StoryNodeType[] = ['cutscene', 'dialogue', 'level', 'det_gate', 'llm_gate', 'merit', 'phone'] function wantsEdit(req: express.Request) { @@ -59,6 +62,35 @@ app.get('/api/health', async (_req, res) => { catch { res.status(503).json({ ok: false, database: 'unavailable' }) } }) app.get('/api/session', (req, res) => res.json({ authenticated: Boolean(req.authClaims), isAdmin: hasAdminClaim(req), playerName:resolvePlayerName(req) })) + +// Player accounts (path A: GUPI issues the token). register/login set the auth_token +// cookie; every game write then binds to this user via resolveUserId. +app.post('/api/auth/register', async (req, res, next) => { + try { + const body = req.body || {} + const result = await users.registerUser({ handle: String(body.handle || ''), password: String(body.password || ''), displayName: String(body.displayName || '') }) + if (result.error || !result.user) return res.status(result.error === 'That handle is taken' ? 409 : 400).json({ error: result.error || 'Registration failed' }) + res.cookie('auth_token', signPlayerToken(result.user), AUTH_COOKIE) + res.status(201).json({ user: result.user }) + } catch (error) { next(error) } +}) +app.post('/api/auth/login', async (req, res, next) => { + try { + const body = req.body || {} + const user = await users.authenticateUser(String(body.handle || ''), String(body.password || '')) + if (!user) return res.status(401).json({ error: 'Invalid handle or password' }) + res.cookie('auth_token', signPlayerToken(user), AUTH_COOKIE) + res.json({ user }) + } catch (error) { next(error) } +}) +app.post('/api/auth/logout', (_req, res) => { res.clearCookie('auth_token', { path: '/' }); res.json({ ok: true }) }) +app.get('/api/auth/me', async (req, res, next) => { + try { + const sub = req.authClaims?.sub + const user = typeof sub === 'string' ? await users.getUser(sub) : null + user ? res.json({ user }) : res.status(204).end() + } catch (error) { next(error) } +}) if (process.env.NODE_ENV !== 'production') app.get('/api/dev/admin-session', (req, res) => { const requestedReturn = String(req.query.returnTo || '/') const returnTo = requestedReturn.startsWith('/') && !requestedReturn.startsWith('//') ? requestedReturn : '/' diff --git a/server/userRepository.ts b/server/userRepository.ts new file mode 100644 index 0000000..8f5ed92 --- /dev/null +++ b/server/userRepository.ts @@ -0,0 +1,60 @@ +import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto' +import type { Pool } from 'pg' + +export type UserDto = { id: string; handle: string; displayName: string; avatarUrl: string | null } + +export interface UserRepository { + registerUser(input: { handle: string; password: string; displayName: string }): Promise<{ user?: UserDto; error?: string }> + authenticateUser(handle: string, password: string): Promise + getUser(id: string): Promise +} + +const HANDLE = /^[a-z0-9_.-]{3,32}$/ + +// scrypt with a per-user random salt; stored as `salt:hash` hex. No dependency. +function hashPassword(password: string): string { + const salt = randomBytes(16) + return `${salt.toString('hex')}:${scryptSync(password, salt, 64).toString('hex')}` +} +function verifyPassword(password: string, stored: string): boolean { + const [saltHex, hashHex] = stored.split(':') + if (!saltHex || !hashHex) return false + const expected = Buffer.from(hashHex, 'hex') + const actual = scryptSync(password, Buffer.from(saltHex, 'hex'), 64) + return expected.length === actual.length && timingSafeEqual(expected, actual) +} + +export function createUserRepository(pool: Pool): UserRepository { + const toDto = (row: { id: string; handle: string; display_name: string; avatar_url: string | null }): UserDto => + ({ id: row.id, handle: row.handle, displayName: row.display_name, avatarUrl: row.avatar_url }) + + return { + async registerUser({ handle: rawHandle, password, displayName: rawName }) { + const handle = rawHandle.trim().toLowerCase() + const displayName = rawName.trim().slice(0, 60) + if (!HANDLE.test(handle)) return { error: 'Handle must be 3–32 chars: a–z, 0–9, . _ -' } + if (password.length < 6) return { error: 'Password must be at least 6 characters' } + if (!displayName) return { error: 'A display name is required' } + const existing = await pool.query('SELECT 1 FROM osint.users WHERE handle=$1', [handle]) + if (existing.rowCount) return { error: 'That handle is taken' } + const row = (await pool.query<{ id: string; handle: string; display_name: string; avatar_url: string | null }>( + 'INSERT INTO osint.users (handle,password_hash,display_name) VALUES ($1,$2,$3) RETURNING id,handle,display_name,avatar_url', + [handle, hashPassword(password), displayName])).rows[0] + return { user: toDto(row) } + }, + + async authenticateUser(rawHandle, password) { + const handle = rawHandle.trim().toLowerCase() + const row = (await pool.query<{ id: string; handle: string; display_name: string; avatar_url: string | null; password_hash: string }>( + 'SELECT id,handle,display_name,avatar_url,password_hash FROM osint.users WHERE handle=$1', [handle])).rows[0] + if (!row || !verifyPassword(password, row.password_hash)) return null + return toDto(row) + }, + + async getUser(id) { + const row = (await pool.query<{ id: string; handle: string; display_name: string; avatar_url: string | null }>( + 'SELECT id,handle,display_name,avatar_url FROM osint.users WHERE id=$1', [id])).rows[0] + return row ? toDto(row) : null + }, + } +}