From 4d7ddf81ee82d2c3efc146732d2909ace132b416 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Tue, 7 Oct 2025 05:22:39 -0700 Subject: [PATCH] feat(admin): add session-backed auth flow --- src/lib/server/admin/session.ts | 134 +++++++++++++++++++++++++ src/lib/server/api-utils.ts | 6 ++ src/lib/types/session.ts | 8 ++ src/routes/admin/+layout.server.ts | 25 +++++ src/routes/admin/+layout.svelte | 32 ++---- src/routes/admin/login/+page.server.ts | 38 +++++++ src/routes/admin/login/+page.svelte | 49 ++------- 7 files changed, 228 insertions(+), 64 deletions(-) create mode 100644 src/lib/server/admin/session.ts create mode 100644 src/lib/types/session.ts create mode 100644 src/routes/admin/+layout.server.ts create mode 100644 src/routes/admin/login/+page.server.ts 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} -
Loading...
-{:else if !isAuthenticated && currentPath !== '/admin/login'} - -
Redirecting to login...
-{:else if currentPath === '/admin/login'} +{#if isLoginRoute} {@render children()} +{:else if !data.user} + +
Redirecting to login...
{:else}
diff --git a/src/routes/admin/login/+page.server.ts b/src/routes/admin/login/+page.server.ts new file mode 100644 index 0000000..33a2a9e --- /dev/null +++ b/src/routes/admin/login/+page.server.ts @@ -0,0 +1,38 @@ +import { fail, redirect } from '@sveltejs/kit' +import type { Actions, PageServerLoad } from './$types' +import { clearSessionCookie, setSessionCookie, validateAdminPassword } from '$lib/server/admin/session' + +export const load = (async ({ cookies }) => { + // Ensure we start with a clean session when hitting the login page + clearSessionCookie(cookies) + + return { + form: { + message: null + } + } +}) satisfies PageServerLoad + +export const actions = { + default: async ({ request, cookies }) => { + const formData = await request.formData() + const password = formData.get('password') + + if (typeof password !== 'string' || password.trim().length === 0) { + return fail(400, { + message: 'Password is required' + }) + } + + const user = validateAdminPassword(password) + if (!user) { + return fail(401, { + message: 'Invalid password' + }) + } + + setSessionCookie(cookies, user) + + throw redirect(303, '/admin') + } +} satisfies Actions diff --git a/src/routes/admin/login/+page.svelte b/src/routes/admin/login/+page.svelte index c237452..82630eb 100644 --- a/src/routes/admin/login/+page.svelte +++ b/src/routes/admin/login/+page.svelte @@ -1,39 +1,11 @@ @@ -42,24 +14,23 @@