feat(auth): add reader accounts and private favorites
This commit is contained in:
parent
eaf237a502
commit
215d943127
2 changed files with 54 additions and 4 deletions
|
|
@ -25,4 +25,16 @@ describe('API', () => {
|
|||
expect(login.json().csrfToken).toBeTruthy();
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { randomBytes, randomUUID } from 'node:crypto';
|
|||
import type Database from 'better-sqlite3';
|
||||
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 }
|
||||
|
||||
|
|
@ -22,15 +22,34 @@ export async function buildApp(options: AppOptions) {
|
|||
app.get('/health', async () => ({ status: 'ok', time: new Date().toISOString() }));
|
||||
app.get('/api/v1/topics', async (request) => {
|
||||
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) => {
|
||||
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' });
|
||||
});
|
||||
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.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) => {
|
||||
const body = request.body as { username?: string; password?: string };
|
||||
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);
|
||||
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());
|
||||
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() };
|
||||
});
|
||||
|
||||
|
|
@ -78,6 +97,25 @@ export async function buildApp(options: AppOptions) {
|
|||
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) {
|
||||
return async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const signed = request.unsignCookie(request.cookies.tech_library_session ?? '');
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue