From e1f927ad2792d332e15e6b73cab5d574d3636e14 Mon Sep 17 00:00:00 2001 From: Kenneth Date: Sun, 4 May 2025 23:14:01 +0100 Subject: [PATCH] initial server and auth implementation --- bun.lockb | Bin 263676 -> 267244 bytes package.json | 93 ++++++++++--------- src/app/$username/bookmarks.tsx | 2 +- src/auth/auth.ts | 160 ++++++++++++++++++++++++++++++++ src/auth/session.ts | 134 ++++++++++++++++++++++++++ src/{ => bookmark}/bookmark.ts | 0 src/bookmark/handlers.ts | 35 +++++++ src/database.ts | 62 +++++++++++++ src/server-main.ts | 27 ++++++ src/server-util.ts | 30 ++++++ src/user/user.ts | 67 +++++++++++++ tsconfig.app.json | 1 + 12 files changed, 567 insertions(+), 44 deletions(-) create mode 100644 src/auth/auth.ts create mode 100644 src/auth/session.ts rename src/{ => bookmark}/bookmark.ts (100%) create mode 100644 src/bookmark/handlers.ts create mode 100644 src/database.ts create mode 100644 src/server-main.ts create mode 100644 src/server-util.ts create mode 100644 src/user/user.ts diff --git a/bun.lockb b/bun.lockb index c577741e8d734b2b9e7e68fc765996b1bd84c1cd..14358ce9fa66f809c13b83826c8f72f4fcf20cd4 100755 GIT binary patch delta 52224 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 iOv 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 delta 50359 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 T=wzI|k=Cq{8k2tw( + handler: (request: Bun.BunRequest, user: User) => Promise, +) { + return httpHandler((request) => { + const session = verifySession(request.cookies); + if (!session) { + throw new HttpError(401); + } + + const user = findUserById(session.userId); + if (!user) { + throw new HttpError(401); + } + + const authTokenCookie = request.cookies.get("auth-token"); + if (authTokenCookie) { + const tokenId = authTokenCookie.split(":")[0]; + deleteAuthTokenQuery.run({ id: tokenId }); + rememberLoginForUser(user, request.cookies); + } + + const extendedSession = extendSession(session); + saveSession(extendedSession, request.cookies); + + return handler(request, user); + }); +} + +function rememberLoginForUser(user: User, cookies: Bun.CookieMap) { + const tokenId = ulid(); + + const authToken = Buffer.alloc(32); + crypto.getRandomValues(authToken); + + const hasher = new Bun.CryptoHasher("sha256"); + hasher.update(authToken); + const hashedToken = hasher.digest("base64url"); + + const expiryDate = dayjs().add(1, "month"); + + createAuthTokenQuery.run({ + id: tokenId, + token: hashedToken, + userId: user.id, + expiresAt: expiryDate.valueOf(), + }); + + cookies.set( + "auth-token", + `${tokenId}:${authToken.toBase64({ alphabet: "base64url" })}`, + { maxAge: 30 * 24 * 60 * 60 * 1000, httpOnly: true }, + ); +} + +async function signUp(request: Bun.BunRequest<"/api/sign-up">) { + const body = await request.json().catch(() => { + throw new HttpError(500); + }); + + const signUpRequest = SignUpRequest(body); + if (signUpRequest instanceof type.errors) { + throw new HttpError(400, signUpRequest.summary); + } + + const { username, password } = signUpRequest; + const hashedPassword = await Bun.password.hash(password, "argon2id"); + const user = createUser(username, hashedPassword); + + await createSessionForUser(user, request.cookies); + rememberLoginForUser(user, request.cookies); + + return Response.json(user, { status: 200 }); +} + +async function login(request: Bun.BunRequest<"/api/login">) { + const body = await request.json().catch(() => { + throw new HttpError(500); + }); + + const loginRequest = LoginRequest(body); + if (loginRequest instanceof type.errors) { + throw new HttpError(400, loginRequest.summary); + } + + const foundUser = findUserByUsername(loginRequest.username, { + password: true, + }); + if (!foundUser) { + throw new HttpError(400); + } + + const ok = await Bun.password + .verify(loginRequest.password, foundUser.password, "argon2id") + .catch(() => { + throw new HttpError(401); + }); + if (!ok) { + throw new HttpError(401); + } + + const user: User = { + id: foundUser.id, + username: foundUser.username, + }; + + await createSessionForUser(user, request.cookies); + rememberLoginForUser(user, request.cookies); + + return Response.json(user, { status: 200 }); +} + +async function logout( + request: Bun.BunRequest<"/api/logout">, + user: User, +): Promise { + forgetAllSessions(user); + deleteAllAuthTokensQuery.run({ userId: user.id }); + return new Response(undefined, { status: 200 }); +} + +export { authenticated, signUp, login, logout }; diff --git a/src/auth/session.ts b/src/auth/session.ts new file mode 100644 index 0000000..7319f51 --- /dev/null +++ b/src/auth/session.ts @@ -0,0 +1,134 @@ +import uid from "uid-safe"; +import dayjs from "dayjs"; +import { db } from "~/database"; +import type { User } from "~/user/user"; + +interface Session { + id: string; + signedId: string; + userId: string; + durationMs: number; + expiresAt: number; +} + +const SESSION_ID_BYTE_LENGTH = 24; +const SESSION_ID_COOKIE_NAME = "session-id"; +const SESSION_DURATION_MS = 30 * 60 * 1000; + +const findSessionQuery = db.query( + "SELECT user_id, expires_at_unix FROM sessions WHERE session_id = $sessionId", +); + +const deleteSessionQuery = db.query( + "DELETE FROM sessions WHERE session_id = $sessionId", +); + +const forgetAllSessionsQuery = db.query( + "DELETE FROM sessions WHERE user_id = $userId", +); + +const saveSessionQuery = db.query( + "INSERT INTO sessions(session_id, user_id, expires_at_unix_ms) VALUES ($sessionId, $userId, $expiresAt)", +); + +const extendSessionQuery = db.query( + "UPDATE sessions SET expires_at_unix_ms = $newExpiryDate WHERE session_id = $session_id", +); + +async function newSessionId(): Promise { + return await uid(SESSION_ID_BYTE_LENGTH); +} + +function signSessionId(sessionId: string): string { + const hasher = new Bun.CryptoHasher("sha256", Bun.env.SESSION_SECRET); + hasher.update(sessionId); + return `${sessionId}.${hasher.digest("base64url")}`; +} + +async function createSessionForUser(user: User, cookies: Bun.CookieMap) { + const sessionId = await newSessionId(); + const signedSessionId = signSessionId(sessionId); + + const expiryDate = dayjs().add(30, "minutes").valueOf(); + + saveSessionQuery.run({ + sessionId, + userId: user.id, + expiresAt: expiryDate, + }); + + cookies.set(SESSION_ID_COOKIE_NAME, signedSessionId, { + maxAge: SESSION_DURATION_MS, + httpOnly: true, + }); +} + +async function saveSession(session: Session, cookies: Bun.CookieMap) { + cookies.set(SESSION_ID_COOKIE_NAME, session.signedId, { + maxAge: SESSION_DURATION_MS, + httpOnly: true, + }); +} + +function verifySession(cookie: Bun.CookieMap): Session | null { + const signedSessionId = cookie.get(SESSION_ID_COOKIE_NAME); + if (!signedSessionId) { + return null; + } + + const value = signedSessionId.slice(0, signedSessionId.lastIndexOf(".")); + const expected = signSessionId(value); + + const a = Buffer.from(signedSessionId); + const b = Buffer.from(expected); + + const isEqual = a.length === b.length && crypto.timingSafeEqual(a, b); + if (!isEqual) { + return null; + } + + const row = findSessionQuery.get({ sessionId: value }); + if (!row) { + return null; + } + const foundSession = row as { user_id: string; expires_at_unix_ms: number }; + + const now = dayjs().valueOf(); + if (now > foundSession.expires_at_unix_ms) { + deleteSessionQuery.run({ sessionId: value }); + return null; + } + + return { + id: value, + signedId: signedSessionId, + userId: foundSession.user_id, + expiresAt: foundSession.expires_at_unix_ms, + durationMs: SESSION_DURATION_MS, + }; +} + +function extendSession(session: Session): Session { + const newExpiryDate = dayjs().add(30, "minutes").valueOf(); + extendSessionQuery.run({ + sessionId: session.id, + newExpiryDate, + }); + return { + ...session, + expiresAt: newExpiryDate, + }; +} + +function forgetAllSessions(user: User) { + forgetAllSessionsQuery.run({ userId: user.id }); +} + +export { + newSessionId, + createSessionForUser, + verifySession, + saveSession, + extendSession, + forgetAllSessions, +}; diff --git a/src/bookmark.ts b/src/bookmark/bookmark.ts similarity index 100% rename from src/bookmark.ts rename to src/bookmark/bookmark.ts diff --git a/src/bookmark/handlers.ts b/src/bookmark/handlers.ts new file mode 100644 index 0000000..c926dbe --- /dev/null +++ b/src/bookmark/handlers.ts @@ -0,0 +1,35 @@ +import type { User } from "~/user/user"; +import { type } from "arktype"; +import { HttpError } from "~/server"; +import { db } from "~/database"; + +const BOOKMARK_PAGINATION_LIMIT = 100; + +const ListUserBookmarksParams = type({ + limit: ["number", "=", BOOKMARK_PAGINATION_LIMIT], + skip: ["number", "=", 5], +}); + +const listBookmarksQuery = db.query( + "SELECT id, kind, title, url FROM bookmarks WHERE user_id = $userId LIMIT $limit OFFSET $skip", +); + +async function listUserBookmarks( + request: Bun.BunRequest<"/api/bookmarks">, + user: User, +) { + const queryParams = ListUserBookmarksParams(request.params); + if (queryParams instanceof type.errors) { + throw new HttpError(400, queryParams.summary); + } + + const results = listBookmarksQuery.all({ + userId: user.id, + limit: queryParams.limit, + skip: queryParams.skip, + }); + + return Response.json(results, { status: 200 }); +} + +export { listUserBookmarks }; diff --git a/src/database.ts b/src/database.ts new file mode 100644 index 0000000..0704e81 --- /dev/null +++ b/src/database.ts @@ -0,0 +1,62 @@ +import { Database } from "bun:sqlite"; + +const DB_VERSION = 0; + +const db = new Database("data.sqlite"); + +const dbVersionQuery = db.query("SELECT version FROM migration"); + +const migrations = [ + ` +CREATE TABLE IF NOT EXISTS migration( + version INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS users( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password TEXT NOT NULL, +); + +CREATE TABLE IF NOT EXISTS bookmarks( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + kind TEXT NOT NULL, + title TEXT NOT NULL, + url TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS sessions( + session_id TEXT NOT NULL, + user_id TEXT NOT NULL, + expires_at_unix_ms INTEGER NOT NULL, + PRIMARY KEY (session_id, user_id) +); + +CREATE TABLE IF NOT EXISTS auth_tokens( + id TEXT PRIMARY KEY, + token TEXT NOT NULL, + user_id TEXT NOT NULL, + expires_at_unix_ms INTEGER NOT NULL +); +`, +]; + +function migrateDatabase() { + let row = dbVersionQuery.get(); + let currentVersion: number; + if (row) { + currentVersion = (row as { version: number }).version; + console.log( + `Migrating database from version ${currentVersion} to version ${DB_VERSION}...`, + ); + } else { + currentVersion = -1; + console.log("Initializing database..."); + } + for (let version = currentVersion + 1; version <= DB_VERSION; ++version) { + db.run(migrations[version]); + } +} + +export { db, migrateDatabase }; diff --git a/src/server-main.ts b/src/server-main.ts new file mode 100644 index 0000000..d8b0d39 --- /dev/null +++ b/src/server-main.ts @@ -0,0 +1,27 @@ +import { authenticated, signUp, login, logout } from "./auth/auth"; +import { listUserBookmarks } from "./bookmark/handlers"; +import { migrateDatabase } from "./database"; +import { httpHandler } from "./server-util"; + +function main() { + migrateDatabase(); + + Bun.serve({ + routes: { + "/api/login": { + POST: httpHandler(login), + }, + "/api/sign-up": { + POST: httpHandler(signUp), + }, + "/api/logout": { + POST: authenticated(logout), + }, + "/api/bookmarks": { + GET: authenticated(listUserBookmarks), + }, + }, + }); +} + +main(); diff --git a/src/server-util.ts b/src/server-util.ts new file mode 100644 index 0000000..7a4ba95 --- /dev/null +++ b/src/server-util.ts @@ -0,0 +1,30 @@ +class HttpError { + constructor( + public readonly status: number, + public readonly message?: string, + ) {} +} + +function httpHandler( + handler: (request: Bun.BunRequest) => Promise, +): (request: Bun.BunRequest) => Promise { + return async (request) => { + try { + const response = await handler(request); + return response; + } catch (error) { + if (error instanceof HttpError) { + if (error.message) { + return Response.json( + { message: error.message }, + { status: error.status }, + ); + } + return new Response(undefined, { status: error.status }); + } + return new Response(undefined, { status: 500 }); + } + }; +} + +export { HttpError, httpHandler }; diff --git a/src/user/user.ts b/src/user/user.ts new file mode 100644 index 0000000..1220085 --- /dev/null +++ b/src/user/user.ts @@ -0,0 +1,67 @@ +import { ulid } from "ulid"; +import { db } from "~/database"; + +interface User { + id: string; + username: string; +} + +interface UserWithPassword extends User { + password: string; +} + +const findUserByIdQuery = db.query( + "SELECT id, username FROM users WHERE id = $userId", +); + +const findUserByUsernameQuery = db.query( + "SELECT id, username FROM users WHERE username = $username", +); +const findUserByUsernameWithPwQuery = db.query( + "SELECT id, username, password FROM users WHERE username = $username", +); + +const createUserQuery = db.query( + "INSERT INTO users(id, username, password) VALUES ($id, $username, $password)", +); + +function findUserByUsername( + username: string, + opts: { password: true }, +): UserWithPassword | null; +function findUserByUsername( + username: string, + { password }: { password?: boolean }, +): User | UserWithPassword | null { + const row = ( + password ? findUserByUsernameWithPwQuery : findUserByUsernameQuery + ).get({ username }); + if (!row) { + return null; + } + return row as User; +} + +function findUserById(userId: string): User | null { + const row = findUserByIdQuery.get({ userId }); + if (!row) { + return null; + } + return row as User; +} + +function createUser(username: string, password: string): User { + const userId = ulid(); + createUserQuery.run({ + id: userId, + username, + password, + }); + return { + id: userId, + username, + }; +} + +export type { User }; +export { createUser, findUserByUsername, findUserById }; diff --git a/tsconfig.app.json b/tsconfig.app.json index 05ec3c8..1e36d59 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -6,6 +6,7 @@ "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, + "experimentalDecorators": true, /* Bundler mode */ "moduleResolution": "bundler",