Add Prettier and ESLint tooling (Refs #77)
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
import type { UserRole } from '../../auth.js';
|
||||
|
||||
export type RoutePermission = 'public' | 'machine' | 'authenticated' | 'viewer' | 'operator' | 'admin';
|
||||
export type RoutePermission =
|
||||
'public' | 'machine' | 'authenticated' | 'viewer' | 'operator' | 'admin';
|
||||
|
||||
const within = (route: string, base: string): boolean => route === base || route.startsWith(base + '/');
|
||||
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.
|
||||
@@ -15,32 +17,63 @@ export function routePermission(routeId: string | null, method: string): RoutePe
|
||||
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 (
|
||||
!routeId.startsWith('/') ||
|
||||
// eslint-disable-next-line no-control-regex -- Reject control characters in resolved route IDs.
|
||||
/[%\\?#\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 === '/api/leaderboard' && reading) return 'public';
|
||||
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 ((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/settings') || within(routeId, '/api/auth')
|
||||
|| within(routeId, '/api/server-keys') || within(routeId, '/server-keys') || within(routeId, '/keys')) return 'admin';
|
||||
if (
|
||||
within(routeId, '/api/users') ||
|
||||
within(routeId, '/settings') ||
|
||||
within(routeId, '/api/settings') ||
|
||||
within(routeId, '/api/auth') ||
|
||||
within(routeId, '/api/server-keys') ||
|
||||
within(routeId, '/server-keys') ||
|
||||
within(routeId, '/keys')
|
||||
)
|
||||
return 'admin';
|
||||
|
||||
if (!reading && ['/api/hosts', '/api/game-files'].some(base => within(routeId, base))) return 'admin';
|
||||
if (!reading && ['/api/hosts', '/api/game-files'].some((base) => within(routeId, base)))
|
||||
return 'admin';
|
||||
if (routeId === '/api/provisioning/[id]/override') 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';
|
||||
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/betas/[id]/revisions/[revision]/files/[scriptId]'
|
||||
|| routeId === '/api/servers/[name]/logs/[file]' || routeId === '/api/servers/[name]/telemetry-requests'
|
||||
|| routeId === '/api/servers/[name]/rcon-diagnostics') return 'operator';
|
||||
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/betas/[id]/revisions/[revision]/files/[scriptId]' ||
|
||||
routeId === '/api/servers/[name]/logs/[file]' ||
|
||||
routeId === '/api/servers/[name]/telemetry-requests' ||
|
||||
routeId === '/api/servers/[name]/rcon-diagnostics'
|
||||
)
|
||||
return 'operator';
|
||||
|
||||
return reading ? 'viewer' : 'operator';
|
||||
}
|
||||
@@ -49,7 +82,8 @@ export function routePermission(routeId: string | null, method: string): RoutePe
|
||||
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 === 'public' || permission === 'authenticated' || permission === 'viewer')
|
||||
return true;
|
||||
if (permission === 'operator') return role === 'operator' || role === 'admin';
|
||||
return permission === 'admin' && role === 'admin';
|
||||
}
|
||||
|
||||
+174
-52
@@ -1,112 +1,234 @@
|
||||
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 {
|
||||
authBody,
|
||||
authError,
|
||||
requireSameOrigin,
|
||||
requireUserTransport,
|
||||
sessionCookie
|
||||
} from './http.js';
|
||||
import { roleAllows, routePermission, type RoutePermission } from './access.js';
|
||||
import { withRequestUsername } from './request-context.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/ec-agent-update.py','/agent/install.sh','/agent/manifest.json'].includes(path));
|
||||
return (
|
||||
routeId === null &&
|
||||
['GET', 'HEAD'].includes(method) &&
|
||||
(path.startsWith('/_app/') ||
|
||||
path === '/favicon.svg' ||
|
||||
[
|
||||
'/agent/ec-agent.py',
|
||||
'/agent/ec-agent-update.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 {
|
||||
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 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();
|
||||
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; }
|
||||
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); } }
|
||||
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(); }
|
||||
cancel() {
|
||||
closed = true;
|
||||
cleanup();
|
||||
return reader.cancel();
|
||||
}
|
||||
});
|
||||
return new Response(stream, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers
|
||||
});
|
||||
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;
|
||||
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 (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; }
|
||||
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;
|
||||
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 (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 (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);
|
||||
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??'')) {
|
||||
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});
|
||||
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); }
|
||||
} 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');
|
||||
} 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 withRequestUsername(session?.user.username ?? null, () => 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 && !['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);
|
||||
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});
|
||||
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
|
||||
});
|
||||
};
|
||||
|
||||
+76
-22
@@ -2,48 +2,102 @@ 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 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.');
|
||||
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') {
|
||||
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' } });
|
||||
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);
|
||||
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;
|
||||
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;
|
||||
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); }
|
||||
if (length > maxBytes) {
|
||||
void reader.cancel().catch(() => {});
|
||||
throw new AuthError('Request is too large.', 413);
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
} finally { reader.releaseLock(); }
|
||||
} 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.');
|
||||
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'} });
|
||||
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'}:{})} });
|
||||
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' } : {})
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
+342
-106
@@ -8,179 +8,415 @@ 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 };
|
||||
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); }
|
||||
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() };
|
||||
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 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.');
|
||||
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.');
|
||||
const normalized = value.toLowerCase();
|
||||
if (normalized === systemUsername.toLowerCase()) throw new AuthError(`${systemUsername} is reserved for system actions. Choose another username.`);
|
||||
if (normalized === systemUsername.toLowerCase())
|
||||
throw new AuthError(
|
||||
`${systemUsername} is reserved for system actions. Choose another username.`
|
||||
);
|
||||
return normalized;
|
||||
}
|
||||
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.');
|
||||
if (
|
||||
typeof name !== 'string' ||
|
||||
!name.trim() ||
|
||||
name.length > 100 ||
|
||||
// eslint-disable-next-line no-control-regex -- Reject control characters in display names.
|
||||
/[\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.');
|
||||
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(?,?,?,?,?)')
|
||||
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();
|
||||
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 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)
|
||||
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();
|
||||
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);
|
||||
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> {
|
||||
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);
|
||||
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();
|
||||
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() : '';
|
||||
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 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();
|
||||
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);
|
||||
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);
|
||||
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 {
|
||||
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 {
|
||||
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;
|
||||
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);
|
||||
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 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);
|
||||
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);
|
||||
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);
|
||||
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> {
|
||||
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 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();
|
||||
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> {
|
||||
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.');
|
||||
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();
|
||||
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> {
|
||||
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 (
|
||||
!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();
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -15,12 +15,14 @@ export function validatePassword(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.'));
|
||||
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);
|
||||
if (error) reject(error);
|
||||
else resolve(result);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -37,9 +39,17 @@ export async function hashPassword(password) {
|
||||
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]);
|
||||
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;
|
||||
return (
|
||||
timingSafeEqual(result, Buffer.from(valid ? parts[5] : '0'.repeat(128), 'hex')) && !!valid
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,11 +3,17 @@ import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
const requester = new AsyncLocalStorage<{ username: string | null; active: boolean }>();
|
||||
|
||||
/** Use only the authenticated session, and stop attribution when the request finishes. */
|
||||
export async function withRequestUsername<T>(username: string | null, work: () => T | Promise<T>): Promise<T> {
|
||||
export async function withRequestUsername<T>(
|
||||
username: string | null,
|
||||
work: () => T | Promise<T>
|
||||
): Promise<T> {
|
||||
const context = { username, active: true };
|
||||
return requester.run(context, async () => {
|
||||
try { return await work(); }
|
||||
finally { context.active = false; }
|
||||
try {
|
||||
return await work();
|
||||
} finally {
|
||||
context.active = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,24 +1,32 @@
|
||||
import { createHash, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
export type TelemetryAuthentication =
|
||||
| { ok: true }
|
||||
| { ok: false; status: 401 | 503; error: string };
|
||||
{ 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 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.' };
|
||||
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.' });
|
||||
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.
|
||||
@@ -28,7 +36,8 @@ export function authenticateTelemetry(request: Request): TelemetryAuthentication
|
||||
const supplied = bearer ?? custom;
|
||||
if (!supplied) return denied();
|
||||
const suppliedHash = hash(supplied);
|
||||
if (bearer !== undefined && custom !== null && !timingSafeEqual(suppliedHash, hash(custom))) return denied();
|
||||
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