diff options
Diffstat (limited to 'app/src')
-rw-r--r-- | app/src/app.d.ts | 27 | ||||
-rw-r--r-- | app/src/app.html | 12 | ||||
-rw-r--r-- | app/src/hooks.server.ts | 28 | ||||
-rw-r--r-- | app/src/lib/server/db.ts | 20 | ||||
-rw-r--r-- | app/src/lib/server/sessions.ts | 141 | ||||
-rw-r--r-- | app/src/lib/server/users.ts | 38 | ||||
-rw-r--r-- | app/src/routes/+page.svelte | 2 | ||||
-rw-r--r-- | app/src/routes/login/+page.server.ts | 39 | ||||
-rw-r--r-- | app/src/routes/login/+page.svelte | 33 | ||||
-rw-r--r-- | app/src/routes/profile/+page.server.ts | 10 | ||||
-rw-r--r-- | app/src/routes/profile/+page.svelte | 12 |
11 files changed, 362 insertions, 0 deletions
diff --git a/app/src/app.d.ts b/app/src/app.d.ts new file mode 100644 index 0000000..7844a85 --- /dev/null +++ b/app/src/app.d.ts @@ -0,0 +1,27 @@ +import type { User } from "$lib/server/users"; +import type { PoolClient } from "pg"; + +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + interface Locals { + dbConn: PoolClient; + + /** + * The user, if they are logged in. + * + * Each page (or group) must have a `load` handler (in `+page.server.ts`) which + * ensures that `user` is defined, optionally redirecting to `/login?redirectTo` + * if they need to ensure this is defined. + */ + user?: User; + } + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/app/src/app.html b/app/src/app.html new file mode 100644 index 0000000..77a5ff5 --- /dev/null +++ b/app/src/app.html @@ -0,0 +1,12 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="utf-8" /> + <link rel="icon" href="%sveltekit.assets%/favicon.png" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + %sveltekit.head% + </head> + <body data-sveltekit-preload-data="hover"> + <div style="display: contents">%sveltekit.body%</div> + </body> +</html> diff --git a/app/src/hooks.server.ts b/app/src/hooks.server.ts new file mode 100644 index 0000000..79535f9 --- /dev/null +++ b/app/src/hooks.server.ts @@ -0,0 +1,28 @@ +import { getDbConnection } from "$lib/server/db"; +import { validateSessionToken } from "$lib/server/sessions"; +import { redirect, type Handle } from "@sveltejs/kit"; +import { sequence } from "@sveltejs/kit/hooks"; + +const dbHandle = (async ({ event, resolve }) => { + const dbConn = await getDbConnection(); + event.locals = { dbConn }; + + const response = await resolve(event); + dbConn.release(); + + return response; +}) satisfies Handle; + +const sessionHandle = (async ({ event, resolve }) => { + const sessionToken = event.cookies.get("SESSION_ID"); + if (sessionToken) { + const { user } = await validateSessionToken(event.locals.dbConn, sessionToken); + if (user) { + event.locals.user = user; + } + } + + return resolve(event); +}) satisfies Handle; + +export const handle = sequence(dbHandle, sessionHandle); diff --git a/app/src/lib/server/db.ts b/app/src/lib/server/db.ts new file mode 100644 index 0000000..dcfcfd0 --- /dev/null +++ b/app/src/lib/server/db.ts @@ -0,0 +1,20 @@ +import pg from "pg"; +import { env } from "$env/dynamic/private"; + +const pool = new pg.Pool({ + database: env.POSTGRES_DB || "postgres", + user: env.POSTGRES_USERNAME || "postgres", + host: env.POSTGRES_HOST || "localhost", + port: Number(env.POSTGRES_PORT || 5432), +}); + +// Prevent errors in idle clients from terminating Node process. +// See: https://node-postgres.com/features/pooling +// See: https://nodejs.org/api/events.html#error-events +pool.on("error", (err, client) => { + console.error("Database error: ", err, client); +}); + +export function getDbConnection(): Promise<pg.PoolClient> { + return pool.connect(); +} diff --git a/app/src/lib/server/sessions.ts b/app/src/lib/server/sessions.ts new file mode 100644 index 0000000..1b829ff --- /dev/null +++ b/app/src/lib/server/sessions.ts @@ -0,0 +1,141 @@ +import type pg from "pg"; +import type { User } from "./users"; + +export type SessionValidationResult = + | { session: Session; user: User } + | { session: null; user: null }; + +export interface Session { + token: string; + userId: number; + expiresAt: Date; +} + +/** Generates a random string which can be used as a session token. */ +function generateSessionToken(): string { + const bytes = new Uint8Array(20); + crypto.getRandomValues(bytes); + const token = encodeBase32LowerCaseNoPadding(bytes); + return token; +} + +function encodeBase32LowerCaseNoPadding(input: Uint8Array): string { + const alphabet = "abcdefghijklmnopqrstuvwxyz234567"; // Lowercase alphabet + let output = ""; + let bits = 0; + let buffer = 0; + + for (const byte of input) { + buffer = (buffer << 8) | byte; + bits += 8; + + while (bits >= 5) { + output += alphabet[(buffer >> (bits - 5)) & 0x1f]; + bits -= 5; + } + } + + // Handle remaining bits (if any) - No padding, so just add if > 0. + if (bits > 0) { + output += alphabet[(buffer << (5 - bits)) & 0x1f]; + } + + return output; +} + +/** + * The amount of milliseconds since last access, a session remains valid for when it is created. + * + * It is currently set to 30 days. + */ +const VALID_MILLISECONDS = 1000 * 60 * 60 * 24 * 30; + +/** Creates a new session for the user with the given `userId`. */ +export async function createSession(dbConn: pg.PoolClient, userId: number): Promise<Session> { + const token = generateSessionToken(); + const session: Session = { + token: token, + userId, + expiresAt: new Date(Date.now() + VALID_MILLISECONDS), + }; + await dbConn.query("INSERT INTO sessions(token, user_id, expires_at) VALUES ($1, $2, $3);", [ + session.token, + session.userId, + session.expiresAt, + ]); + return session; +} + +/** + * Validates a session token. It happens in two parts: + * + * 1. Does a token exist? + * 2. Is that token NOT expired? + * + * As a side effect, the session is touched (i.e. it's expiration is delayed) if validation suceeds. + * This is to avoid unnecessary logouts, as session validation indicates that the session is still in use. + * + * @returns A session + user pair if the session is valid, or `null` for both otherwise. + */ +export async function validateSessionToken( + dbConn: pg.PoolClient, + token: string, +): Promise<SessionValidationResult> { + // Step 1 + const result = await dbConn.query( + `SELECT * FROM sessions + INNER JOIN users ON users.id = sessions.user_id + WHERE token = $1;`, + [token], + ); + console.debug(result); + if (result.rowCount === 0) { + return { session: null, user: null }; + } + + const session: Session = { + token, + userId: result.rows[0].user_id, + expiresAt: result.rows[0].expires_at, + }; + const user: User = { + id: result.rows[0].id, // Luckily JOIN avoids collision... + email: result.rows[0].email, + firstName: result.rows[0].first_name, + lastName: result.rows[0].last_name, + role: result.rows[0].role, + }; + console.debug("Session with token %s: %o, %o", token, session, user); + + // Step 2. + const now = Date.now(); + if (now >= session.expiresAt.getTime()) { + await invalidateSession(dbConn, session.token); + return { session: null, user: null }; + } + + // "Touch" the session, if it is about to expire. + // We only do this a bit into the period to avoid superflous database writes. + if (now >= session.expiresAt.getTime() - VALID_MILLISECONDS / 2) { + session.expiresAt = new Date(session.expiresAt.getTime() + VALID_MILLISECONDS / 2); + await dbConn.query("UPDATE sessions SET expires_at = ? WHERE id = ?;", [ + session.expiresAt, + session.token, + ]); + } + + return { session, user }; +} + +/** Invalidates the session with token `sessionToken`. */ +export async function invalidateSession( + dbConn: pg.PoolClient, + sessionToken: string, +): Promise<void> { + await dbConn.query("DELETE sessions WHERE token = ?;", [sessionToken]); +} + +/** Invalidates all sessions for the user with the id `userId`. */ +export async function invalidateAllSessions(dbConn: pg.PoolClient, userId: number): Promise<void> { + await dbConn.query("DELETE sessions WHERE user_id = ?", [userId]); +} diff --git a/app/src/lib/server/users.ts b/app/src/lib/server/users.ts new file mode 100644 index 0000000..c233fda --- /dev/null +++ b/app/src/lib/server/users.ts @@ -0,0 +1,38 @@ +import type pg from "pg"; + +/** A row from the `users` column. */ +export interface User { + id: number; + email: string; + firstName: string; + lastName: string; + role: "gardener" | "owner"; +} + +/** + * Retrieves a user by their email and password. + * + * The password should be passed unaltered. All validation is done on the DB server. + * + * @returns The user, or `undefined` on authentication failure. + */ +export async function getUser( + dbConn: pg.PoolClient, + email: string, + password: string, +): Promise<User | undefined> { + let result = await dbConn.query( + "SELECT * FROM users WHERE email = $1 AND password_hash = crypt($2, password_hash);", + [email, password], + ); + if (result.rowCount == 0) { + return undefined; + } + return { + id: result.rows[0].id, + email: result.rows[0].email, + firstName: result.rows[0].first_name, + lastName: result.rows[0].last_name, + role: result.rows[0].role, + }; +} diff --git a/app/src/routes/+page.svelte b/app/src/routes/+page.svelte new file mode 100644 index 0000000..cc88df0 --- /dev/null +++ b/app/src/routes/+page.svelte @@ -0,0 +1,2 @@ +<h1>Welcome to SvelteKit</h1> +<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p> diff --git a/app/src/routes/login/+page.server.ts b/app/src/routes/login/+page.server.ts new file mode 100644 index 0000000..d011af9 --- /dev/null +++ b/app/src/routes/login/+page.server.ts @@ -0,0 +1,39 @@ +import { getUser } from "$lib/server/users"; +import { createSession } from "$lib/server/sessions"; +import { fail, redirect } from "@sveltejs/kit"; +import type { Actions } from "./$types"; + +export const actions = { + default: async ({ url, cookies, request, locals: { dbConn } }) => { + const formData = Object.fromEntries(await request.formData()) as { + email?: string; + password?: string; + }; + if (!formData.email || !formData.password) { + return fail(400, { failure: true, error: "Du skal udfylde alle felterne!" }); + } + + const user = await getUser(dbConn, formData.email, formData.password); + if (!user) { + // It's important that we don't leak _which_ value is missing. + return fail(404, { failure: true, error: "Forkert email/kodeord kombi!" }); + } + console.debug("Found user %o", user); + + // The user has proven that they posses the right credentials. In return they gain a session token, which can be used to authenticate future requests. + const session = await createSession(dbConn, user.id); + cookies.set("SESSION_ID", session.token, { + path: "/", + secure: true, + sameSite: "strict", + }); + console.debug("Created session %o", session); + + // If sent here from trying to access another page without session cookie. + if (url.searchParams.has("redirectTo")) { + return redirect(303, url.searchParams.get("redirectTo")!); + } + + return { success: true }; + }, +} satisfies Actions; diff --git a/app/src/routes/login/+page.svelte b/app/src/routes/login/+page.svelte new file mode 100644 index 0000000..3e3e3c7 --- /dev/null +++ b/app/src/routes/login/+page.svelte @@ -0,0 +1,33 @@ +<script lang="ts"> + import { enhance } from "$app/forms"; + import type { PageProps } from "./$types"; + let { data, form }: PageProps = $props(); +</script> + +<svelte:head> + <title>Log ind</title> +</svelte:head> + +<!-- If login completed successfully and we dont have ?redirectTo --> +{#if form?.success} + <p>Du er nu logget ind!</p> +{/if} + +<form method="POST" use:enhance> + {#if form?.failure}<p class="error">{form?.error}</p>{/if} + <label> + Email + <input name="email" type="email" /> + </label> + <label> + Kodeord + <input name="password" type="password" /> + </label> + <button>Log ind</button> +</form> + +<style> + label { + display: block; + } +</style> diff --git a/app/src/routes/profile/+page.server.ts b/app/src/routes/profile/+page.server.ts new file mode 100644 index 0000000..5c9b9d3 --- /dev/null +++ b/app/src/routes/profile/+page.server.ts @@ -0,0 +1,10 @@ +import type { PageServerLoad } from "./$types"; +import { redirect } from "@sveltejs/kit"; + +export const load = (async ({ url, locals }) => { + if (!locals.user) { + redirect(303, `/login?redirectTo=${encodeURIComponent(url.toString())}`); + } + + return { user: locals.user }; +}) satisfies PageServerLoad; diff --git a/app/src/routes/profile/+page.svelte b/app/src/routes/profile/+page.svelte new file mode 100644 index 0000000..0ee18f0 --- /dev/null +++ b/app/src/routes/profile/+page.svelte @@ -0,0 +1,12 @@ +<script lang="ts"> + import type { PageProps } from "./$types"; + + const { data }: PageProps = $props(); +</script> + +<!-- svelte-ignore a11y_img_redundant_alt: That's not what 'picture' refers to... --> +<img src="/profile_picture_standin.jpeg" width="255" height="255" alt="Dummy profile picture" /> +<p>Hej, {data.user.firstName} {data.user.lastName}!</p> + +<style> +</style> |