feat(auth): add reader accounts and private favorites

This commit is contained in:
Christian Johannesen 2026-10-18 17:21:34 +02:00
parent eaf237a502
commit 215d943127
2 changed files with 54 additions and 4 deletions

View file

@ -25,4 +25,16 @@ describe('API', () => {
expect(login.json().csrfToken).toBeTruthy(); expect(login.json().csrfToken).toBeTruthy();
await app.close(); await app.close();
}); });
it('registers a reader and stores private favorites', async () => {
const app = await buildApp({ sqlite: context.sqlite, sessionSecret: 'a-secret-with-at-least-thirty-two-characters' });
const register = await app.inject({ method: 'POST', url: '/api/v1/auth/register', payload: { username: 'reader_one', password: 'correct horse' } });
expect(register.statusCode).toBe(200);
const cookie = register.cookies.find((item) => item.name === 'tech_library_user')!;
const favorite = await app.inject({ method: 'PUT', url: '/api/v1/favorites/typescript', headers: { cookie: `${cookie.name}=${cookie.value}`, 'x-csrf-token': register.json().csrfToken } });
expect(favorite.statusCode).toBe(200);
const list = await app.inject({ method: 'GET', url: '/api/v1/favorites?lang=da', headers: { cookie: `${cookie.name}=${cookie.value}` } });
expect(list.json()[0]).toMatchObject({ slug: 'typescript', language: 'da', isFavorite: true });
await app.close();
});
}); });

View file

@ -7,7 +7,7 @@ import { randomBytes, randomUUID } from 'node:crypto';
import type Database from 'better-sqlite3'; import type Database from 'better-sqlite3';
import { LibraryRepository } from './repository.js'; import { LibraryRepository } from './repository.js';
declare module 'fastify' { interface FastifyRequest { adminSession?: { id: string; csrfToken: string } } } declare module 'fastify' { interface FastifyRequest { adminSession?: { id: string; csrfToken: string }; userSession?: { id: string; userId: string; username: string; csrfToken: string } } }
export interface AppOptions { sqlite: Database.Database; adminUsername?: string; adminPasswordHash?: string; sessionSecret?: string; webOrigin?: string } export interface AppOptions { sqlite: Database.Database; adminUsername?: string; adminPasswordHash?: string; sessionSecret?: string; webOrigin?: string }
@ -22,15 +22,34 @@ export async function buildApp(options: AppOptions) {
app.get('/health', async () => ({ status: 'ok', time: new Date().toISOString() })); app.get('/health', async () => ({ status: 'ok', time: new Date().toISOString() }));
app.get('/api/v1/topics', async (request) => { app.get('/api/v1/topics', async (request) => {
const query = request.query as Record<string, string | undefined>; const query = request.query as Record<string, string | undefined>;
return repo.listTopics({ q: query.q, domain: query.domain, type: query.type, page: Math.max(1, Number(query.page) || 1), pageSize: Math.min(100, Math.max(1, Number(query.pageSize) || 24)) }); return repo.listTopics({ q: query.q, domain: query.domain, type: query.type, language: query.lang === 'da' ? 'da' : 'en', userId: readUserSession(request, options.sqlite)?.userId, page: Math.max(1, Number(query.page) || 1), pageSize: Math.min(100, Math.max(1, Number(query.pageSize) || 24)) });
}); });
app.get('/api/v1/topics/:slug', async (request, reply) => { app.get('/api/v1/topics/:slug', async (request, reply) => {
const result = repo.getTopic((request.params as { slug: string }).slug); const result = repo.getTopic((request.params as { slug: string }).slug, (request.query as { lang?: string }).lang === 'da' ? 'da' : 'en', readUserSession(request, options.sqlite)?.userId);
return result ?? reply.code(404).send({ error: 'Topic not found' }); return result ?? reply.code(404).send({ error: 'Topic not found' });
}); });
app.get('/api/v1/topics/:slug/revisions', async (request) => repo.revisions((request.params as { slug: string }).slug)); app.get('/api/v1/topics/:slug/revisions', async (request) => repo.revisions((request.params as { slug: string }).slug));
app.get('/api/v1/facets', async () => repo.facets()); app.get('/api/v1/facets', async () => repo.facets());
app.post('/api/v1/auth/register', async (request, reply) => {
const body = request.body as { username?: string; password?: string }; const username = (body.username ?? '').trim();
if (!/^[a-zA-Z0-9_-]{3,32}$/.test(username)) return reply.code(400).send({ error: 'Username must be 3–32 letters, numbers, _ or -' });
if ((body.password ?? '').length < 8) return reply.code(400).send({ error: 'Password must be at least 8 characters' });
if (options.sqlite.prepare('SELECT 1 FROM users WHERE username=? COLLATE NOCASE').get(username)) return reply.code(409).send({ error: 'Username is already taken' });
const userId = randomUUID(); options.sqlite.prepare('INSERT INTO users(id,username,password_hash,created_at) VALUES(?,?,?,?)').run(userId, username, await argon2.hash(body.password!), new Date().toISOString());
return createUserSession(reply, options.sqlite, userId, username);
});
app.post('/api/v1/auth/login', async (request, reply) => {
const body = request.body as { username?: string; password?: string }; const user = options.sqlite.prepare('SELECT id,username,password_hash passwordHash FROM users WHERE username=? COLLATE NOCASE').get((body.username ?? '').trim()) as { id: string; username: string; passwordHash: string } | undefined;
if (!user || !(await argon2.verify(user.passwordHash, body.password ?? ''))) return reply.code(401).send({ error: 'Invalid credentials' });
return createUserSession(reply, options.sqlite, user.id, user.username);
});
app.get('/api/v1/auth/me', { preHandler: requireUser(options.sqlite, false) }, async (request) => ({ username: request.userSession!.username, csrfToken: request.userSession!.csrfToken }));
app.post('/api/v1/auth/logout', { preHandler: requireUser(options.sqlite) }, async (request, reply) => { options.sqlite.prepare('DELETE FROM user_sessions WHERE id=?').run(request.userSession!.id); reply.clearCookie('tech_library_user', { path: '/' }); return { authenticated: false }; });
app.get('/api/v1/favorites', { preHandler: requireUser(options.sqlite, false) }, async (request) => repo.listTopics({ pageSize: 100, language: (request.query as { lang?: string }).lang === 'da' ? 'da' : 'en', userId: request.userSession!.userId }).items.filter((item) => item.isFavorite));
app.put('/api/v1/favorites/:slug', { preHandler: requireUser(options.sqlite) }, async (request, reply) => { const topic = options.sqlite.prepare('SELECT id FROM topics WHERE slug=?').get((request.params as { slug: string }).slug) as { id: string } | undefined; if (!topic) return reply.code(404).send({ error: 'Topic not found' }); options.sqlite.prepare('INSERT OR IGNORE INTO favorites(user_id,topic_id,created_at) VALUES(?,?,?)').run(request.userSession!.userId, topic.id, new Date().toISOString()); return { favorite: true }; });
app.delete('/api/v1/favorites/:slug', { preHandler: requireUser(options.sqlite) }, async (request) => { options.sqlite.prepare('DELETE FROM favorites WHERE user_id=? AND topic_id=(SELECT id FROM topics WHERE slug=?)').run(request.userSession!.userId, (request.params as { slug: string }).slug); return { favorite: false }; });
app.post('/api/v1/admin/login', async (request, reply) => { app.post('/api/v1/admin/login', async (request, reply) => {
const body = request.body as { username?: string; password?: string }; const body = request.body as { username?: string; password?: string };
const username = options.adminUsername ?? process.env.ADMIN_USERNAME ?? 'admin'; const username = options.adminUsername ?? process.env.ADMIN_USERNAME ?? 'admin';
@ -42,7 +61,7 @@ export async function buildApp(options: AppOptions) {
const expires = new Date(now.getTime() + 8 * 60 * 60 * 1000); const expires = new Date(now.getTime() + 8 * 60 * 60 * 1000);
options.sqlite.prepare('DELETE FROM sessions WHERE expires_at < ?').run(now.toISOString()); options.sqlite.prepare('DELETE FROM sessions WHERE expires_at < ?').run(now.toISOString());
options.sqlite.prepare('INSERT INTO sessions(id,csrf_token,expires_at,created_at) VALUES(?,?,?,?)').run(id, csrfToken, expires.toISOString(), now.toISOString()); options.sqlite.prepare('INSERT INTO sessions(id,csrf_token,expires_at,created_at) VALUES(?,?,?,?)').run(id, csrfToken, expires.toISOString(), now.toISOString());
reply.setCookie('tech_library_session', id, { path: '/', httpOnly: true, sameSite: 'strict', secure: process.env.NODE_ENV === 'production', signed: true, expires }); reply.setCookie('tech_library_session', id, { path: '/', httpOnly: true, sameSite: 'strict', secure: isSecureCookie(), signed: true, expires });
return { authenticated: true, csrfToken, expiresAt: expires.toISOString() }; return { authenticated: true, csrfToken, expiresAt: expires.toISOString() };
}); });
@ -78,6 +97,25 @@ export async function buildApp(options: AppOptions) {
return app; return app;
} }
function createUserSession(reply: FastifyReply, sqlite: Database.Database, userId: string, username: string) {
const id = randomUUID(); const csrfToken = randomBytes(24).toString('base64url'); const expires = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
sqlite.prepare('INSERT INTO user_sessions(id,user_id,csrf_token,expires_at,created_at) VALUES(?,?,?,?,?)').run(id, userId, csrfToken, expires.toISOString(), new Date().toISOString());
reply.setCookie('tech_library_user', id, { path: '/', httpOnly: true, sameSite: 'lax', secure: isSecureCookie(), signed: true, expires });
return { username, csrfToken, expiresAt: expires.toISOString() };
}
function readUserSession(request: FastifyRequest, sqlite: Database.Database) {
const signed = request.unsignCookie(request.cookies.tech_library_user ?? '');
if (!signed.valid || !signed.value) return undefined;
return sqlite.prepare('SELECT s.id,s.user_id userId,s.csrf_token csrfToken,u.username FROM user_sessions s JOIN users u ON u.id=s.user_id WHERE s.id=? AND s.expires_at>?').get(signed.value, new Date().toISOString()) as { id: string; userId: string; username: string; csrfToken: string } | undefined;
}
function requireUser(sqlite: Database.Database, csrf = true) {
return async (request: FastifyRequest, reply: FastifyReply) => { const session = readUserSession(request, sqlite); if (!session) return reply.code(401).send({ error: 'Authentication required' }); if (csrf && request.headers['x-csrf-token'] !== session.csrfToken) return reply.code(403).send({ error: 'Invalid CSRF token' }); request.userSession = session; };
}
const isSecureCookie = () => (process.env.WEB_ORIGIN ?? '').startsWith('https://');
function requireAdmin(sqlite: Database.Database, csrf = true) { function requireAdmin(sqlite: Database.Database, csrf = true) {
return async (request: FastifyRequest, reply: FastifyReply) => { return async (request: FastifyRequest, reply: FastifyReply) => {
const signed = request.unsignCookie(request.cookies.tech_library_session ?? ''); const signed = request.unsignCookie(request.cookies.tech_library_session ?? '');