From 007384be67559e1848ee0a8a1726c6b0fa39a221 Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Thu, 13 Aug 2026 09:38:16 +0200 Subject: feat(auth): add persistent family login --- src/hooks.server.ts | 30 ++++++++++++++ src/lib/server/auth.test.ts | 59 +++++++++++++++++++++++++++ src/lib/server/auth.ts | 88 ++++++++++++++++++++++++++++++++++++++++ src/routes/+layout.svelte | 5 ++- src/routes/login/+page.server.ts | 30 ++++++++++++++ src/routes/login/+page.svelte | 83 +++++++++++++++++++++++++++++++++++++ 6 files changed, 294 insertions(+), 1 deletion(-) create mode 100644 src/hooks.server.ts create mode 100644 src/lib/server/auth.test.ts create mode 100644 src/lib/server/auth.ts create mode 100644 src/routes/login/+page.server.ts create mode 100644 src/routes/login/+page.svelte (limited to 'src') diff --git a/src/hooks.server.ts b/src/hooks.server.ts new file mode 100644 index 0000000..f1f5b52 --- /dev/null +++ b/src/hooks.server.ts @@ -0,0 +1,30 @@ +import { redirect } from '@sveltejs/kit'; +import type { Handle } from '@sveltejs/kit'; +import { renewSession } from '$lib/server/auth'; + +const PUBLIC_ASSETS = new Set([ + '/favicon.png', + '/manifest.webmanifest', + '/pwa-192x192.png', + '/pwa-512x512.png', + '/registerSW.js', + '/sw.js', +]); + +function isPublicPath(pathname: string): boolean { + return ( + pathname === '/login' || + pathname.startsWith('/_app/') || + PUBLIC_ASSETS.has(pathname) + ); +} + +export const handle: Handle = async ({ event, resolve }) => { + if (isPublicPath(event.url.pathname)) return resolve(event); + + const secure = event.url.protocol === 'https:'; + if (renewSession(event.cookies, secure)) return resolve(event); + + const next = `${event.url.pathname}${event.url.search}`; + throw redirect(303, `/login?next=${encodeURIComponent(next)}`); +}; diff --git a/src/lib/server/auth.test.ts b/src/lib/server/auth.test.ts new file mode 100644 index 0000000..59ddabc --- /dev/null +++ b/src/lib/server/auth.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { Cookies } from '@sveltejs/kit'; +import { + AUTH_COOKIE_NAME, + SESSION_MAX_AGE_SECONDS, + createSession, + isValidSession, + renewSession, + verifyPassword, +} from './auth'; + +const TEST_PASSWORD = vi.hoisted(() => { + return 'family-password-5f2c1d7e'; +}); +const NOW = 1_800_000_000_000; + +vi.mock('$env/dynamic/private', () => ({ + env: { AUTH_PASSWORD: TEST_PASSWORD }, +})); + +describe('authentication', () => { + it('verifies the configured shared password', () => { + expect(verifyPassword(TEST_PASSWORD)).toBe(true); + expect(verifyPassword('wrong-password')).toBe(false); + }); + + it('accepts a signed session until its expiry', () => { + const session = createSession(NOW); + + expect( + isValidSession(session, NOW + SESSION_MAX_AGE_SECONDS * 1000 - 1), + ).toBe(true); + expect(isValidSession(session, NOW + SESSION_MAX_AGE_SECONDS * 1000)).toBe( + false, + ); + expect(isValidSession(`${session}x`, NOW)).toBe(false); + }); + + it('renews a valid session with the requested cookie settings', () => { + const session = createSession(NOW); + const cookies = { + get: vi.fn(() => session), + set: vi.fn(), + } as unknown as Cookies; + + expect(renewSession(cookies, true, NOW + 1_000)).toBe(true); + expect(cookies.set).toHaveBeenCalledWith( + AUTH_COOKIE_NAME, + expect.any(String), + expect.objectContaining({ + path: '/', + httpOnly: true, + secure: true, + sameSite: 'lax', + maxAge: SESSION_MAX_AGE_SECONDS, + }), + ); + }); +}); diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts new file mode 100644 index 0000000..f80eda0 --- /dev/null +++ b/src/lib/server/auth.ts @@ -0,0 +1,88 @@ +import { createHash, createHmac, timingSafeEqual } from 'node:crypto'; +import type { Cookies } from '@sveltejs/kit'; +import { env } from '$env/dynamic/private'; + +export const AUTH_COOKIE_NAME = 'home_auth'; +export const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 7; + +const SESSION_MAX_AGE_MS = SESSION_MAX_AGE_SECONDS * 1000; + +function configuredPassword(): string { + if (!env.AUTH_PASSWORD) { + throw new Error('AUTH_PASSWORD is not configured'); + } + return env.AUTH_PASSWORD; +} + +function digest(value: string): Buffer { + return createHash('sha256').update(value).digest(); +} + +function sign(value: string): string { + return createHmac('sha256', configuredPassword()) + .update(value) + .digest('base64url'); +} + +export function verifyPassword(candidate: string): boolean { + const expected = digest(configuredPassword()); + const actual = digest(candidate); + return timingSafeEqual(actual, expected); +} + +export function createSession(now = Date.now()): string { + const expiresAt = now + SESSION_MAX_AGE_MS; + const payload = String(expiresAt); + return `${payload}.${sign(payload)}`; +} + +export function isValidSession( + value: string | undefined, + now = Date.now(), +): boolean { + if (!value) return false; + + const [expiresAtText, signature, extra] = value.split('.'); + if (!expiresAtText || !signature || extra) return false; + + const expiresAt = Number(expiresAtText); + if (!Number.isSafeInteger(expiresAt) || expiresAt <= now) return false; + + const expected = Buffer.from(sign(expiresAtText), 'base64url'); + const actual = Buffer.from(signature, 'base64url'); + if (actual.length !== expected.length) return false; + + return timingSafeEqual(actual, expected); +} + +export function renewSession( + cookies: Cookies, + secure: boolean, + now = Date.now(), +): boolean { + const current = cookies.get(AUTH_COOKIE_NAME); + if (!isValidSession(current, now)) return false; + + cookies.set(AUTH_COOKIE_NAME, createSession(now), { + path: '/', + httpOnly: true, + secure, + sameSite: 'lax', + maxAge: SESSION_MAX_AGE_SECONDS, + }); + return true; +} + +export function setSession( + cookies: Cookies, + secure: boolean, + now = Date.now(), +): void { + cookies.set(AUTH_COOKIE_NAME, createSession(now), { + path: '/', + httpOnly: true, + secure, + sameSite: 'lax', + maxAge: SESSION_MAX_AGE_SECONDS, + }); +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index a168c65..b2803cc 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,5 +1,6 @@ + + + Connexion - Courses + + +
+ +
+ + -- cgit v1.3