Files
electric-cherry/src/lib/server/auth/guard.ts
T

236 lines
8.5 KiB
TypeScript

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';
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',
'/agent/manifest.json.sig'
].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 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 && 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
});
};