219 lines
7.8 KiB
TypeScript
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 });
|
|
});
|
|
}
|