feat: setup/login/logout/me auth routes
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
import Fastify, { FastifyInstance } from 'fastify'
|
import Fastify, { FastifyInstance } from 'fastify'
|
||||||
import Database from 'better-sqlite3'
|
import Database from 'better-sqlite3'
|
||||||
|
import cookie from '@fastify/cookie'
|
||||||
import type { Config } from './config.js'
|
import type { Config } from './config.js'
|
||||||
|
import { registerAuthRoutes } from './routes/authRoutes.js'
|
||||||
|
|
||||||
declare module 'fastify' {
|
declare module 'fastify' {
|
||||||
interface FastifyInstance {
|
interface FastifyInstance {
|
||||||
@@ -8,6 +10,9 @@ declare module 'fastify' {
|
|||||||
config: Config
|
config: Config
|
||||||
fetchImpl: typeof fetch
|
fetchImpl: typeof fetch
|
||||||
}
|
}
|
||||||
|
interface FastifyRequest {
|
||||||
|
user?: import('./auth.js').UserRow
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppOptions {
|
export interface AppOptions {
|
||||||
@@ -21,6 +26,10 @@ export async function buildApp(opts: AppOptions): Promise<FastifyInstance> {
|
|||||||
app.decorate('db', opts.db)
|
app.decorate('db', opts.db)
|
||||||
app.decorate('config', opts.config)
|
app.decorate('config', opts.config)
|
||||||
app.decorate('fetchImpl', opts.fetchImpl ?? fetch)
|
app.decorate('fetchImpl', opts.fetchImpl ?? fetch)
|
||||||
|
|
||||||
|
await app.register(cookie)
|
||||||
|
await registerAuthRoutes(app)
|
||||||
|
|
||||||
app.get('/api/health', async () => ({ ok: true }))
|
app.get('/api/health', async () => ({ ok: true }))
|
||||||
return app
|
return app
|
||||||
}
|
}
|
||||||
|
|||||||
109
server/src/routes/authRoutes.ts
Normal file
109
server/src/routes/authRoutes.ts
Normal file
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
if (!request.user || request.user.is_admin !== 1) {
|
||||||
|
await reply.code(403).send({ error: 'forbidden' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerAuthRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
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) }
|
||||||
|
})
|
||||||
|
}
|
||||||
74
server/test/authRoutes.test.ts
Normal file
74
server/test/authRoutes.test.ts
Normal file
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
69
server/test/helpers.ts
Normal file
69
server/test/helpers.ts
Normal file
@@ -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<FastifyInstance> {
|
||||||
|
const db = openDatabase(':memory:')
|
||||||
|
return buildApp({ db, config: testConfig(), fetchImpl })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildTestAppWithDb(db: DB, fetchImpl?: typeof fetch): Promise<FastifyInstance> {
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<string, string> } {
|
||||||
|
return { cookies: { rs_session: sessionToken } }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user