Add native host management and role-based operator authentication
Introduce an outbound Python host agent, Debian installer, durable remote operations, immutable game-file libraries, host-aware deployments and ports, and occupancy-safe campaigns and draining. Native hosts do not require Komodo. Add administrator, operator and viewer accounts, protected management routes, secure sessions, password recovery, audit history and separate telemetry tokens. Document first-admin setup, reporter migration and live-host acceptance checks. Validation: 1,343 TypeScript tests and 39 agent tests passed, along with type checking, the production build, compiled SSR/API smoke tests and administrator recovery. Real Debian/Docker T5/T6 acceptance remains unrun.
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
import type { UserRole } from '../../auth.js';
|
||||
|
||||
export type RoutePermission = 'public' | 'machine' | 'authenticated' | 'viewer' | 'operator' | 'admin';
|
||||
|
||||
const within = (route: string, base: string): boolean => route === base || route.startsWith(base + '/');
|
||||
const readMethod = (method: string): boolean => method === 'GET' || method === 'HEAD';
|
||||
|
||||
/** Classify SvelteKit's resolved route ID, never a user-supplied URL pathname.
|
||||
* Route IDs preserve the same policy for page-data requests and encoded URLs.
|
||||
* Machine endpoints enforce their own host/enrollment/telemetry credentials.
|
||||
*/
|
||||
export function routePermission(routeId: string | null, method: string): RoutePermission {
|
||||
method = method.toUpperCase();
|
||||
const reading = readMethod(method);
|
||||
if (routeId === null) return reading ? 'viewer' : 'operator';
|
||||
// These cannot occur in this application's resolved route IDs. Fail closed
|
||||
// if a future caller accidentally supplies a raw or noncanonical pathname.
|
||||
if (!routeId.startsWith('/') || /[%\\?#\x00-\x1f\x7f]/.test(routeId)
|
||||
|| routeId !== '/' && routeId.split('/').slice(1).some(part => !part || part === '.' || part === '..')) return 'admin';
|
||||
|
||||
if (within(routeId, '/api/agent')) return 'machine';
|
||||
if (routeId === '/api/telemetry' && method === 'POST') return 'machine';
|
||||
if ((routeId === '/login' || routeId === '/setup') && reading) return 'public';
|
||||
if ((routeId === '/api/auth/login' || routeId === '/api/auth/setup') && method === 'POST') return 'public';
|
||||
if (routeId === '/account' || ['/api/auth/me', '/api/auth/logout', '/api/auth/password'].includes(routeId)) return 'authenticated';
|
||||
|
||||
if (within(routeId, '/api/users') || within(routeId, '/settings') || within(routeId, '/api/auth')
|
||||
|| within(routeId, '/api/server-keys') || within(routeId, '/server-keys') || within(routeId, '/keys')) return 'admin';
|
||||
|
||||
if (!reading && ['/api/hosts', '/api/settings', '/api/game-files'].some(base => within(routeId, base))) return 'admin';
|
||||
// These endpoints mix operational actions with global policy and schedule
|
||||
// changes. Per-incident/job actions remain available to operators.
|
||||
if (!reading && ['/api/maintenance', '/api/plutonium', '/api/watchdog'].includes(routeId)) return 'admin';
|
||||
|
||||
// Summaries remain visible to viewers; raw configuration, historical file
|
||||
// bytes, and unfiltered diagnostics may contain runtime credentials.
|
||||
if (routeId === '/configs/[id]' || routeId === '/api/configs/[id]' || routeId === '/api/configs/inspect'
|
||||
|| routeId === '/api/campaigns/[id]/file/[fileId]' || routeId === '/api/deployments/[id]/file/[fileId]'
|
||||
|| routeId === '/api/servers/[name]/logs/[file]' || routeId === '/api/servers/[name]/telemetry-requests') return 'operator';
|
||||
|
||||
return reading ? 'viewer' : 'operator';
|
||||
}
|
||||
|
||||
/** A browser role never substitutes for a machine credential. */
|
||||
export function roleAllows(role: UserRole, permission: RoutePermission): boolean {
|
||||
if (!['viewer', 'operator', 'admin'].includes(role)) return false;
|
||||
if (permission === 'machine') return false;
|
||||
if (permission === 'public' || permission === 'authenticated' || permission === 'viewer') return true;
|
||||
if (permission === 'operator') return role === 'operator' || role === 'admin';
|
||||
return permission === 'admin' && role === 'admin';
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { error, json, redirect, type Handle } from '@sveltejs/kit';
|
||||
import { readSession, readSessionHash, recordAudit, setupRequired } from './index.js';
|
||||
import { authBody, authError, requireSameOrigin, requireUserTransport, sessionCookie } from './http.js';
|
||||
import { roleAllows, routePermission, type RoutePermission } from './access.js';
|
||||
|
||||
function publicAsset(routeId: string | null, path: string, method: string) {
|
||||
return routeId === null && ['GET','HEAD'].includes(method) &&
|
||||
(path.startsWith('/_app/') || path === '/favicon.svg' || ['/agent/ec-agent.py','/agent/install.sh','/agent/manifest.json'].includes(path));
|
||||
}
|
||||
|
||||
/** Streaming responses must lose access when the underlying session does. */
|
||||
export function protectEventStream(response: Response, tokenHash: string, permission: RoutePermission, signal: AbortSignal): Response {
|
||||
if (!response.body) return response;
|
||||
const reader = response.body.getReader();
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
let closed = false;
|
||||
let closeStream: () => void = () => {};
|
||||
const cleanup = () => { if (timer) clearInterval(timer); signal.removeEventListener('abort', closeStream); };
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
closeStream = () => {
|
||||
if (closed) return; closed = true; cleanup();
|
||||
void reader.cancel().catch(() => {}); controller.close();
|
||||
};
|
||||
timer = setInterval(() => {
|
||||
try {
|
||||
const current = readSessionHash(tokenHash);
|
||||
if (!current || current.user.mustChangePassword || !roleAllows(current.user.role,permission)) closeStream();
|
||||
} catch { closeStream(); }
|
||||
},15_000);
|
||||
signal.addEventListener('abort',closeStream,{once:true});
|
||||
if (signal.aborted) { closeStream(); return; }
|
||||
},
|
||||
async pull(controller) {
|
||||
try {
|
||||
const next = await reader.read();
|
||||
if (closed) return;
|
||||
if (next.done) { closed=true; cleanup(); controller.close(); }
|
||||
else controller.enqueue(next.value);
|
||||
} catch (cause) { if (!closed) { closed=true; cleanup(); controller.error(cause); } }
|
||||
},
|
||||
cancel() { closed=true; cleanup(); return reader.cancel(); }
|
||||
});
|
||||
return new Response(stream,{status:response.status,statusText:response.statusText,headers:response.headers});
|
||||
}
|
||||
|
||||
export const authenticationHandle: Handle = async ({ event, resolve }) => {
|
||||
event.locals.user = null; event.locals.sessionHash = null;
|
||||
const routeId = event.route.id;
|
||||
const method = event.request.method.toUpperCase();
|
||||
if (publicAsset(routeId,event.url.pathname,method)) return resolve(event);
|
||||
const permission = routePermission(routeId,method);
|
||||
if (!routeId?.startsWith('/api/agent/') && /^Bearer ec_(?:host|enroll)_/i.test(event.request.headers.get('authorization')??'')) {
|
||||
return json({error:'Host credentials only authorize the host agent API.'},{status:403});
|
||||
}
|
||||
if (permission === 'machine') return resolve(event);
|
||||
const api = routeId?.startsWith('/api/') || event.url.pathname.startsWith('/api/');
|
||||
try {
|
||||
requireUserTransport(event.url);
|
||||
if (!['GET','HEAD','OPTIONS'].includes(method)) requireSameOrigin(event.request,event.url);
|
||||
} catch (cause) { if (api) return authError(cause); throw cause; }
|
||||
|
||||
const session = readSession(event.cookies.get(sessionCookie(event.url)));
|
||||
event.locals.user = session?.user??null;
|
||||
event.locals.sessionHash = session?.tokenHash??null;
|
||||
const setup = setupRequired();
|
||||
if (permission !== 'public') {
|
||||
if (!session) {
|
||||
if (api) return json({error:setup?'Complete administrator setup before using the management API.':'Sign in to continue.',setupRequired:setup},{status:401,headers:{'Cache-Control':'no-store'}});
|
||||
redirect(303,setup?'/setup':`/login?next=${encodeURIComponent(event.url.pathname+event.url.search)}`);
|
||||
}
|
||||
if (session.user.mustChangePassword && permission !== 'authenticated') {
|
||||
if (api) return json({error:'Change your temporary password from Account before continuing.',mustChangePassword:true},{status:403,headers:{'Cache-Control':'no-store'}});
|
||||
redirect(303,'/account');
|
||||
}
|
||||
if (!roleAllows(session.user.role,permission)) {
|
||||
const message = permission==='admin'?'Administrator access is required.':'Operator access is required for this action.';
|
||||
if (api) return json({error:message},{status:403,headers:{'Cache-Control':'no-store'}});
|
||||
error(403,message);
|
||||
}
|
||||
// Launch APIs can also save credentials. Operators may launch with saved
|
||||
// keys, but only administrators may introduce or replace server keys.
|
||||
if (session.user.role === 'operator' && method === 'POST' && ['/api/servers','/api/deployment-collections'].includes(routeId??'')) {
|
||||
try {
|
||||
const body = await authBody(event.request.clone(),1024*1024);
|
||||
if (body.plutoKey || (body.keys && typeof body.keys==='object' && Object.values(body.keys).some(Boolean))) {
|
||||
return json({error:'An administrator must save server keys before an operator launches these servers.'},{status:403});
|
||||
}
|
||||
} catch (cause) { return authError(cause); }
|
||||
}
|
||||
} else if (['/login','/setup'].includes(routeId??'')) {
|
||||
if (session) redirect(303,session.user.mustChangePassword?'/account':'/');
|
||||
if (routeId==='/setup'&&!setup) redirect(303,'/login');
|
||||
if (routeId==='/login'&&setup) redirect(303,'/setup');
|
||||
}
|
||||
|
||||
let response = await resolve(event);
|
||||
if (session && !['GET','HEAD','OPTIONS'].includes(method) && response.status < 400) {
|
||||
recordAudit(session.user.id,'request',routeId,{method,status:response.status,path:event.url.pathname});
|
||||
}
|
||||
if (session && response.headers.get('content-type')?.startsWith('text/event-stream')) {
|
||||
response = protectEventStream(response,session.tokenHash,permission,event.request.signal);
|
||||
}
|
||||
const headers = new Headers(response.headers);
|
||||
headers.set('Cache-Control','no-store');
|
||||
headers.set('Vary',[headers.get('Vary'),'Cookie'].filter(Boolean).join(', '));
|
||||
headers.set('Referrer-Policy','same-origin');
|
||||
headers.set('X-Content-Type-Options','nosniff');
|
||||
headers.set('X-Frame-Options','DENY');
|
||||
return new Response(response.body,{status:response.status,statusText:response.statusText,headers});
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { error, isHttpError, json, type Cookies } from '@sveltejs/kit';
|
||||
import { AuthError, SESSION_COOKIE, type AuthResult } from './index.js';
|
||||
import type { AuthUser } from '../../auth.js';
|
||||
|
||||
export function sessionCookie(url: URL): string { return url.protocol === 'https:' ? SESSION_COOKIE : 'ec_session_dev'; }
|
||||
export function requireUserTransport(url: URL): void {
|
||||
if (url.protocol === 'https:') return;
|
||||
if (process.env.EC_AUTH_ALLOW_HTTP === 'true' && ['localhost','127.0.0.1','[::1]','::1'].includes(url.hostname)) return;
|
||||
error(426, 'Operator login requires HTTPS. For local development only, set EC_AUTH_ALLOW_HTTP=true and use localhost.');
|
||||
}
|
||||
export function requireSameOrigin(request: Request, url: URL): void {
|
||||
if (request.headers.get('origin') !== url.origin || request.headers.get('sec-fetch-site') === 'cross-site') {
|
||||
error(403, 'This action requires a same-origin request.');
|
||||
}
|
||||
}
|
||||
export function sessionResponse(result: AuthResult, cookies: Cookies, url: URL) {
|
||||
cookies.set(sessionCookie(url), result.session.token, { path: '/', httpOnly: true, secure: url.protocol === 'https:',
|
||||
sameSite: 'lax', expires: new Date(result.session.expiresAt) });
|
||||
return json({ user: result.user }, { headers: { 'Cache-Control':'no-store' } });
|
||||
}
|
||||
export function requireUser(user: AuthUser | null): AuthUser {
|
||||
if (!user) throw new AuthError('Sign in to continue.',401);
|
||||
return user;
|
||||
}
|
||||
export async function authBody(request: Request, maxBytes = 8192): Promise<Record<string, unknown>> {
|
||||
if (!request.headers.get('content-type')?.toLowerCase().startsWith('application/json')) throw new AuthError('Send a JSON request.',415);
|
||||
if (Number(request.headers.get('content-length')) > maxBytes) throw new AuthError('Request is too large.',413);
|
||||
const reader = request.body?.getReader(); if (!reader) throw new AuthError('A JSON object is required.');
|
||||
const chunks: Uint8Array[] = []; let length = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read(); if (done) break;
|
||||
length += value.byteLength;
|
||||
if (length > maxBytes) { void reader.cancel().catch(() => {}); throw new AuthError('Request is too large.',413); }
|
||||
chunks.push(value);
|
||||
}
|
||||
} finally { reader.releaseLock(); }
|
||||
let body: unknown;
|
||||
try { body = JSON.parse(Buffer.concat(chunks,length).toString('utf8')); } catch { throw new AuthError('Invalid JSON.'); }
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new AuthError('A JSON object is required.');
|
||||
return body as Record<string, unknown>;
|
||||
}
|
||||
export function authError(cause: unknown): Response {
|
||||
if (isHttpError(cause)) return json({ error: cause.body.message }, { status:cause.status, headers:{'Cache-Control':'no-store'} });
|
||||
const message = cause instanceof Error ? cause.message : 'Authentication request failed.';
|
||||
const status = cause instanceof AuthError ? cause.status : message.includes('busy') ? 429 : message.startsWith('Use a password') ? 400 : 500;
|
||||
return json({ error: status === 500 ? 'The authentication request could not be completed.' : message },
|
||||
{ status, headers:{'Cache-Control':'no-store',...(status===429?{'Retry-After':'900'}:{})} });
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
||||
import { db } from '../db/index.js';
|
||||
import { ensureAuthSchema } from './schema.js';
|
||||
import { hashPassword, verifyPassword } from './passwords.js';
|
||||
import type { AuthUser, UserRole } from '../../auth.js';
|
||||
|
||||
export const SESSION_COOKIE = '__Host-ec_session';
|
||||
export const SESSION_LIFETIME_MS = 12 * 60 * 60_000;
|
||||
export const SESSION_IDLE_MS = 30 * 60_000;
|
||||
const digest = (value: string) => createHash('sha256').update(value).digest('hex');
|
||||
function database() { const handle = db(); ensureAuthSchema(handle); return handle; }
|
||||
type UserRow = { id: string; username: string; display_name: string; password_hash: string; role: UserRole; disabled: number;
|
||||
must_change_password: number; created_at: number; updated_at: number; last_login_at: number | null };
|
||||
export class AuthError extends Error {
|
||||
constructor(message: string, public status = 400) { super(message); }
|
||||
}
|
||||
function present(row: UserRow): AuthUser {
|
||||
return { id: row.id, username: row.username, displayName: row.display_name, role: row.role, disabled: !!row.disabled,
|
||||
mustChangePassword: !!row.must_change_password, createdAt: new Date(row.created_at).toISOString(),
|
||||
updatedAt: new Date(row.updated_at).toISOString(), lastLoginAt: row.last_login_at === null ? null : new Date(row.last_login_at).toISOString() };
|
||||
}
|
||||
function userRow(id: string) { return database().prepare('SELECT * FROM auth_users WHERE id=?').get(id) as UserRow | undefined; }
|
||||
export function getUser(id: string): AuthUser | null { const row = userRow(id); return row ? present(row) : null; }
|
||||
export function listUsers(): AuthUser[] { return (database().prepare('SELECT * FROM auth_users ORDER BY username').all() as UserRow[]).map(present); }
|
||||
export function setupRequired(): boolean { return !(database().prepare('SELECT 1 FROM auth_users LIMIT 1').get()); }
|
||||
function username(value: unknown): string {
|
||||
if (typeof value !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{2,63}$/.test(value)) throw new AuthError('Usernames need 3–64 letters, numbers, dots, underscores or hyphens.');
|
||||
return value.toLowerCase();
|
||||
}
|
||||
function displayName(value: unknown, fallback: string): string {
|
||||
const name = value === undefined ? fallback : value;
|
||||
if (typeof name !== 'string' || !name.trim() || name.length > 100 || /[\x00-\x1f\x7f]/.test(name)) throw new AuthError('Display name must contain 1–100 printable characters.');
|
||||
return name.trim();
|
||||
}
|
||||
function role(value: unknown): UserRole {
|
||||
if (!['admin','operator','viewer'].includes(value as string)) throw new AuthError('Choose administrator, operator or viewer.');
|
||||
return value as UserRole;
|
||||
}
|
||||
export function recordAudit(actorId: string | null, action: string, targetId: string | null = null, details: Record<string, unknown> = {}): void {
|
||||
database().prepare('INSERT INTO auth_audit(actor_id,action,target_id,details,created_at) VALUES(?,?,?,?,?)')
|
||||
.run(actorId, action, targetId, JSON.stringify(details), Date.now());
|
||||
}
|
||||
export function listAuthAudit() {
|
||||
return database().prepare(`SELECT a.id,a.actor_id AS actorId,u.username AS actor,a.action,a.target_id AS targetId,a.details,a.created_at AS createdAt
|
||||
FROM auth_audit a LEFT JOIN auth_users u ON u.id=a.actor_id ORDER BY a.id DESC LIMIT 200`).all();
|
||||
}
|
||||
|
||||
/** Persistent fixed windows are consumed before hashing, including failed setup. */
|
||||
export function consumeAuthAttempt(clientAddress: string, account: string, kind = 'login'): void {
|
||||
const now = Date.now(); const windowMs = 15 * 60_000;
|
||||
const ipKey = digest(`${kind}:ip:${clientAddress}`);
|
||||
const accountKey = digest(`${kind}:account:${account.toLowerCase()}`);
|
||||
database().transaction(() => {
|
||||
database().prepare('DELETE FROM auth_rate_limits WHERE window_start < ?').run(now - 24 * 60 * 60_000);
|
||||
for (const [key, limit] of [[ipKey, 30], [accountKey, 10]] as const) {
|
||||
const previous = database().prepare('SELECT window_start,attempts FROM auth_rate_limits WHERE key=?').get(key) as { window_start: number; attempts: number } | undefined;
|
||||
if (previous && previous.window_start > now - windowMs && previous.attempts >= limit) throw new AuthError('Too many attempts. Try again in 15 minutes.', 429);
|
||||
}
|
||||
for (const key of [ipKey, accountKey]) database().prepare(`INSERT INTO auth_rate_limits(key,window_start,attempts) VALUES(?,?,1)
|
||||
ON CONFLICT(key) DO UPDATE SET attempts=CASE WHEN window_start <= ? THEN 1 ELSE attempts+1 END,
|
||||
window_start=CASE WHEN window_start <= ? THEN excluded.window_start ELSE window_start END`).run(key, now, now - windowMs, now - windowMs);
|
||||
}).immediate();
|
||||
}
|
||||
|
||||
function insertUser(input: { username: string; displayName: string; role: UserRole; passwordHash: string; mustChangePassword: boolean }): AuthUser {
|
||||
if (database().prepare('SELECT 1 FROM auth_users WHERE username=?').get(input.username)) throw new AuthError('That username is already in use.', 409);
|
||||
const id = randomUUID(); const now = Date.now();
|
||||
database().prepare(`INSERT INTO auth_users(id,username,display_name,password_hash,role,must_change_password,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)`)
|
||||
.run(id,input.username,input.displayName,input.passwordHash,input.role,Number(input.mustChangePassword),now,now);
|
||||
return getUser(id)!;
|
||||
}
|
||||
export interface AuthResult { user: AuthUser; session: { token: string; expiresAt: number } }
|
||||
export async function bootstrapAdmin(input: Record<string, unknown>, clientAddress: string): Promise<AuthResult> {
|
||||
consumeAuthAttempt(clientAddress, 'bootstrap', 'setup');
|
||||
if (!setupRequired()) throw new AuthError('Initial setup is already complete.', 409);
|
||||
const expected = process.env.EC_SETUP_TOKEN;
|
||||
if (!expected || expected.length < 32) throw new AuthError('The controller owner must configure EC_SETUP_TOKEN with at least 32 characters before setup.', 503);
|
||||
if (typeof input.token !== 'string' || !timingSafeEqual(Buffer.from(digest(input.token), 'hex'), Buffer.from(digest(expected), 'hex'))) throw new AuthError('Invalid setup token.', 401);
|
||||
const name = username(input.username); const label = displayName(input.displayName, name);
|
||||
const passwordHash = await hashPassword(input.password);
|
||||
return database().transaction(() => {
|
||||
if (!setupRequired()) throw new AuthError('Initial setup is already complete.', 409);
|
||||
const user = insertUser({ username: name, displayName: label, passwordHash, role: 'admin', mustChangePassword: false });
|
||||
recordAudit(user.id, 'setup', user.id);
|
||||
return { user, session: createSession(user.id) };
|
||||
}).immediate();
|
||||
}
|
||||
export async function authenticatePassword(input: Record<string, unknown>, clientAddress: string): Promise<AuthResult> {
|
||||
const name = typeof input.username === 'string' ? input.username.slice(0,64).toLowerCase() : '';
|
||||
consumeAuthAttempt(clientAddress, name);
|
||||
const row = database().prepare('SELECT * FROM auth_users WHERE username=?').get(name) as UserRow | undefined;
|
||||
const valid = await verifyPassword(input.password, row?.password_hash ?? null);
|
||||
const result = database().transaction(() => {
|
||||
const current = row ? userRow(row.id) : undefined;
|
||||
if (!valid || !current || current.disabled || current.password_hash !== row?.password_hash) {
|
||||
recordAudit(null, 'login.failed'); return null;
|
||||
}
|
||||
database().prepare('UPDATE auth_users SET last_login_at=? WHERE id=?').run(Date.now(), current.id);
|
||||
recordAudit(current.id, 'login', current.id);
|
||||
return { user: getUser(current.id)!, session: createSession(current.id) };
|
||||
}).immediate();
|
||||
if (!result) throw new AuthError('Invalid username or password.', 401);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function createSession(userId: string): { token: string; expiresAt: number } {
|
||||
const now = Date.now(); const user = userRow(userId);
|
||||
if (!user || user.disabled) throw new AuthError('Account is unavailable.', 401);
|
||||
const token = randomBytes(32).toString('base64url'); const expiresAt = now + SESSION_LIFETIME_MS;
|
||||
database().prepare('DELETE FROM auth_sessions WHERE expires_at <= ? OR last_seen_at <= ?').run(now,now-SESSION_IDLE_MS);
|
||||
database().prepare('INSERT INTO auth_sessions(token_hash,user_id,created_at,expires_at,last_seen_at) VALUES(?,?,?,?,?)').run(digest(token),userId,now,expiresAt,now);
|
||||
return { token, expiresAt };
|
||||
}
|
||||
export function readSession(token: string | undefined, touch = true): { user: AuthUser; tokenHash: string; expiresAt: number } | null {
|
||||
if (!token || !/^[A-Za-z0-9_-]{43}$/.test(token)) return null;
|
||||
return readSessionHash(digest(token), touch);
|
||||
}
|
||||
export function readSessionHash(tokenHash: string, touch = false): { user: AuthUser; tokenHash: string; expiresAt: number } | null {
|
||||
const now = Date.now();
|
||||
const row = database().prepare(`SELECT u.*,s.expires_at,s.last_seen_at FROM auth_sessions s JOIN auth_users u ON u.id=s.user_id
|
||||
WHERE s.token_hash=? AND s.expires_at>? AND s.last_seen_at>? AND u.disabled=0`).get(tokenHash,now,now-SESSION_IDLE_MS) as (UserRow & { expires_at: number; last_seen_at: number }) | undefined;
|
||||
if (!row) return null;
|
||||
if (touch && row.last_seen_at < now-60_000) database().prepare('UPDATE auth_sessions SET last_seen_at=? WHERE token_hash=?').run(now,tokenHash);
|
||||
return { user: present(row), tokenHash, expiresAt: row.expires_at };
|
||||
}
|
||||
export function revokeSession(token: string | undefined): void { if (token) database().prepare('DELETE FROM auth_sessions WHERE token_hash=?').run(digest(token)); }
|
||||
export function revokeUserSessions(actorId: string, userId: string): void {
|
||||
database().transaction(() => {
|
||||
requireAdministrator(actorId);
|
||||
if (!userRow(userId)) throw new AuthError('User not found.',404);
|
||||
database().prepare('DELETE FROM auth_sessions WHERE user_id=?').run(userId);
|
||||
recordAudit(actorId,'sessions.revoked',userId);
|
||||
})();
|
||||
}
|
||||
function requireAdministrator(actorId: string) {
|
||||
const actor = userRow(actorId);
|
||||
if (!actor || actor.disabled || actor.role !== 'admin' || actor.must_change_password) throw new AuthError('Administrator access is required.',403);
|
||||
}
|
||||
export async function createUser(actorId: string, input: Record<string, unknown>): Promise<AuthUser> {
|
||||
requireAdministrator(actorId);
|
||||
const name = username(input.username), label = displayName(input.displayName,name), userRole = role(input.role);
|
||||
const passwordHash = await hashPassword(input.password);
|
||||
return database().transaction(() => {
|
||||
requireAdministrator(actorId);
|
||||
const user = insertUser({ username:name,displayName:label,role:userRole,passwordHash,mustChangePassword:true });
|
||||
recordAudit(actorId,'user.created',user.id,{role:userRole}); return user;
|
||||
}).immediate();
|
||||
}
|
||||
export async function updateUser(actorId: string, id: string, input: Record<string, unknown>): Promise<AuthUser> {
|
||||
requireAdministrator(actorId);
|
||||
if (input.disabled !== undefined && typeof input.disabled !== 'boolean') throw new AuthError('Disabled must be true or false.');
|
||||
if (input.password !== undefined && actorId === id) throw new AuthError('Change your own password from Account using your current password.');
|
||||
const passwordHash = input.password === undefined ? null : await hashPassword(input.password);
|
||||
return database().transaction(() => {
|
||||
requireAdministrator(actorId);
|
||||
const current = userRow(id); if (!current) throw new AuthError('User not found.',404);
|
||||
const nextRole = input.role === undefined ? current.role : role(input.role);
|
||||
const disabled = input.disabled === undefined ? current.disabled : Number(input.disabled);
|
||||
const label = displayName(input.displayName,current.display_name);
|
||||
if (current.role === 'admin' && !current.disabled && (nextRole !== 'admin' || disabled)) {
|
||||
const other = database().prepare("SELECT 1 FROM auth_users WHERE role='admin' AND disabled=0 AND id!=? LIMIT 1").get(id);
|
||||
if (!other) throw new AuthError('Keep at least one enabled administrator.',409);
|
||||
}
|
||||
database().prepare(`UPDATE auth_users SET display_name=?,role=?,disabled=?,password_hash=?,must_change_password=?,updated_at=? WHERE id=?`)
|
||||
.run(label,nextRole,disabled,passwordHash??current.password_hash,passwordHash?1:current.must_change_password,Date.now(),id);
|
||||
if (passwordHash || disabled !== current.disabled || nextRole !== current.role) database().prepare('DELETE FROM auth_sessions WHERE user_id=?').run(id);
|
||||
recordAudit(actorId,'user.updated',id,{role:nextRole,disabled:!!disabled,passwordReset:!!passwordHash});
|
||||
return getUser(id)!;
|
||||
}).immediate();
|
||||
}
|
||||
export async function changePassword(userId: string, currentPassword: unknown, newPassword: unknown): Promise<AuthResult> {
|
||||
const original = userRow(userId);
|
||||
if (!original || original.disabled || !await verifyPassword(currentPassword,original.password_hash)) throw new AuthError('Current password is incorrect.',400);
|
||||
if (currentPassword === newPassword) throw new AuthError('Choose a different password.');
|
||||
const passwordHash = await hashPassword(newPassword);
|
||||
return database().transaction(() => {
|
||||
const current = userRow(userId);
|
||||
if (!current || current.disabled || current.password_hash !== original.password_hash) throw new AuthError('Account changed during password verification. Sign in again.',409);
|
||||
database().prepare('UPDATE auth_users SET password_hash=?,must_change_password=0,updated_at=? WHERE id=?').run(passwordHash,Date.now(),userId);
|
||||
database().prepare('DELETE FROM auth_sessions WHERE user_id=?').run(userId);
|
||||
recordAudit(userId,'password.changed',userId);
|
||||
return { user: getUser(userId)!, session: createSession(userId) };
|
||||
}).immediate();
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
// OWASP's scrypt baseline: 128 MiB, r=8, p=1. Bound concurrent work so
|
||||
// unauthenticated password attempts cannot allocate unlimited memory.
|
||||
const parameters = { N: 131072, r: 8, p: 1, maxmem: 256 * 1024 * 1024 };
|
||||
let active = 0;
|
||||
|
||||
/** @param {unknown} password */
|
||||
export function validatePassword(password) {
|
||||
if (typeof password !== 'string' || [...password].length < 15 || [...password].length > 128) {
|
||||
throw new Error('Use a password or passphrase between 15 and 128 characters.');
|
||||
}
|
||||
return password;
|
||||
}
|
||||
|
||||
/** @param {string} password @param {string} salt @returns {Promise<Buffer>} */
|
||||
function derive(password, salt) {
|
||||
if (active >= 2) return Promise.reject(new Error('Password service is busy. Try again shortly.'));
|
||||
active++;
|
||||
return new Promise((resolve, reject) => {
|
||||
scrypt(password, salt, 64, parameters, (error, result) => {
|
||||
active--;
|
||||
if (error) reject(error); else resolve(result);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** @param {unknown} password */
|
||||
export async function hashPassword(password) {
|
||||
const validated = validatePassword(password);
|
||||
const salt = randomBytes(32).toString('hex');
|
||||
const key = await derive(validated, salt);
|
||||
return `scrypt$131072$8$1$${salt}$${key.toString('hex')}`;
|
||||
}
|
||||
|
||||
/** @param {unknown} password @param {string | null} encoded */
|
||||
export async function verifyPassword(password, encoded) {
|
||||
if (typeof password !== 'string' || [...password].length > 128) return false;
|
||||
const parts = encoded?.split('$');
|
||||
const valid = parts?.length === 6 && parts[0] === 'scrypt' && parts[1] === '131072' && parts[2] === '8' && parts[3] === '1'
|
||||
&& /^[a-f0-9]{64}$/.test(parts[4]) && /^[a-f0-9]{128}$/.test(parts[5]);
|
||||
// Missing accounts perform the same expensive operation as real accounts.
|
||||
const result = await derive(password, valid ? parts[4] : '0'.repeat(64));
|
||||
return timingSafeEqual(result, Buffer.from(valid ? parts[5] : '0'.repeat(128), 'hex')) && !!valid;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
const initialized = new WeakSet<Database.Database>();
|
||||
export function ensureAuthSchema(handle: Database.Database): void {
|
||||
if (initialized.has(handle)) return;
|
||||
handle.exec(`
|
||||
CREATE TABLE IF NOT EXISTS auth_users (
|
||||
id TEXT PRIMARY KEY, username TEXT NOT NULL COLLATE NOCASE UNIQUE,
|
||||
display_name TEXT NOT NULL, password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK(role IN ('admin','operator','viewer')),
|
||||
disabled INTEGER NOT NULL DEFAULT 0, must_change_password INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, last_login_at INTEGER
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS auth_sessions (
|
||||
token_hash TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES auth_users(id) ON DELETE CASCADE,
|
||||
created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, last_seen_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS auth_sessions_user ON auth_sessions(user_id);
|
||||
CREATE TABLE IF NOT EXISTS auth_rate_limits (
|
||||
key TEXT PRIMARY KEY, window_start INTEGER NOT NULL, attempts INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS auth_audit (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, actor_id TEXT, action TEXT NOT NULL,
|
||||
target_id TEXT, details TEXT NOT NULL, created_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
initialized.add(handle);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { createHash, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
export type TelemetryAuthentication =
|
||||
| { ok: true }
|
||||
| { ok: false; status: 401 | 503; error: string };
|
||||
|
||||
const tokenPattern = /^[A-Za-z0-9._~+\/-]+=*$/;
|
||||
const validToken = (value: string): boolean => value.length >= 32 && value.length <= 512 && tokenPattern.test(value);
|
||||
const hash = (value: string): Buffer => createHash('sha256').update(value, 'utf8').digest();
|
||||
|
||||
/** Machine telemetry has its own credential; browser sessions never authorize it. */
|
||||
export function authenticateTelemetry(request: Request): TelemetryAuthentication {
|
||||
const expected = process.env.EC_TELEMETRY_TOKEN ?? '';
|
||||
if (!validToken(expected)) {
|
||||
return { ok: false, status: 503, error: 'Game telemetry authentication is not configured. Set EC_TELEMETRY_TOKEN to a secret of at least 32 characters.' };
|
||||
}
|
||||
|
||||
const authorization = request.headers.get('authorization');
|
||||
const custom = request.headers.get('x-ec-telemetry-token');
|
||||
const bearer = authorization?.match(/^Bearer ([A-Za-z0-9._~+\/-]+=*)$/i)?.[1];
|
||||
const denied = (): TelemetryAuthentication => ({ ok: false, status: 401, error: 'A valid game telemetry token is required.' });
|
||||
|
||||
// Reject malformed/ambiguous credentials rather than falling back to a
|
||||
// second header. A shared secret must never come from a URL, cookie or body.
|
||||
if (authorization !== null && !bearer) return denied();
|
||||
if (custom !== null && !validToken(custom)) return denied();
|
||||
if (bearer !== undefined && !validToken(bearer)) return denied();
|
||||
const supplied = bearer ?? custom;
|
||||
if (!supplied) return denied();
|
||||
const suppliedHash = hash(supplied);
|
||||
if (bearer !== undefined && custom !== null && !timingSafeEqual(suppliedHash, hash(custom))) return denied();
|
||||
if (!timingSafeEqual(suppliedHash, hash(expected))) return denied();
|
||||
return { ok: true };
|
||||
}
|
||||
Reference in New Issue
Block a user