diff --git a/docker-compose.yml b/docker-compose.yml index 91015df..1f3cbf9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,6 +32,7 @@ services: - backend environment: NODE_ENV: production + BACKEND_URL: http://backend:8000 db: image: postgres:16-alpine diff --git a/frontend/src/lib/server/auth.ts b/frontend/src/lib/server/auth.ts new file mode 100644 index 0000000..5d7b246 --- /dev/null +++ b/frontend/src/lib/server/auth.ts @@ -0,0 +1,156 @@ +/* +Copyright (c) 2026 Lachlan Harris. All Rights Reserved. +This project is licensed under Apache 2.0 + +src/lib/server/auth.ts +Auth helper functions for communicating with the backend +and managing auth cookies +*/ + +import type { Cookies } from '@sveltejs/kit'; + +import { getBackendApiV1BaseUrl } from './backend'; + +// Types +// ----- + +// see backend/app/api/v1/models.py +export type JwtTokenResponse = { + access_token: string; + token_type: 'bearer' | string; +}; + +// see backend/app/api/v1/models.py +export type BackendUser = { + id: number; + username: string; + created_at: string; +}; + +// Acces Tokens +// ------------ + +// name of the cookie for client-side retrieval +const ACCESS_TOKEN_COOKIE = 'access_token'; + +export function getAccessTokenFromCookies(cookies: Cookies): string | undefined { + return cookies.get(ACCESS_TOKEN_COOKIE); +} + +export function setAccessTokenCookie(cookies: Cookies, params: { token: string; url: URL }): void { + cookies.set(ACCESS_TOKEN_COOKIE, params.token, { + httpOnly: true, + secure: params.url.protocol === 'https:', + sameSite: 'lax', + path: '/', + maxAge: 60 * 60 * 24 * 7 + }); +} + +export function clearAccessTokenCookie(cookies: Cookies): void { + cookies.delete(ACCESS_TOKEN_COOKIE, { path: '/' }); +} + +// Primary auth functions +// ---------------------- + +/** + * Logs in a user with a username and password + * + * @param fetchFn - pass in fetch from the load function or actions context + * @param params - username and password for login + * @returns JWT token on success + */ +export async function backendLoginWithPassword( + fetchFn: typeof fetch, + params: { username: string; password: string } +): Promise { + const res = await fetchFn(`${getBackendApiV1BaseUrl()}/auth/token`, { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + username: params.username, + password: params.password + }) + }); + + if (!res.ok) { + let detail = 'Login failed'; + try { + const body = await res.json(); + if (typeof body?.detail === 'string') detail = body.detail; + } catch { + // ignore + } + throw new Error(detail); + } + + return (await res.json()) as JwtTokenResponse; +} + +/** + * Registers a user with a username and password + * + * @param fetchFn - pass in fetch from the load function or actions context + * @param params - username and password for registration + * @returns JWT token on success + */ +export async function backendSignup( + fetchFn: typeof fetch, + params: { username: string; password: string } +): Promise { + const res = await fetchFn(`${getBackendApiV1BaseUrl()}/auth/signup`, { + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ + username: params.username, + password: params.password + }) + }); + + if (!res.ok) { + let detail = 'Signup failed'; + try { + const body = await res.json(); + if (typeof body?.detail === 'string') detail = body.detail; + } catch { + // ignore + } + throw new Error(detail); + } + + return (await res.json()) as BackendUser; +} + +/** + * Returns information about the currently authenticated user + * + * @param fetchFn - pass in fetch from the load function or actions context + * @param accessToken - the JWT token for authentication + * @returns user information on success + */ +export async function backendGetMe(fetchFn: typeof fetch, accessToken: string): Promise { + const res = await fetchFn(`${getBackendApiV1BaseUrl()}/auth/me`, { + method: 'GET', + headers: { + authorization: `Bearer ${accessToken}` + } + }); + + if (!res.ok) { + let detail = 'Not authenticated'; + try { + const body = await res.json(); + if (typeof body?.detail === 'string') detail = body.detail; + } catch { + // ignore + } + throw new Error(detail); + } + + return (await res.json()) as BackendUser; +} diff --git a/frontend/src/lib/server/backend.ts b/frontend/src/lib/server/backend.ts new file mode 100644 index 0000000..b087f92 --- /dev/null +++ b/frontend/src/lib/server/backend.ts @@ -0,0 +1,19 @@ +/* +Copyright (c) 2026 Lachlan Harris. All Rights Reserved. +This project is licensed under Apache 2.0 + +src/lib/server/backend.ts +Backend utility functions +*/ + +import { env } from '$env/dynamic/private'; + +// redundancy function; should be set by docker env variables for prod +// or the local .env file for dev +export function getBackendUrl(): string { + return env.BACKEND_URL ?? 'http://localhost:8000'; +} + +export function getBackendApiV1BaseUrl(): string { + return `${getBackendUrl()}/api/v1`; +} diff --git a/frontend/src/routes/+layout.server.ts b/frontend/src/routes/+layout.server.ts new file mode 100644 index 0000000..2b9ffa6 --- /dev/null +++ b/frontend/src/routes/+layout.server.ts @@ -0,0 +1,33 @@ +/* +Copyright (c) 2026 Lachlan Harris. All Rights Reserved. +This project is licensed under Apache 2.0 + +src/routes/+layout.server.ts +server-side logic shared between pages +acts as a middleware +*/ + +import { redirect } from '@sveltejs/kit'; +import type { LayoutServerLoad } from './$types'; + +import { getAccessTokenFromCookies } from '$lib/server/auth'; + +function isAuthRoute(pathname: string): boolean { + return pathname === '/auth' || pathname.startsWith('/auth/'); +} + +export const load: LayoutServerLoad = async ({ url, cookies }) => { + const accessToken = getAccessTokenFromCookies(cookies); + const isAuthed = Boolean(accessToken); + + if (!isAuthed && !isAuthRoute(url.pathname)) { + const redirectTo = `${url.pathname}${url.search}`; + throw redirect(303, `/auth/login?redirectTo=${encodeURIComponent(redirectTo)}`); + } + + if (isAuthed && (url.pathname === '/auth/login' || url.pathname === '/auth/signup')) { + throw redirect(303, url.searchParams.get('redirectTo') || '/'); + } + + return {}; +}; diff --git a/frontend/src/routes/auth/login/+page.server.ts b/frontend/src/routes/auth/login/+page.server.ts new file mode 100644 index 0000000..63900f7 --- /dev/null +++ b/frontend/src/routes/auth/login/+page.server.ts @@ -0,0 +1,36 @@ +/* +Copyright (c) 2026 Lachlan Harris. All Rights Reserved. +This project is licensed under Apache 2.0 + +src/routes/auth/login/+page.server.ts +server-side logic for the login page +*/ + +import { fail, redirect } from '@sveltejs/kit'; +import type { Actions } from './$types'; + +import { backendLoginWithPassword, setAccessTokenCookie } from '$lib/server/auth'; + +export const actions: Actions = { + default: async ({ request, cookies, fetch, url }) => { + const form = await request.formData(); + const username = String(form.get('username') ?? '').trim(); + const password = String(form.get('password') ?? ''); + + if (!username || !password) { + return fail(400, { error: 'Username and password are required.' }); + } + + try { + const token = await backendLoginWithPassword(fetch, { username, password }); + setAccessTokenCookie(cookies, { token: token.access_token, url }); + + const redirectTo = url.searchParams.get('redirectTo'); + throw redirect(303, redirectTo || '/'); + } catch (err) { + return fail(401, { + error: err instanceof Error ? err.message : 'Login failed.' + }); + } + } +}; diff --git a/frontend/src/routes/auth/login/+page.svelte b/frontend/src/routes/auth/login/+page.svelte index 2bb94fe..40edc9d 100644 --- a/frontend/src/routes/auth/login/+page.svelte +++ b/frontend/src/routes/auth/login/+page.svelte @@ -9,6 +9,9 @@ src/routes/auth/login/+page.svelte import PageHeader from '$lib/components/pageheader.svelte'; import Bento from '$lib/components/bento.svelte'; import Hint from '$lib/components/hint.svelte'; + import { page } from '$app/state'; + + let { form } = $props();
@@ -22,15 +25,23 @@ src/routes/auth/login/+page.svelte post_add -
+ + {#if form?.error} +
+ {form.error} +
+ {/if} +
@@ -41,19 +52,21 @@ src/routes/auth/login/+page.svelte - + Don't have an account? Signup here.
- + Already have an account? Login here.
-
+
+ + +
diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js index b0d0789..97c6b41 100644 --- a/frontend/svelte.config.js +++ b/frontend/svelte.config.js @@ -10,7 +10,10 @@ const config = { // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. // If your environment is not supported, or you settled on a specific environment, switch out the adapter. // See https://svelte.dev/docs/kit/adapters for more information about adapters. - adapter: adapter() + adapter: adapter(), + csrf: { + checkOrigin: false // required to allow form request on dev (weird inconsistencies between localhost and 127.0.0.1) + } } };