diff --git a/src/lib/server/admin/session.ts b/src/lib/server/admin/session.ts new file mode 100644 index 0000000..fd1f1a4 --- /dev/null +++ b/src/lib/server/admin/session.ts @@ -0,0 +1,134 @@ +import { dev } from '$app/environment' +import type { Cookies } from '@sveltejs/kit' +import { createHmac, timingSafeEqual } from 'node:crypto' +import type { SessionUser } from '$lib/types/session' + +const SESSION_COOKIE_NAME = 'admin_session' +const SESSION_TTL_SECONDS = 60 * 60 * 12 // 12 hours + +interface SessionPayload { + username: string + exp: number +} + +function adminPassword(): string { + return process.env.ADMIN_PASSWORD ?? 'changeme' +} + +function sessionSecret(): string { + return process.env.ADMIN_SESSION_SECRET ?? process.env.ADMIN_PASSWORD ?? 'changeme' +} + +function signPayload(payload: string): Buffer { + const hmac = createHmac('sha256', sessionSecret()) + hmac.update(payload) + hmac.update(adminPassword()) + return hmac.digest() +} + +function buildToken(payload: SessionPayload): string { + const payloadStr = JSON.stringify(payload) + const signature = signPayload(payloadStr).toString('base64url') + return `${Buffer.from(payloadStr, 'utf8').toString('base64url')}.${signature}` +} + +function parseToken(token: string): SessionPayload | null { + const [encodedPayload, encodedSignature] = token.split('.') + if (!encodedPayload || !encodedSignature) return null + + const payloadStr = Buffer.from(encodedPayload, 'base64url').toString('utf8') + let payload: SessionPayload + try { + payload = JSON.parse(payloadStr) + if (!payload || typeof payload.username !== 'string' || typeof payload.exp !== 'number') { + return null + } + } catch { + return null + } + + const expectedSignature = signPayload(payloadStr) + let providedSignature: Buffer + try { + providedSignature = Buffer.from(encodedSignature, 'base64url') + } catch { + return null + } + + if (expectedSignature.length !== providedSignature.length) { + return null + } + + try { + if (!timingSafeEqual(expectedSignature, providedSignature)) { + return null + } + } catch { + return null + } + + if (Date.now() > payload.exp) { + return null + } + + return payload +} + +export function validateAdminPassword(password: string): SessionUser | null { + const expected = adminPassword() + const providedBuf = Buffer.from(password) + const expectedBuf = Buffer.from(expected) + + if (providedBuf.length !== expectedBuf.length) { + return null + } + + try { + if (!timingSafeEqual(providedBuf, expectedBuf)) { + return null + } + } catch { + return null + } + + return { username: 'admin' } +} + +export function createSessionToken(user: SessionUser): string { + const payload: SessionPayload = { + username: user.username, + exp: Date.now() + SESSION_TTL_SECONDS * 1000 + } + return buildToken(payload) +} + +export function readSessionToken(token: string | undefined): SessionUser | null { + if (!token) return null + const payload = parseToken(token) + if (!payload) return null + return { username: payload.username } +} + +export function setSessionCookie(cookies: Cookies, user: SessionUser) { + const token = createSessionToken(user) + cookies.set(SESSION_COOKIE_NAME, token, { + path: '/admin', + httpOnly: true, + secure: !dev, + sameSite: 'lax', + maxAge: SESSION_TTL_SECONDS + }) +} + +export function clearSessionCookie(cookies: Cookies) { + cookies.delete(SESSION_COOKIE_NAME, { + path: '/admin' + }) +} + +export function getSessionUser(cookies: Cookies): SessionUser | null { + const token = cookies.get(SESSION_COOKIE_NAME) + return readSessionToken(token) +} + +export const ADMIN_SESSION_COOKIE = SESSION_COOKIE_NAME diff --git a/src/lib/server/api-utils.ts b/src/lib/server/api-utils.ts index 8273860..8d3a144 100644 --- a/src/lib/server/api-utils.ts +++ b/src/lib/server/api-utils.ts @@ -1,4 +1,5 @@ import type { RequestEvent } from '@sveltejs/kit' +import { getSessionUser } from '$lib/server/admin/session' // Response helpers export function jsonResponse(data: any, status = 200): Response { @@ -72,6 +73,11 @@ export function toISOString(date: Date | string | null | undefined): string | nu // Basic auth check (temporary until proper auth is implemented) export function checkAdminAuth(event: RequestEvent): boolean { + const sessionUser = getSessionUser(event.cookies) + if (sessionUser) { + return true + } + const authHeader = event.request.headers.get('Authorization') if (!authHeader) return false diff --git a/src/lib/types/session.ts b/src/lib/types/session.ts new file mode 100644 index 0000000..bb0d0d1 --- /dev/null +++ b/src/lib/types/session.ts @@ -0,0 +1,8 @@ +export interface SessionUser { + username: string +} + +export interface AdminSession { + user: SessionUser + expiresAt: number +} diff --git a/src/routes/admin/+layout.server.ts b/src/routes/admin/+layout.server.ts new file mode 100644 index 0000000..cb2aa05 --- /dev/null +++ b/src/routes/admin/+layout.server.ts @@ -0,0 +1,25 @@ +import { redirect } from '@sveltejs/kit' +import type { LayoutServerLoad } from './$types' +import { getSessionUser } from '$lib/server/admin/session' + +const LOGIN_PATH = '/admin/login' +const DASHBOARD_PATH = '/admin' + +function isLoginRoute(pathname: string) { + return pathname === LOGIN_PATH +} + +export const load = (async (event) => { + const user = getSessionUser(event.cookies) + const pathname = event.url.pathname + + if (!user && !isLoginRoute(pathname)) { + throw redirect(303, LOGIN_PATH) + } + + if (user && isLoginRoute(pathname)) { + throw redirect(303, DASHBOARD_PATH) + } + + return { user } +}) satisfies LayoutServerLoad diff --git a/src/routes/admin/+layout.svelte b/src/routes/admin/+layout.svelte index c97d63b..a8257a9 100644 --- a/src/routes/admin/+layout.svelte +++ b/src/routes/admin/+layout.svelte @@ -1,42 +1,24 @@ -{#if isLoading} -