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:
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user