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
This commit is contained in:
Admin
2026-03-03 14:03:11 +05:00
parent 1820fa7303
commit 3c26dfe2c0
11 changed files with 441 additions and 5 deletions

View File

@@ -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)

View File

@@ -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

18
ui/package-lock.json generated
View File

@@ -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",

View File

@@ -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",

5
ui/src/app.d.ts vendored
View File

@@ -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 {}
}

View File

@@ -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 "<payload>.<signature>".
*/
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: "<userId>:<username>:<role>"
*/
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);
};

View File

@@ -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<User | null> {
return listOne<User>('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<User> {
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<User>;
}
/**
* Verify username + password. Returns the user on success, null on failure.
*/
export async function loginUser(
username: string,
password: string
): Promise<User | null> {
const user = await getUserByUsername(username);
if (!user) return null;
if (!verifyPassword(password, user.password_hash)) return null;
return user;
}

View File

@@ -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
};
};

View File

@@ -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');
}
};

View File

@@ -0,0 +1,136 @@
<script lang="ts">
import type { ActionData } from './$types';
let { form }: { form: ActionData } = $props();
let mode: 'login' | 'register' = $state('login');
</script>
<svelte:head>
<title>Sign in — libnovel</title>
</svelte:head>
<div class="flex items-center justify-center min-h-[60vh]">
<div class="w-full max-w-sm">
<!-- Tab switcher -->
<div class="flex mb-6 border-b border-zinc-700">
<button
type="button"
onclick={() => (mode = 'login')}
class="flex-1 pb-3 text-sm font-medium transition-colors
{mode === 'login'
? 'text-amber-400 border-b-2 border-amber-400 -mb-px'
: 'text-zinc-400 hover:text-zinc-100'}"
>
Sign in
</button>
<button
type="button"
onclick={() => (mode = 'register')}
class="flex-1 pb-3 text-sm font-medium transition-colors
{mode === 'register'
? 'text-amber-400 border-b-2 border-amber-400 -mb-px'
: 'text-zinc-400 hover:text-zinc-100'}"
>
Create account
</button>
</div>
{#if form?.error && (form?.action === mode || !form?.action)}
<div class="mb-4 rounded bg-red-900/40 border border-red-700 px-4 py-3 text-sm text-red-300">
{form.error}
</div>
{/if}
{#if mode === 'login'}
<form method="POST" action="?/login" class="flex flex-col gap-4">
<div>
<label for="login-username" class="block text-xs text-zinc-400 mb-1">Username</label>
<input
id="login-username"
name="username"
type="text"
autocomplete="username"
required
class="w-full rounded bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100
placeholder-zinc-500 focus:outline-none focus:border-amber-400 focus:ring-1 focus:ring-amber-400"
placeholder="your_username"
/>
</div>
<div>
<label for="login-password" class="block text-xs text-zinc-400 mb-1">Password</label>
<input
id="login-password"
name="password"
type="password"
autocomplete="current-password"
required
class="w-full rounded bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100
placeholder-zinc-500 focus:outline-none focus:border-amber-400 focus:ring-1 focus:ring-amber-400"
placeholder="••••••••"
/>
</div>
<button
type="submit"
class="w-full py-2 rounded bg-amber-400 text-zinc-900 font-semibold text-sm hover:bg-amber-300 transition-colors"
>
Sign in
</button>
</form>
{:else}
<form method="POST" action="?/register" class="flex flex-col gap-4">
<div>
<label for="reg-username" class="block text-xs text-zinc-400 mb-1">Username</label>
<input
id="reg-username"
name="username"
type="text"
autocomplete="username"
required
minlength="3"
maxlength="32"
pattern="[a-zA-Z0-9_\-]+"
class="w-full rounded bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100
placeholder-zinc-500 focus:outline-none focus:border-amber-400 focus:ring-1 focus:ring-amber-400"
placeholder="your_username"
/>
<p class="mt-1 text-xs text-zinc-500">332 characters: letters, numbers, _ or -</p>
</div>
<div>
<label for="reg-password" class="block text-xs text-zinc-400 mb-1">Password</label>
<input
id="reg-password"
name="password"
type="password"
autocomplete="new-password"
required
minlength="8"
class="w-full rounded bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100
placeholder-zinc-500 focus:outline-none focus:border-amber-400 focus:ring-1 focus:ring-amber-400"
placeholder="••••••••"
/>
<p class="mt-1 text-xs text-zinc-500">At least 8 characters</p>
</div>
<div>
<label for="reg-confirm" class="block text-xs text-zinc-400 mb-1">Confirm password</label>
<input
id="reg-confirm"
name="confirm"
type="password"
autocomplete="new-password"
required
class="w-full rounded bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100
placeholder-zinc-500 focus:outline-none focus:border-amber-400 focus:ring-1 focus:ring-amber-400"
placeholder="••••••••"
/>
</div>
<button
type="submit"
class="w-full py-2 rounded bg-amber-400 text-zinc-900 font-semibold text-sm hover:bg-amber-300 transition-colors"
>
Create account
</button>
</form>
{/if}
</div>
</div>

View File

@@ -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');
}
};