summaryrefslogtreecommitdiff
path: root/app/src
diff options
context:
space:
mode:
authorLinnnus <[email protected]>2025-02-17 20:28:59 +0100
committerLinnnus <[email protected]>2025-02-17 20:28:59 +0100
commit2b309097ca145651618234476160fb30405eabe7 (patch)
tree20321cf83d18c0c3c3a0a745626565074ea69a41 /app/src
Initial commit
Diffstat (limited to 'app/src')
-rw-r--r--app/src/app.d.ts27
-rw-r--r--app/src/app.html12
-rw-r--r--app/src/hooks.server.ts28
-rw-r--r--app/src/lib/server/db.ts20
-rw-r--r--app/src/lib/server/sessions.ts141
-rw-r--r--app/src/lib/server/users.ts38
-rw-r--r--app/src/routes/+page.svelte2
-rw-r--r--app/src/routes/login/+page.server.ts39
-rw-r--r--app/src/routes/login/+page.svelte33
-rw-r--r--app/src/routes/profile/+page.server.ts10
-rw-r--r--app/src/routes/profile/+page.svelte12
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>