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

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