GUPI-issued local auth (path A): osint.users (handle + scrypt password_hash + display_name + avatar_url; external_id reserved for a later glitch.university key-exchange). POST /api/auth/register|login set the auth_token cookie via a new signPlayerToken (sub=user id, role=player); /logout and /me added. Because playthroughs already bind to resolveUserId, two players each "Begin" and stay fully isolated. Verification stays issuer-agnostic for the external swap later. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
61 lines
3.1 KiB
TypeScript
61 lines
3.1 KiB
TypeScript
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<UserDto | null>
|
||
getUser(id: string): Promise<UserDto | null>
|
||
}
|
||
|
||
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
|
||
},
|
||
}
|
||
}
|