security hardening, team invites, granular locking, view counts, board subscriptions, scheduled changelog, mentions, recovery codes, accessibility and hover states
This commit is contained in:
@@ -1,42 +1,156 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
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 prisma = new PrismaClient();
|
||||
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" });
|
||||
}
|
||||
|
||||
export default async function adminAuthRoutes(app: FastifyInstance) {
|
||||
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 } });
|
||||
if (!admin) {
|
||||
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()}`);
|
||||
|
||||
const valid = await bcrypt.compare(body.password, admin.passwordHash);
|
||||
if (!valid) {
|
||||
reply.status(401).send({ error: "Invalid credentials" });
|
||||
return;
|
||||
// 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 token = jwt.sign(
|
||||
{ sub: admin.id, type: "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: "24h" }
|
||||
{ expiresIn: "4h" }
|
||||
);
|
||||
|
||||
reply.send({ token });
|
||||
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: "strict",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
maxAge: 60 * 60 * 4,
|
||||
})
|
||||
.setCookie("echoboard_passkey", userToken, {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
sameSite: "strict",
|
||||
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 },
|
||||
});
|
||||
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,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
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 });
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user