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

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