diff --git a/server/src/app.ts b/server/src/app.ts index 373380c..7627156 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -1,6 +1,8 @@ import Fastify, { FastifyInstance } from 'fastify' import Database from 'better-sqlite3' +import cookie from '@fastify/cookie' import type { Config } from './config.js' +import { registerAuthRoutes } from './routes/authRoutes.js' declare module 'fastify' { interface FastifyInstance { @@ -8,6 +10,9 @@ declare module 'fastify' { config: Config fetchImpl: typeof fetch } + interface FastifyRequest { + user?: import('./auth.js').UserRow + } } export interface AppOptions { @@ -21,6 +26,10 @@ export async function buildApp(opts: AppOptions): Promise { app.decorate('db', opts.db) app.decorate('config', opts.config) app.decorate('fetchImpl', opts.fetchImpl ?? fetch) + + await app.register(cookie) + await registerAuthRoutes(app) + app.get('/api/health', async () => ({ ok: true })) return app } diff --git a/server/src/routes/authRoutes.ts b/server/src/routes/authRoutes.ts new file mode 100644 index 0000000..d035cbb --- /dev/null +++ b/server/src/routes/authRoutes.ts @@ -0,0 +1,109 @@ +import { FastifyInstance } from 'fastify' +import { + hashPassword, + verifyPassword, + createUser, + getUserByUsername, + createSession, + getUserBySession, + deleteSession, + type UserRow, +} from '../auth.js' +import argon2 from 'argon2' + +export const COOKIE_NAME = 'rs_session' + +// Used to equalize response time for unknown usernames (timing-attack defense). +const DUMMY_HASH = argon2.hash('dummy-password-for-timing') + +export interface PublicUser { + id: number + username: string + isAdmin: boolean +} + +export function toPublicUser(u: { id: number; username: string; is_admin: number }): PublicUser { + return { id: u.id, username: u.username, isAdmin: u.is_admin === 1 } +} + +function validateCredentials(username: unknown, password: unknown): string | null { + if (typeof username !== 'string' || username.length < 3 || username.length > 40) { + return 'username must be 3-40 characters' + } + if (typeof password !== 'string' || password.length < 8) { + return 'password must be at least 8 characters' + } + return null +} + +export function cookieOpts() { + return { + path: '/', + httpOnly: true, + sameSite: 'lax' as const, + maxAge: 30 * 24 * 60 * 60, // seconds + } +} + +export async function requireAuth(request: any, reply: any): Promise { + const token = request.cookies[COOKIE_NAME] + if (token) { + const user = getUserBySession(request.server.db, token) + if (user) { + request.user = user + return + } + } + await reply.code(401).send({ error: 'unauthorized' }) +} + +export async function requireAdmin(request: any, reply: any): Promise { + if (!request.user || request.user.is_admin !== 1) { + await reply.code(403).send({ error: 'forbidden' }) + } +} + +export async function registerAuthRoutes(app: FastifyInstance): Promise { + app.get('/api/setup', async (request) => { + const count = ( + request.server.db.prepare('SELECT COUNT(*) AS n FROM users').get() as { n: number } + ).n + return { needed: count === 0 } + }) + + app.post('/api/setup', async (request, reply) => { + const count = ( + request.server.db.prepare('SELECT COUNT(*) AS n FROM users').get() as { n: number } + ).n + if (count > 0) return reply.code(403).send({ error: 'setup_already_done' }) + const { username, password } = (request.body ?? {}) as { username?: string; password?: string } + const invalid = validateCredentials(username, password) + if (invalid) return reply.code(400).send({ error: 'invalid_input', detail: invalid }) + const user = createUser(request.server.db, username as string, await hashPassword(password as string), true) + request.server.db.prepare('INSERT INTO settings (user_id) VALUES (?)').run(user.id) + const token = createSession(request.server.db, user.id) + return reply.setCookie(COOKIE_NAME, token, cookieOpts()).code(200).send({ user: toPublicUser(user) }) + }) + + app.post('/api/login', async (request, reply) => { + const { username, password } = (request.body ?? {}) as { username?: string; password?: string } + const user = typeof username === 'string' ? getUserByUsername(request.server.db, username) : undefined + const hash = user?.password_hash ?? (await DUMMY_HASH) + const ok = typeof password === 'string' && (await verifyPassword(hash, password)) + if (!user || !ok) { + return reply.code(401).send({ error: 'invalid_credentials' }) + } + const token = createSession(request.server.db, user.id) + return reply.setCookie(COOKIE_NAME, token, cookieOpts()).code(200).send({ user: toPublicUser(user) }) + }) + + app.post('/api/logout', async (request, reply) => { + const token = request.cookies[COOKIE_NAME] + if (token) deleteSession(request.server.db, token) + return reply.clearCookie(COOKIE_NAME, { path: '/' }).code(200).send({ ok: true }) + }) + + app.get('/api/me', { preHandler: [requireAuth] }, async (request) => { + return { user: toPublicUser(request.user as UserRow) } + }) +} diff --git a/server/test/authRoutes.test.ts b/server/test/authRoutes.test.ts new file mode 100644 index 0000000..143b69f --- /dev/null +++ b/server/test/authRoutes.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest' +import { buildTestApp, setupAdmin, getCookie, auth } from './helpers.js' + +describe('auth routes', () => { + it('setup creates admin when no users exist, then is closed', async () => { + const app = await buildTestApp() + const status = await app.inject({ method: 'GET', url: '/api/setup' }) + expect(status.json()).toEqual({ needed: true }) + + const cookie = await setupAdmin(app) + + const me = await app.inject({ method: 'GET', url: '/api/me', ...auth(cookie) }) + expect(me.statusCode).toBe(200) + expect(me.json()).toEqual({ user: { id: 1, username: 'admin', isAdmin: true } }) + + const again = await app.inject({ + method: 'POST', + url: '/api/setup', + payload: { username: 'x', password: 'password123' }, + }) + expect(again.statusCode).toBe(403) + await app.close() + }) + + it('setup validates input', async () => { + const app = await buildTestApp() + const bad = await app.inject({ + method: 'POST', + url: '/api/setup', + payload: { username: 'ab', password: 'short' }, + }) + expect(bad.statusCode).toBe(400) + await app.close() + }) + + it('login and logout', async () => { + const app = await buildTestApp() + await setupAdmin(app) + const login = await app.inject({ + method: 'POST', + url: '/api/login', + payload: { username: 'admin', password: 'adminpass123' }, + }) + expect(login.statusCode).toBe(200) + const cookie = getCookie(login) + + const me = await app.inject({ method: 'GET', url: '/api/me', ...auth(cookie) }) + expect(me.statusCode).toBe(200) + + await app.inject({ method: 'POST', url: '/api/logout', ...auth(cookie) }) + const after = await app.inject({ method: 'GET', url: '/api/me', ...auth(cookie) }) + expect(after.statusCode).toBe(401) + await app.close() + }) + + it('login rejects wrong password', async () => { + const app = await buildTestApp() + await setupAdmin(app) + const res = await app.inject({ + method: 'POST', + url: '/api/login', + payload: { username: 'admin', password: 'wrongpass123' }, + }) + expect(res.statusCode).toBe(401) + await app.close() + }) + + it('protected route requires auth', async () => { + const app = await buildTestApp() + const res = await app.inject({ method: 'GET', url: '/api/me' }) + expect(res.statusCode).toBe(401) + await app.close() + }) +}) diff --git a/server/test/helpers.ts b/server/test/helpers.ts new file mode 100644 index 0000000..9e640eb --- /dev/null +++ b/server/test/helpers.ts @@ -0,0 +1,69 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { openDatabase, type DB } from '../src/db.js' +import { buildApp } from '../src/app.js' +import type { Config } from '../src/config.js' +import type { FastifyInstance } from 'fastify' + +export function testConfig(): Config { + // artworkDir must be a real writable directory (cached images land there) + const artworkDir = mkdtempSync(path.join(tmpdir(), 'rs-art-')) + return { + dataDir: ':memory:', + artworkDir, + dbPath: ':memory:', + port: 0, + sessionSecret: 'test-secret-test-secret-test-secret-1234', + } +} + +export async function buildTestApp(fetchImpl?: typeof fetch): Promise { + const db = openDatabase(':memory:') + return buildApp({ db, config: testConfig(), fetchImpl }) +} + +export async function buildTestAppWithDb(db: DB, fetchImpl?: typeof fetch): Promise { + return buildApp({ db, config: testConfig(), fetchImpl }) +} + +/** Runs /api/setup to create admin 'admin' / 'adminpass123'. Returns session token value. */ +export async function setupAdmin(app: FastifyInstance): Promise { + const res = await app.inject({ + method: 'POST', + url: '/api/setup', + payload: { username: 'admin', password: 'adminpass123' }, + }) + if (res.statusCode !== 200) throw new Error(`setup failed: ${res.body}`) + return getCookie(res) +} + +/** Creates and logs in a non-admin user via /api/users + /api/login. Returns session token value. */ +export async function loginAs( + app: FastifyInstance, + adminToken: string, + username: string, + password: string +): Promise { + const created = await app.inject({ + method: 'POST', + url: '/api/users', + ...auth(adminToken), + payload: { username, password }, + }) + if (created.statusCode !== 200) throw new Error(`user create failed: ${created.body}`) + const login = await app.inject({ method: 'POST', url: '/api/login', payload: { username, password } }) + if (login.statusCode !== 200) throw new Error(`login failed: ${login.body}`) + return getCookie(login) +} + +export function getCookie(res: { cookies: { name: string; value: string }[] }): string { + const cookie = res.cookies.find((c) => c.name === 'rs_session') + if (!cookie) throw new Error('no rs_session cookie in response') + return cookie.value +} + +/** inject option spread for an authenticated request: `app.inject({ ..., ...auth(token) })` */ +export function auth(sessionToken: string): { cookies: Record } { + return { cookies: { rs_session: sessionToken } } +}