diff options
| -rw-r--r-- | README.md | 3 | ||||
| -rw-r--r-- | src/hooks.server.ts | 30 | ||||
| -rw-r--r-- | src/lib/server/auth.test.ts | 59 | ||||
| -rw-r--r-- | src/lib/server/auth.ts | 88 | ||||
| -rw-r--r-- | src/routes/+layout.svelte | 5 | ||||
| -rw-r--r-- | src/routes/login/+page.server.ts | 30 | ||||
| -rw-r--r-- | src/routes/login/+page.svelte | 83 |
7 files changed, 297 insertions, 1 deletions
@@ -75,6 +75,9 @@ worker precaches assets; works offline after first visit. - [ ] Deploy bare build + node systemctl service +The app password is supplied at runtime as `AUTH_PASSWORD` in the VPS Docker +Compose deployment. + ```bash docker build -t home-sveltekit . docker save home-sveltekit | gzip | pv | ssh ubuntu@feed.kagamino.dev "gunzip | sudo docker load" 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> |
