aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-08-13 09:38:16 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-08-13 09:38:16 +0200
commit007384be67559e1848ee0a8a1726c6b0fa39a221 (patch)
tree6b11cc43bd00967564c5bc74aed60168331f225b /src
parentb30150a8feb960c0667eaff6cb105dcc55b65744 (diff)
feat(auth): add persistent family login
Diffstat (limited to 'src')
-rw-r--r--src/hooks.server.ts30
-rw-r--r--src/lib/server/auth.test.ts59
-rw-r--r--src/lib/server/auth.ts88
-rw-r--r--src/routes/+layout.svelte5
-rw-r--r--src/routes/login/+page.server.ts30
-rw-r--r--src/routes/login/+page.svelte83
6 files changed, 294 insertions, 1 deletions
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 @@
<script>
import '../app.css';
+ import { page } from '$app/stores';
import Nav from '$lib/components/Nav.svelte';
let { children } = $props();
@@ -10,7 +11,9 @@
</svelte:head>
<div class="app">
- <Nav />
+ {#if $page.url.pathname !== '/login'}
+ <Nav />
+ {/if}
{@render children()}
</div>
diff --git a/src/routes/login/+page.server.ts b/src/routes/login/+page.server.ts
new file mode 100644
index 0000000..88c5f2d
--- /dev/null
+++ b/src/routes/login/+page.server.ts
@@ -0,0 +1,30 @@
+import { fail, redirect } from '@sveltejs/kit';
+import { setSession, verifyPassword } from '$lib/server/auth';
+import type { Actions, PageServerLoad } from './$types';
+
+function safeNext(value: string | null): string {
+ return value?.startsWith('/') && !value.startsWith('//') ? value : '/';
+}
+
+export const load: PageServerLoad = ({ url }) => ({
+ next: safeNext(url.searchParams.get('next')),
+});
+
+export const actions: Actions = {
+ default: async ({ request, cookies, url }) => {
+ const data = await request.formData();
+ const password = data.get('password')?.toString() ?? '';
+ const next = safeNext(
+ data.get('next')?.toString() ?? url.searchParams.get('next'),
+ );
+
+ if (!verifyPassword(password)) {
+ console.warn('[auth] failed login attempt');
+ return fail(401, { error: 'Mot de passe incorrect', next });
+ }
+
+ console.info('[auth] login succeeded');
+ setSession(cookies, url.protocol === 'https:');
+ throw redirect(303, next);
+ },
+};
diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte
new file mode 100644
index 0000000..303043f
--- /dev/null
+++ b/src/routes/login/+page.svelte
@@ -0,0 +1,83 @@
+<script lang="ts">
+ let { data, form } = $props();
+</script>
+
+<svelte:head>
+ <title>Connexion - Courses</title>
+</svelte:head>
+
+<main class="login">
+ <div class="login-card">
+ <h1>Courses</h1>
+ <p>Entrez le mot de passe familial pour continuer.</p>
+
+ <form method="POST">
+ <label>
+ <span class="form-label">Mot de passe</span>
+ <input
+ class="form-input"
+ type="password"
+ name="password"
+ autocomplete="current-password"
+ required
+ />
+ </label>
+ <input type="hidden" name="next" value={data.next} />
+ <button class="btn-primary" type="submit">Entrer</button>
+ </form>
+
+ {#if form?.error}
+ <p class="error">{form.error}</p>
+ {/if}
+ </div>
+</main>
+
+<style>
+ .login {
+ display: grid;
+ min-height: 100dvh;
+ place-items: center;
+ padding: 1rem;
+ }
+
+ .login-card {
+ width: min(100%, 24rem);
+ padding: 1.5rem;
+ background: var(--surface);
+ border-radius: 0.75rem;
+ box-shadow: 0 2px 8px var(--shadow);
+ }
+
+ .login-card h1 {
+ margin-bottom: 0.25rem;
+ }
+
+ .login-card > p {
+ margin-bottom: 1.5rem;
+ color: var(--text-secondary);
+ }
+
+ .login-card label {
+ display: flex;
+ flex-direction: column;
+ gap: 0.375rem;
+ }
+
+ .login-card .form-input {
+ width: 100%;
+ }
+
+ form {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ }
+
+ .btn-primary {
+ align-self: stretch;
+ }
+
+ .error {
+ margin-top: 1rem;
+ }
+</style>