1
0

feat: setup/login/logout/me auth routes

This commit is contained in:
2026-08-29 15:20:42 +02:00
parent f48093319b
commit ad8b7cb22a
4 changed files with 261 additions and 0 deletions

View File

@@ -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<FastifyInstance> {
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
}

View 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) }
})
}