Files
echoboard/packages/api/src/routes/admin/auth.ts

219 lines
7.8 KiB
TypeScript

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<string, { count: number; lastAttempt: number }>();
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<string> {
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<typeof setupBody> }>(
"/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<typeof loginBody> }>(
"/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 });
});
}