From 7e4570cf0e3e5a003c21ee5beac5ba9856c88a36 Mon Sep 17 00:00:00 2001 From: Zias van Nes Date: Fri, 26 Sep 2025 18:47:03 +0200 Subject: [PATCH] feat:basic auth --- package.json | 3 + src/app.d.ts | 16 +-- src/hooks.server.ts | 26 +++++ src/lib/server/auth.ts | 81 +++++++++++++++ src/lib/server/db/schema.ts | 20 +++- src/routes/demo/+page.svelte | 1 + src/routes/demo/lucia/+page.server.ts | 31 ++++++ src/routes/demo/lucia/+page.svelte | 12 +++ src/routes/demo/lucia/login/+page.server.ts | 107 ++++++++++++++++++++ src/routes/demo/lucia/login/+page.svelte | 21 ++++ 10 files changed, 308 insertions(+), 10 deletions(-) create mode 100644 src/hooks.server.ts create mode 100644 src/lib/server/auth.ts create mode 100644 src/routes/demo/+page.svelte create mode 100644 src/routes/demo/lucia/+page.server.ts create mode 100644 src/routes/demo/lucia/+page.svelte create mode 100644 src/routes/demo/lucia/login/+page.server.ts create mode 100644 src/routes/demo/lucia/login/+page.svelte diff --git a/package.json b/package.json index 7a86174..5d1925c 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "@chromatic-com/storybook": "^4.1.1", "@eslint/compat": "^1.2.5", "@eslint/js": "^9.22.0", + "@oslojs/crypto": "^1.0.1", + "@oslojs/encoding": "^1.1.0", "@storybook/addon-a11y": "^9.1.8", "@storybook/addon-docs": "^9.1.8", "@storybook/addon-svelte-csf": "^5.0.8", @@ -53,6 +55,7 @@ "vite": "^7.0.4" }, "dependencies": { + "@node-rs/argon2": "^2.0.2", "postgres": "^3.4.5" }, "pnpm": { diff --git a/src/app.d.ts b/src/app.d.ts index da08e6d..c6ff201 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -2,12 +2,14 @@ // for information about these interfaces declare global { namespace App { - // interface Error {} - // interface Locals {} - // interface PageData {} - // interface PageState {} - // interface Platform {} - } -} + interface Locals { + user: import('$lib/server/auth').SessionValidationResult['user']; + session: import('$lib/server/auth').SessionValidationResult['session']; + } + } // interface Error {} + // interface Locals {} +} // interface PageData {} +// interface PageState {} +// interface Platform {} export {}; diff --git a/src/hooks.server.ts b/src/hooks.server.ts new file mode 100644 index 0000000..94001b6 --- /dev/null +++ b/src/hooks.server.ts @@ -0,0 +1,26 @@ +import type { Handle } from '@sveltejs/kit'; +import * as auth from '$lib/server/auth'; + +const handleAuth: Handle = async ({ event, resolve }) => { + const sessionToken = event.cookies.get(auth.sessionCookieName); + + if (!sessionToken) { + event.locals.user = null; + event.locals.session = null; + return resolve(event); + } + + const { session, user } = await auth.validateSessionToken(sessionToken); + + if (session) { + auth.setSessionTokenCookie(event, sessionToken, session.expiresAt); + } else { + auth.deleteSessionTokenCookie(event); + } + + event.locals.user = user; + event.locals.session = session; + return resolve(event); +}; + +export const handle: Handle = handleAuth; diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts new file mode 100644 index 0000000..38c9930 --- /dev/null +++ b/src/lib/server/auth.ts @@ -0,0 +1,81 @@ +import type { RequestEvent } from '@sveltejs/kit'; +import { eq } from 'drizzle-orm'; +import { sha256 } from '@oslojs/crypto/sha2'; +import { encodeBase64url, encodeHexLowerCase } from '@oslojs/encoding'; +import { db } from '$lib/server/db'; +import * as table from '$lib/server/db/schema'; + +const DAY_IN_MS = 1000 * 60 * 60 * 24; + +export const sessionCookieName = 'auth-session'; + +export function generateSessionToken() { + const bytes = crypto.getRandomValues(new Uint8Array(18)); + const token = encodeBase64url(bytes); + return token; +} + +export async function createSession(token: string, userId: string) { + const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); + const session: table.Session = { + id: sessionId, + userId, + expiresAt: new Date(Date.now() + DAY_IN_MS * 30) + }; + await db.insert(table.session).values(session); + return session; +} + +export async function validateSessionToken(token: string) { + const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); + const [result] = await db + .select({ + // Adjust user table here to tweak returned data + user: { id: table.user.id, username: table.user.username }, + session: table.session + }) + .from(table.session) + .innerJoin(table.user, eq(table.session.userId, table.user.id)) + .where(eq(table.session.id, sessionId)); + + if (!result) { + return { session: null, user: null }; + } + const { session, user } = result; + + const sessionExpired = Date.now() >= session.expiresAt.getTime(); + if (sessionExpired) { + await db.delete(table.session).where(eq(table.session.id, session.id)); + return { session: null, user: null }; + } + + const renewSession = Date.now() >= session.expiresAt.getTime() - DAY_IN_MS * 15; + if (renewSession) { + session.expiresAt = new Date(Date.now() + DAY_IN_MS * 30); + await db + .update(table.session) + .set({ expiresAt: session.expiresAt }) + .where(eq(table.session.id, session.id)); + } + + return { session, user }; +} + +export type SessionValidationResult = Awaited>; + +export async function invalidateSession(sessionId: string) { + await db.delete(table.session).where(eq(table.session.id, sessionId)); +} + +export function setSessionTokenCookie(event: RequestEvent, token: string, expiresAt: Date) { + event.cookies.set(sessionCookieName, token, { + expires: expiresAt, + path: '/' + }); +} + +export function deleteSessionTokenCookie(event: RequestEvent) { + event.cookies.delete(sessionCookieName, { + path: '/' + }); +} diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index b256205..cbb6d1a 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -1,6 +1,20 @@ -import { pgTable, serial, integer } from 'drizzle-orm/pg-core'; +import { pgTable, serial, integer, text, timestamp } from 'drizzle-orm/pg-core'; export const user = pgTable('user', { - id: serial('id').primaryKey(), - age: integer('age') + id: text('id').primaryKey(), + age: integer('age'), + username: text('username').notNull().unique(), + passwordHash: text('password_hash').notNull() }); + +export const session = pgTable('session', { + id: text('id').primaryKey(), + userId: text('user_id') + .notNull() + .references(() => user.id), + expiresAt: timestamp('expires_at', { withTimezone: true, mode: 'date' }).notNull() +}); + +export type Session = typeof session.$inferSelect; + +export type User = typeof user.$inferSelect; diff --git a/src/routes/demo/+page.svelte b/src/routes/demo/+page.svelte new file mode 100644 index 0000000..e291ab8 --- /dev/null +++ b/src/routes/demo/+page.svelte @@ -0,0 +1 @@ +lucia diff --git a/src/routes/demo/lucia/+page.server.ts b/src/routes/demo/lucia/+page.server.ts new file mode 100644 index 0000000..9fe83e5 --- /dev/null +++ b/src/routes/demo/lucia/+page.server.ts @@ -0,0 +1,31 @@ +import * as auth from '$lib/server/auth'; +import { fail, redirect } from '@sveltejs/kit'; +import { getRequestEvent } from '$app/server'; +import type { Actions, PageServerLoad } from './$types'; + +export const load: PageServerLoad = async () => { + const user = requireLogin(); + return { user }; +}; + +export const actions: Actions = { + logout: async (event) => { + if (!event.locals.session) { + return fail(401); + } + await auth.invalidateSession(event.locals.session.id); + auth.deleteSessionTokenCookie(event); + + return redirect(302, '/demo/lucia/login'); + } +}; + +function requireLogin() { + const { locals } = getRequestEvent(); + + if (!locals.user) { + return redirect(302, '/demo/lucia/login'); + } + + return locals.user; +} diff --git a/src/routes/demo/lucia/+page.svelte b/src/routes/demo/lucia/+page.svelte new file mode 100644 index 0000000..cefb2d1 --- /dev/null +++ b/src/routes/demo/lucia/+page.svelte @@ -0,0 +1,12 @@ + + +

Hi, {data.user.username}!

+

Your user ID is {data.user.id}.

+
+ +
diff --git a/src/routes/demo/lucia/login/+page.server.ts b/src/routes/demo/lucia/login/+page.server.ts new file mode 100644 index 0000000..97d1235 --- /dev/null +++ b/src/routes/demo/lucia/login/+page.server.ts @@ -0,0 +1,107 @@ +import { hash, verify } from '@node-rs/argon2'; +import { encodeBase32LowerCase } from '@oslojs/encoding'; +import { fail, redirect } from '@sveltejs/kit'; +import { eq } from 'drizzle-orm'; +import * as auth from '$lib/server/auth'; +import { db } from '$lib/server/db'; +import * as table from '$lib/server/db/schema'; +import type { Actions, PageServerLoad } from './$types'; + +export const load: PageServerLoad = async (event) => { + if (event.locals.user) { + return redirect(302, '/demo/lucia'); + } + return {}; +}; + +export const actions: Actions = { + login: async (event) => { + const formData = await event.request.formData(); + const username = formData.get('username'); + const password = formData.get('password'); + + if (!validateUsername(username)) { + return fail(400, { + message: 'Invalid username (min 3, max 31 characters, alphanumeric only)' + }); + } + if (!validatePassword(password)) { + return fail(400, { message: 'Invalid password (min 6, max 255 characters)' }); + } + + const results = await db.select().from(table.user).where(eq(table.user.username, username)); + + const existingUser = results.at(0); + if (!existingUser) { + return fail(400, { message: 'Incorrect username or password' }); + } + + const validPassword = await verify(existingUser.passwordHash, password, { + memoryCost: 19456, + timeCost: 2, + outputLen: 32, + parallelism: 1 + }); + if (!validPassword) { + return fail(400, { message: 'Incorrect username or password' }); + } + + const sessionToken = auth.generateSessionToken(); + const session = await auth.createSession(sessionToken, existingUser.id); + auth.setSessionTokenCookie(event, sessionToken, session.expiresAt); + + return redirect(302, '/demo/lucia'); + }, + register: async (event) => { + const formData = await event.request.formData(); + const username = formData.get('username'); + const password = formData.get('password'); + + if (!validateUsername(username)) { + return fail(400, { message: 'Invalid username' }); + } + if (!validatePassword(password)) { + return fail(400, { message: 'Invalid password' }); + } + + const userId = generateUserId(); + const passwordHash = await hash(password, { + // recommended minimum parameters + memoryCost: 19456, + timeCost: 2, + outputLen: 32, + parallelism: 1 + }); + + try { + await db.insert(table.user).values({ id: userId, username, passwordHash }); + + const sessionToken = auth.generateSessionToken(); + const session = await auth.createSession(sessionToken, userId); + auth.setSessionTokenCookie(event, sessionToken, session.expiresAt); + } catch { + return fail(500, { message: 'An error has occurred' }); + } + return redirect(302, '/demo/lucia'); + } +}; + +function generateUserId() { + // ID with 120 bits of entropy, or about the same as UUID v4. + const bytes = crypto.getRandomValues(new Uint8Array(15)); + const id = encodeBase32LowerCase(bytes); + return id; +} + +function validateUsername(username: unknown): username is string { + return ( + typeof username === 'string' && + username.length >= 3 && + username.length <= 31 && + /^[a-z0-9_-]+$/.test(username) + ); +} + +function validatePassword(password: unknown): password is string { + return typeof password === 'string' && password.length >= 6 && password.length <= 255; +} diff --git a/src/routes/demo/lucia/login/+page.svelte b/src/routes/demo/lucia/login/+page.svelte new file mode 100644 index 0000000..a3138d7 --- /dev/null +++ b/src/routes/demo/lucia/login/+page.svelte @@ -0,0 +1,21 @@ + + +

Login/Register

+
+ + + + +
+

{form?.message ?? ''}