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