diff --git a/bun.lockb b/bun.lockb index 5ccfe2b..3cfcf9c 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql new file mode 100644 index 0000000..708d65c --- /dev/null +++ b/migrations/0001_init.sql @@ -0,0 +1,12 @@ +-- Migration number: 0001 2024-11-19T11:13:32.513Z +CREATE TABLE user ( + `id` VARCHAR(32) NOT NULL PRIMARY KEY, + `name` VARCHAR(32) NOT NULL, + `avatar_hash` VARCHAR(32) NOT NULL +); + +CREATE TABLE session ( + `id` TEXT NOT NULL PRIMARY KEY, + `user_id` VARCHAR(32) NOT NULL REFERENCES user(id), + `expires_at` INTEGER NOT NULL +); \ No newline at end of file diff --git a/package.json b/package.json index 7adfc85..49dd5db 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,9 @@ "format": "prettier --write ." }, "dependencies": { + "@oslojs/crypto": "^1.0.1", + "@oslojs/encoding": "^1.1.0", + "arctic": "^2.2.2", "leaflet": "^1.9.4", "leaflet-defaulticon-compatibility": "^0.1.2" }, diff --git a/src/app.d.ts b/src/app.d.ts index fb61326..e249b04 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -1,14 +1,20 @@ +import type { Session, User } from "$lib/auth"; + // See https://kit.svelte.dev/docs/types#app // for information about these interfaces declare global { namespace App { // interface Error {} - // interface Locals {} // interface PageData {} // interface PageState {} + interface Locals { + user: User | null; + session: Session | null; + } interface Platform { env?: { TCL_GUESSR_KV: KVNamespace; + TCL_GUESSR_D1: D1Database; }; } } diff --git a/src/hooks.server.ts b/src/hooks.server.ts new file mode 100644 index 0000000..8a2fca5 --- /dev/null +++ b/src/hooks.server.ts @@ -0,0 +1,25 @@ +import { deleteSessionTokenCookie, setSessionTokenCookie } from "$lib/auth/cookie"; +import { validateSessionToken } from "$lib/auth/session"; +import type { Handle } from "@sveltejs/kit"; + +export const handle: Handle = async ({ event, resolve }) => { + const db = event.platform?.env?.TCL_GUESSR_D1 ?? null; + const token = event.cookies.get("session") ?? null; + + if (db === null || token === null) { + event.locals.session = null; + event.locals.user = null; + return resolve(event); + } + + const { session, user } = await validateSessionToken(token, db); + if (session !== null) { + setSessionTokenCookie(event.cookies, token, session.expiresAt); + } else { + deleteSessionTokenCookie(event.cookies); + } + + event.locals.session = session; + event.locals.user = user; + return resolve(event); +}; diff --git a/src/lib/auth/cookie.ts b/src/lib/auth/cookie.ts new file mode 100644 index 0000000..68acc60 --- /dev/null +++ b/src/lib/auth/cookie.ts @@ -0,0 +1,19 @@ +import type { Cookies } from "@sveltejs/kit"; + +export function setSessionTokenCookie(cookies: Cookies, token: string, expiresAt: Date): void { + cookies.set("session", token, { + httpOnly: true, + sameSite: "lax", + expires: expiresAt, + path: "/", + }); +} + +export function deleteSessionTokenCookie(cookies: Cookies): void { + cookies.set("session", "", { + httpOnly: true, + sameSite: "lax", + maxAge: 0, + path: "/", + }); +} diff --git a/src/lib/auth/discord.ts b/src/lib/auth/discord.ts new file mode 100644 index 0000000..6994872 --- /dev/null +++ b/src/lib/auth/discord.ts @@ -0,0 +1,14 @@ +import { Discord } from "arctic"; +import { DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET } from "$env/static/private"; + +export interface DiscordUser { + id: string; + username: string; + avatar: string; +} + +export const discord = new Discord( + DISCORD_CLIENT_ID, + DISCORD_CLIENT_SECRET, + "http://localhost:8788/login/callback", +); diff --git a/src/lib/auth/index.ts b/src/lib/auth/index.ts new file mode 100644 index 0000000..2f5b7a6 --- /dev/null +++ b/src/lib/auth/index.ts @@ -0,0 +1,15 @@ +export type SessionValidationResult = + | { session: Session; user: User } + | { session: null; user: null }; + +export interface Session { + id: string; + userId: string; + expiresAt: Date; +} + +export interface User { + id: string; + name: string; + avatarHash: string; +} diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts new file mode 100644 index 0000000..11256e0 --- /dev/null +++ b/src/lib/auth/session.ts @@ -0,0 +1,76 @@ +import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from "@oslojs/encoding"; +import { sha256 } from "@oslojs/crypto/sha2"; +import type { Session, SessionValidationResult, User } from "."; + +interface ValidateResponse { + id: string; + user_id: string; + expires_at: number; + name: string; + avatar_hash: string; +} + +export function generateSessionToken(): string { + const bytes = new Uint8Array(40); + crypto.getRandomValues(bytes); + return encodeBase32LowerCaseNoPadding(bytes); +} + +export async function createSession( + token: string, + userId: string, + db: D1Database, +): Promise { + const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); + const session: Session = { + id: sessionId, + userId, + expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), // 7 days + }; + + await db + .prepare("INSERT INTO session (id, user_id, expires_at) VALUES (?, ?, ?)") + .bind(session.id, session.userId, Math.floor(session.expiresAt.getTime() / 1000)) + .run(); + + return session; +} + +export async function validateSessionToken( + token: string, + db: D1Database, +): Promise { + const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); + + const row = await db + .prepare( + "SELECT session.id, session.user_id, session.expires_at, user.name, user.avatar_hash FROM session INNER JOIN user ON user.id = session.user_id WHERE session.id = ?", + ) + .bind(sessionId) + .first(); + + if (row === null) return { session: null, user: null }; + + const session: Session = { + id: row.id, + userId: row.user_id, + expiresAt: new Date(row.expires_at * 1000), + }; + + const user: User = { + id: row.user_id, + name: row.name, + avatarHash: row.avatar_hash, + }; + + if (Date.now() >= session.expiresAt.getTime()) { + await invalidateSession(sessionId, db); + return { session: null, user: null }; + } else { + return { session, user }; + } +} + +export async function invalidateSession(sessionId: string, db: D1Database): Promise { + await db.prepare("DELETE FROM session WHERE id = ?").bind(sessionId).run(); +} diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts new file mode 100644 index 0000000..353be24 --- /dev/null +++ b/src/routes/+page.server.ts @@ -0,0 +1,5 @@ +import type { PageServerLoad } from "./$types"; + +export const load: PageServerLoad = async ({ locals }) => { + return { user: locals.user }; +}; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 271c903..267ec96 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,9 +1,29 @@

TCL-Guessr

+ + + +