From 3c26dfe2c08f5a3dec0678efbee89d37dccd05a7 Mon Sep 17 00:00:00 2001 From: Admin Date: Tue, 3 Mar 2026 14:03:11 +0500 Subject: [PATCH] feat(auth): add user authentication with roles, HMAC-signed cookies, and login/register UI - Add `users` PocketBase collection (username, password_hash, role, created) - Implement HMAC-SHA256 signed cookie auth in hooks.server.ts; token payload is userId:username:role - Add User type, getUserByUsername, createUser (scrypt), loginUser (timing-safe) to pocketbase.ts - Add login/register page with tabbed form UI and server actions - Add logout route that clears the auth cookie - Add layout.server.ts auth guard: redirect unauthenticated users to /login - Extend App.Locals and App.PageData with role field - Add AUTH_SECRET, POCKETBASE_ADMIN_EMAIL/PASSWORD to .env.example - Install @types/node for Node crypto/scrypt types --- scraper/internal/storage/pocketbase.go | 11 ++ ui/.env.example | 7 ++ ui/package-lock.json | 18 ++++ ui/package.json | 1 + ui/src/app.d.ts | 5 +- ui/src/hooks.server.ts | 72 ++++++++++++- ui/src/lib/server/pocketbase.ts | 69 +++++++++++++ ui/src/routes/+layout.server.ts | 15 +++ ui/src/routes/login/+page.server.ts | 101 ++++++++++++++++++ ui/src/routes/login/+page.svelte | 136 +++++++++++++++++++++++++ ui/src/routes/logout/+page.server.ts | 11 ++ 11 files changed, 441 insertions(+), 5 deletions(-) create mode 100644 ui/src/routes/+layout.server.ts create mode 100644 ui/src/routes/login/+page.server.ts create mode 100644 ui/src/routes/login/+page.svelte create mode 100644 ui/src/routes/logout/+page.server.ts diff --git a/scraper/internal/storage/pocketbase.go b/scraper/internal/storage/pocketbase.go index 8a34ae3..336d21b 100644 --- a/scraper/internal/storage/pocketbase.go +++ b/scraper/internal/storage/pocketbase.go @@ -9,6 +9,7 @@ // ranking_html — page(number,unique), html(text), updated(date) // progress — session_id(text), slug(text), chapter(number), updated(date) // audio_cache — cache_key(text,unique), filename(text), updated(date) +// users — username(text,unique), password_hash(text), role(text), created(date) package storage import ( @@ -312,6 +313,16 @@ func (s *PocketBaseStore) EnsureCollections(ctx context.Context) error { {"name": "updated", "type": "date"}, }, }, + { + "name": "users", + "type": "base", + "schema": []map[string]interface{}{ + {"name": "username", "type": "text", "required": true, "options": map[string]interface{}{"min": 3, "max": 32}}, + {"name": "password_hash", "type": "text", "required": true}, + {"name": "role", "type": "text"}, + {"name": "created", "type": "date"}, + }, + }, } for _, col := range collections { resp, err := s.pb.do(ctx, http.MethodPost, "/api/collections", col) diff --git a/ui/.env.example b/ui/.env.example index 5ac0c97..a887a51 100644 --- a/ui/.env.example +++ b/ui/.env.example @@ -8,6 +8,13 @@ SCRAPER_API_URL=http://localhost:8080 # Public URL of PocketBase (used by SvelteKit server-side load functions) POCKETBASE_URL=http://localhost:8090 +# PocketBase admin credentials (server-side only, never exposed to browser) +POCKETBASE_ADMIN_EMAIL=admin@libnovel.local +POCKETBASE_ADMIN_PASSWORD=changeme123 + # Public-facing MinIO URL (used to rewrite presigned URLs for the browser) # In dev this is localhost; in prod set to your MinIO public domain PUBLIC_MINIO_PUBLIC_URL=http://localhost:9000 + +# Secret used to sign auth tokens stored in cookies (generate with: openssl rand -hex 32) +AUTH_SECRET=change_this_to_a_long_random_secret diff --git a/ui/package-lock.json b/ui/package-lock.json index af9008e..dfa3bec 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -17,6 +17,7 @@ "@sveltejs/kit": "^2.50.2", "@sveltejs/vite-plugin-svelte": "^6.2.4", "@tailwindcss/vite": "^4.2.1", + "@types/node": "^25.3.3", "svelte": "^5.51.0", "svelte-check": "^4.4.2", "tailwindcss": "^4.2.1", @@ -1389,6 +1390,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "25.3.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz", + "integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, "node_modules/@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", @@ -2356,6 +2367,13 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", diff --git a/ui/package.json b/ui/package.json index 01bca59..044f02b 100644 --- a/ui/package.json +++ b/ui/package.json @@ -17,6 +17,7 @@ "@sveltejs/kit": "^2.50.2", "@sveltejs/vite-plugin-svelte": "^6.2.4", "@tailwindcss/vite": "^4.2.1", + "@types/node": "^25.3.3", "svelte": "^5.51.0", "svelte-check": "^4.4.2", "tailwindcss": "^4.2.1", diff --git a/ui/src/app.d.ts b/ui/src/app.d.ts index acf043c..f85fed0 100644 --- a/ui/src/app.d.ts +++ b/ui/src/app.d.ts @@ -5,8 +5,11 @@ declare global { // interface Error {} interface Locals { sessionId: string; + user: { id: string; username: string; role: string } | null; + } + interface PageData { + user?: { id: string; username: string; role: string } | null; } - // interface PageData {} // interface PageState {} // interface Platform {} } diff --git a/ui/src/hooks.server.ts b/ui/src/hooks.server.ts index dfe3870..5f500ee 100644 --- a/ui/src/hooks.server.ts +++ b/ui/src/hooks.server.ts @@ -1,12 +1,72 @@ import type { Handle } from '@sveltejs/kit'; -import { randomBytes } from 'node:crypto'; +import { randomBytes, createHmac } from 'node:crypto'; +import { env } from '$env/dynamic/private'; const SESSION_COOKIE = 'libnovel_session'; +const AUTH_COOKIE = 'libnovel_auth'; const ONE_YEAR = 60 * 60 * 24 * 365; -export const handle: Handle = async ({ event, resolve }) => { - let sessionId = event.cookies.get(SESSION_COOKIE); +const AUTH_SECRET = env.AUTH_SECRET ?? 'dev_secret_change_in_production'; +// ─── Token helpers ──────────────────────────────────────────────────────────── + +/** + * Sign a payload string with HMAC-SHA256 using AUTH_SECRET. + * Returns ".". + */ +export function signToken(payload: string): string { + const sig = createHmac('sha256', AUTH_SECRET).update(payload).digest('hex'); + return `${payload}.${sig}`; +} + +/** + * Verify a signed token. Returns the payload string on success, null on failure. + */ +export function verifyToken(token: string): string | null { + const lastDot = token.lastIndexOf('.'); + if (lastDot < 0) return null; + const payload = token.slice(0, lastDot); + const expected = createHmac('sha256', AUTH_SECRET).update(payload).digest('hex'); + const actual = token.slice(lastDot + 1); + // constant-time comparison + if (expected.length !== actual.length) return null; + let diff = 0; + for (let i = 0; i < expected.length; i++) { + diff |= expected.charCodeAt(i) ^ actual.charCodeAt(i); + } + return diff === 0 ? payload : null; +} + +/** + * Create a signed auth token for a user. + * Payload format: "::" + */ +export function createAuthToken(userId: string, username: string, role: string): string { + return signToken(`${userId}:${username}:${role}`); +} + +/** + * Parse a verified auth token into user data. Returns null if invalid. + */ +export function parseAuthToken(token: string): { id: string; username: string; role: string } | null { + const payload = verifyToken(token); + if (!payload) return null; + const firstColon = payload.indexOf(':'); + if (firstColon < 0) return null; + const secondColon = payload.indexOf(':', firstColon + 1); + if (secondColon < 0) return null; + const id = payload.slice(0, firstColon); + const username = payload.slice(firstColon + 1, secondColon); + const role = payload.slice(secondColon + 1); + if (!id || !username) return null; + return { id, username, role }; +} + +// ─── Hook ───────────────────────────────────────────────────────────────────── + +export const handle: Handle = async ({ event, resolve }) => { + // Anonymous session cookie (for reading progress) + let sessionId = event.cookies.get(SESSION_COOKIE) ?? ''; if (!sessionId) { sessionId = randomBytes(16).toString('hex'); event.cookies.set(SESSION_COOKIE, sessionId, { @@ -16,8 +76,12 @@ export const handle: Handle = async ({ event, resolve }) => { maxAge: ONE_YEAR }); } - event.locals.sessionId = sessionId; + // Auth cookie → resolve logged-in user + const authToken = event.cookies.get(AUTH_COOKIE); + event.locals.user = authToken ? parseAuthToken(authToken) : null; + return resolve(event); }; + diff --git a/ui/src/lib/server/pocketbase.ts b/ui/src/lib/server/pocketbase.ts index c45e66c..4eaf6b3 100644 --- a/ui/src/lib/server/pocketbase.ts +++ b/ui/src/lib/server/pocketbase.ts @@ -42,6 +42,14 @@ export interface Progress { updated: string; } +export interface User { + id: string; + username: string; + password_hash: string; + role: string; + created: string; +} + // ─── Auth token cache ───────────────────────────────────────────────────────── let _token = ''; @@ -167,3 +175,64 @@ export async function setProgress(sessionId: string, slug: string, chapter: numb await pbPost('/api/collections/progress/records', payload); } } + +// ─── Users ──────────────────────────────────────────────────────────────────── + +import { scryptSync, randomBytes, timingSafeEqual } from 'node:crypto'; + +function hashPassword(password: string): string { + const salt = randomBytes(16).toString('hex'); + const hash = scryptSync(password, salt, 64).toString('hex'); + return `${salt}:${hash}`; +} + +function verifyPassword(password: string, stored: string): boolean { + const [salt, hash] = stored.split(':'); + if (!salt || !hash) return false; + const derived = scryptSync(password, salt, 64); + const hashBuf = Buffer.from(hash, 'hex'); + if (derived.length !== hashBuf.length) return false; + return timingSafeEqual(derived, hashBuf); +} + +/** + * Look up a user by username. Returns null if not found. + */ +export async function getUserByUsername(username: string): Promise { + return listOne('users', `username="${username.replace(/"/g, '\\"')}"`); +} + +/** + * Create a new user with a hashed password. Throws if username already exists. + */ +export async function createUser(username: string, password: string, role = 'user'): Promise { + const existing = await getUserByUsername(username); + if (existing) { + throw new Error('Username already taken'); + } + const password_hash = hashPassword(password); + const res = await pbPost('/api/collections/users/records', { + username, + password_hash, + role, + created: new Date().toISOString() + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`Failed to create user: ${res.status} ${body}`); + } + return res.json() as Promise; +} + +/** + * Verify username + password. Returns the user on success, null on failure. + */ +export async function loginUser( + username: string, + password: string +): Promise { + const user = await getUserByUsername(username); + if (!user) return null; + if (!verifyPassword(password, user.password_hash)) return null; + return user; +} diff --git a/ui/src/routes/+layout.server.ts b/ui/src/routes/+layout.server.ts new file mode 100644 index 0000000..bd5cbf5 --- /dev/null +++ b/ui/src/routes/+layout.server.ts @@ -0,0 +1,15 @@ +import { redirect } from '@sveltejs/kit'; +import type { LayoutServerLoad } from './$types'; + +// Routes that are accessible without being logged in +const PUBLIC_ROUTES = new Set(['/login']); + +export const load: LayoutServerLoad = async ({ locals, url }) => { + if (!PUBLIC_ROUTES.has(url.pathname) && !locals.user) { + redirect(302, `/login`); + } + + return { + user: locals.user + }; +}; diff --git a/ui/src/routes/login/+page.server.ts b/ui/src/routes/login/+page.server.ts new file mode 100644 index 0000000..3bde2d1 --- /dev/null +++ b/ui/src/routes/login/+page.server.ts @@ -0,0 +1,101 @@ +import { fail, redirect } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; +import { loginUser, createUser } from '$lib/server/pocketbase'; +import { createAuthToken } from '../../hooks.server'; + +const AUTH_COOKIE = 'libnovel_auth'; +const ONE_YEAR = 60 * 60 * 24 * 365; + +export const load: PageServerLoad = async ({ locals }) => { + // Already logged in — send to library + if (locals.user) { + redirect(302, '/books'); + } + return {}; +}; + +export const actions: Actions = { + login: async ({ request, cookies }) => { + const data = await request.formData(); + const username = (data.get('username') as string | null)?.trim() ?? ''; + const password = (data.get('password') as string | null) ?? ''; + + if (!username || !password) { + return fail(400, { action: 'login', error: 'Username and password are required.' }); + } + + let user; + try { + user = await loginUser(username, password); + } catch { + return fail(500, { action: 'login', error: 'An error occurred. Please try again.' }); + } + + if (!user) { + return fail(401, { action: 'login', error: 'Invalid username or password.' }); + } + + const token = createAuthToken(user.id, user.username, user.role ?? 'user'); + cookies.set(AUTH_COOKIE, token, { + path: '/', + httpOnly: true, + sameSite: 'lax', + maxAge: ONE_YEAR + }); + + redirect(302, '/books'); + }, + + register: async ({ request, cookies }) => { + const data = await request.formData(); + const username = (data.get('username') as string | null)?.trim() ?? ''; + const password = (data.get('password') as string | null) ?? ''; + const confirm = (data.get('confirm') as string | null) ?? ''; + + if (!username || !password) { + return fail(400, { action: 'register', error: 'Username and password are required.' }); + } + if (username.length < 3 || username.length > 32) { + return fail(400, { + action: 'register', + error: 'Username must be between 3 and 32 characters.' + }); + } + if (!/^[a-zA-Z0-9_-]+$/.test(username)) { + return fail(400, { + action: 'register', + error: 'Username may only contain letters, numbers, underscores and hyphens.' + }); + } + if (password.length < 8) { + return fail(400, { + action: 'register', + error: 'Password must be at least 8 characters.' + }); + } + if (password !== confirm) { + return fail(400, { action: 'register', error: 'Passwords do not match.' }); + } + + let user; + try { + user = await createUser(username, password); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Registration failed.'; + if (msg.includes('Username already taken')) { + return fail(409, { action: 'register', error: 'That username is already taken.' }); + } + return fail(500, { action: 'register', error: 'An error occurred. Please try again.' }); + } + + const token = createAuthToken(user.id, user.username, user.role ?? 'user'); + cookies.set(AUTH_COOKIE, token, { + path: '/', + httpOnly: true, + sameSite: 'lax', + maxAge: ONE_YEAR + }); + + redirect(302, '/books'); + } +}; diff --git a/ui/src/routes/login/+page.svelte b/ui/src/routes/login/+page.svelte new file mode 100644 index 0000000..77a5a13 --- /dev/null +++ b/ui/src/routes/login/+page.svelte @@ -0,0 +1,136 @@ + + + + Sign in — libnovel + + +
+
+ +
+ + +
+ + {#if form?.error && (form?.action === mode || !form?.action)} +
+ {form.error} +
+ {/if} + + {#if mode === 'login'} +
+
+ + +
+
+ + +
+ +
+ {:else} +
+
+ + +

3–32 characters: letters, numbers, _ or -

+
+
+ + +

At least 8 characters

+
+
+ + +
+ +
+ {/if} +
+
diff --git a/ui/src/routes/logout/+page.server.ts b/ui/src/routes/logout/+page.server.ts new file mode 100644 index 0000000..9af7c4b --- /dev/null +++ b/ui/src/routes/logout/+page.server.ts @@ -0,0 +1,11 @@ +import { redirect } from '@sveltejs/kit'; +import type { Actions } from './$types'; + +const AUTH_COOKIE = 'libnovel_auth'; + +export const actions: Actions = { + default: async ({ cookies }) => { + cookies.delete(AUTH_COOKIE, { path: '/' }); + redirect(302, '/login'); + } +};