import { FastifyInstance } from "fastify"; import bcrypt from "bcrypt"; import jwt from "jsonwebtoken"; import { z } from "zod"; import { config } from "../../config.js"; import { encrypt, decrypt } from "../../services/encryption.js"; import { masterKey } from "../../config.js"; import { blockToken } from "../../lib/token-blocklist.js"; import { prisma } from "../../lib/prisma.js"; const DUMMY_HASH = bcrypt.hashSync("__timing_safe__", 12); const failedAttempts = new Map(); setInterval(() => { const cutoff = Date.now() - 15 * 60 * 1000; for (const [k, v] of failedAttempts) { if (v.lastAttempt < cutoff) failedAttempts.delete(k); } }, 60 * 1000); const loginBody = z.object({ email: z.string().email(), password: z.string().min(1), }); async function ensureLinkedUser(adminId: string): Promise { return prisma.$transaction(async (tx) => { const admin = await tx.adminUser.findUnique({ where: { id: adminId } }); if (admin?.linkedUserId) return admin.linkedUserId; const displayName = encrypt("Admin", masterKey); const user = await tx.user.create({ data: { displayName, authMethod: "COOKIE" }, }); await tx.adminUser.update({ where: { id: adminId }, data: { linkedUserId: user.id }, }); return user.id; }, { isolationLevel: "Serializable" }); } const setupBody = z.object({ email: z.string().email(), password: z.string().min(8, "Password must be at least 8 characters"), }); export default async function adminAuthRoutes(app: FastifyInstance) { // check if initial setup is needed (no admin exists) app.get( "/admin/setup-status", { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, async (_req, reply) => { const count = await prisma.adminUser.count(); reply.send({ needsSetup: count === 0 }); } ); // initial admin setup (only works when no admin exists) app.post<{ Body: z.infer }>( "/admin/setup", { config: { rateLimit: { max: 3, timeWindow: "15 minutes" } } }, async (req, reply) => { const count = await prisma.adminUser.count(); if (count > 0) { reply.status(403).send({ error: "Setup already completed" }); return; } const body = setupBody.parse(req.body); const hash = await bcrypt.hash(body.password, 12); const admin = await prisma.adminUser.create({ data: { email: body.email, passwordHash: hash, role: "SUPER_ADMIN" }, }); const linkedUserId = await ensureLinkedUser(admin.id); const adminToken = jwt.sign( { sub: admin.id, type: "admin", aud: "echoboard:admin", iss: "echoboard" }, config.JWT_SECRET, { expiresIn: "4h" } ); const userToken = jwt.sign( { sub: linkedUserId, type: "passkey", aud: "echoboard:user", iss: "echoboard" }, config.JWT_SECRET, { expiresIn: "4h" } ); reply .setCookie("echoboard_admin", adminToken, { path: "/", httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", maxAge: 60 * 60 * 4, }) .setCookie("echoboard_passkey", userToken, { path: "/", httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", maxAge: 60 * 60 * 4, }) .send({ ok: true }); } ); app.post<{ Body: z.infer }>( "/admin/login", { config: { rateLimit: { max: 5, timeWindow: "15 minutes" } } }, async (req, reply) => { const body = loginBody.parse(req.body); const admin = await prisma.adminUser.findUnique({ where: { email: body.email } }); const valid = await bcrypt.compare(body.password, admin?.passwordHash ?? DUMMY_HASH); if (!admin || !valid) { const bruteKey = `${req.ip}:${body.email.toLowerCase()}`; if (!failedAttempts.has(bruteKey) && failedAttempts.size > 10000) { const sorted = [...failedAttempts.entries()].sort((a, b) => a[1].lastAttempt - b[1].lastAttempt); for (let i = 0; i < sorted.length / 2; i++) failedAttempts.delete(sorted[i][0]); } const entry = failedAttempts.get(bruteKey) || { count: 0, lastAttempt: 0 }; entry.count++; entry.lastAttempt = Date.now(); failedAttempts.set(bruteKey, entry); const delay = Math.min(100 * Math.pow(2, entry.count - 1), 10000); await new Promise((r) => setTimeout(r, delay)); reply.status(401).send({ error: "Invalid credentials" }); return; } failedAttempts.delete(`${req.ip}:${body.email.toLowerCase()}`); // only auto-upgrade CLI-created admins (have email, not invited) if (admin.role !== "SUPER_ADMIN" && admin.email && !admin.invitedById) { await prisma.adminUser.update({ where: { id: admin.id }, data: { role: "SUPER_ADMIN" } }); } const linkedUserId = await ensureLinkedUser(admin.id); const adminToken = jwt.sign( { sub: admin.id, type: "admin", aud: "echoboard:admin", iss: "echoboard" }, config.JWT_SECRET, { expiresIn: "4h" } ); const userToken = jwt.sign( { sub: linkedUserId, type: "passkey", aud: "echoboard:user", iss: "echoboard" }, config.JWT_SECRET, { expiresIn: "4h" } ); reply .setCookie("echoboard_admin", adminToken, { path: "/", httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", maxAge: 60 * 60 * 4, }) .setCookie("echoboard_passkey", userToken, { path: "/", httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", maxAge: 60 * 60 * 4, }) .send({ ok: true }); } ); app.get( "/admin/me", { preHandler: [app.optionalUser, app.optionalAdmin], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, async (req, reply) => { if (!req.adminId) { reply.send({ isAdmin: false }); return; } const admin = await prisma.adminUser.findUnique({ where: { id: req.adminId }, select: { role: true, displayName: true, teamTitle: true, linkedUserId: true, linkedUser: { select: { avatarPath: true } } }, }); if (!admin) { reply.send({ isAdmin: false }); return; } reply.send({ isAdmin: true, role: admin.role, displayName: admin.displayName ? decrypt(admin.displayName, masterKey) : null, teamTitle: admin.teamTitle ? decrypt(admin.teamTitle, masterKey) : null, avatarUrl: admin.linkedUser?.avatarPath ? `/api/v1/avatars/${admin.linkedUserId}` : null, }); } ); app.post("/admin/exit", { preHandler: [app.requireAdmin], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } }, async (req, reply) => { const adminToken = req.cookies?.echoboard_admin; const passkeyToken = req.cookies?.echoboard_passkey; if (adminToken) await blockToken(adminToken); if (passkeyToken) await blockToken(passkeyToken); reply .clearCookie("echoboard_admin", { path: "/" }) .clearCookie("echoboard_passkey", { path: "/" }) .clearCookie("echoboard_token", { path: "/" }) .send({ ok: true }); }); app.post("/admin/logout", { preHandler: [app.requireAdmin], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } }, async (req, reply) => { const adminToken = req.cookies?.echoboard_admin; const passkeyToken = req.cookies?.echoboard_passkey; if (adminToken) await blockToken(adminToken); if (passkeyToken) await blockToken(passkeyToken); reply .clearCookie("echoboard_admin", { path: "/" }) .clearCookie("echoboard_passkey", { path: "/" }) .clearCookie("echoboard_token", { path: "/" }) .send({ ok: true }); }); }