security hardening, team invites, granular locking, view counts, board subscriptions, scheduled changelog, mentions, recovery codes, accessibility and hover states

This commit is contained in:
2026-03-21 17:37:01 +02:00
parent f07eddf29e
commit 5ba25fb956
142 changed files with 30397 additions and 2287 deletions

View File

@@ -1,27 +1,33 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import fp from "fastify-plugin";
import jwt from "jsonwebtoken";
import { PrismaClient, User } from "@prisma/client";
import type { User } from "@prisma/client";
import prisma from "../lib/prisma.js";
import { hashToken } from "../services/encryption.js";
import { config } from "../config.js";
import { isTokenBlocked } from "../lib/token-blocklist.js";
declare module "fastify" {
interface FastifyRequest {
user?: User;
adminId?: string;
adminRole?: string;
}
}
const prisma = new PrismaClient();
async function authPlugin(app: FastifyInstance) {
app.decorateRequest("user", undefined);
app.decorateRequest("adminId", undefined);
app.decorateRequest("adminRole", undefined);
app.decorate("requireUser", async (req: FastifyRequest, reply: FastifyReply) => {
// try cookie auth first
const token = req.cookies?.echoboard_token;
if (token) {
if (await isTokenBlocked(token)) {
reply.status(401).send({ error: "Not authenticated" });
return;
}
const hash = hashToken(token);
const user = await prisma.user.findUnique({ where: { tokenHash: hash } });
if (user) {
@@ -30,11 +36,15 @@ async function authPlugin(app: FastifyInstance) {
}
}
// try bearer token (passkey sessions)
const authHeader = req.headers.authorization;
if (authHeader?.startsWith("Bearer ")) {
// try passkey session cookie, then fall back to Authorization header
const passkeyToken = req.cookies?.echoboard_passkey
|| (req.headers.authorization?.startsWith("Bearer ")
? req.headers.authorization.slice(7)
: null);
if (passkeyToken && !(await isTokenBlocked(passkeyToken))) {
try {
const decoded = jwt.verify(authHeader.slice(7), config.JWT_SECRET) as { sub: string; type: string };
const decoded = jwt.verify(passkeyToken, config.JWT_SECRET, { algorithms: ['HS256'], audience: 'echoboard:user', issuer: 'echoboard' }) as { sub: string; type: string };
if (decoded.type === "passkey") {
const user = await prisma.user.findUnique({ where: { id: decoded.sub } });
if (user) {
@@ -53,15 +63,23 @@ async function authPlugin(app: FastifyInstance) {
app.decorate("optionalUser", async (req: FastifyRequest) => {
const token = req.cookies?.echoboard_token;
if (token) {
if (await isTokenBlocked(token)) return;
const hash = hashToken(token);
const user = await prisma.user.findUnique({ where: { tokenHash: hash } });
if (user) req.user = user;
return;
if (user) {
req.user = user;
return;
}
}
const authHeader = req.headers.authorization;
if (authHeader?.startsWith("Bearer ")) {
const passkeyToken = req.cookies?.echoboard_passkey
|| (req.headers.authorization?.startsWith("Bearer ")
? req.headers.authorization.slice(7)
: null);
if (passkeyToken && !(await isTokenBlocked(passkeyToken))) {
try {
const decoded = jwt.verify(authHeader.slice(7), config.JWT_SECRET) as { sub: string; type: string };
const decoded = jwt.verify(passkeyToken, config.JWT_SECRET, { algorithms: ['HS256'], audience: 'echoboard:user', issuer: 'echoboard' }) as { sub: string; type: string };
if (decoded.type === "passkey") {
const user = await prisma.user.findUnique({ where: { id: decoded.sub } });
if (user) req.user = user;
@@ -72,22 +90,103 @@ async function authPlugin(app: FastifyInstance) {
}
});
app.decorate("requireAdmin", async (req: FastifyRequest, reply: FastifyReply) => {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith("Bearer ")) {
reply.status(401).send({ error: "Admin token required" });
return;
app.decorate("optionalAdmin", async (req: FastifyRequest) => {
const token = req.cookies?.echoboard_admin;
if (token && !(await isTokenBlocked(token))) {
try {
const decoded = jwt.verify(token, config.JWT_SECRET, { algorithms: ['HS256'], audience: 'echoboard:admin', issuer: 'echoboard' }) as { sub: string; type: string };
if (decoded.type === "admin") {
const admin = await prisma.adminUser.findUnique({ where: { id: decoded.sub }, select: { id: true, role: true } });
if (admin) {
req.adminId = decoded.sub;
req.adminRole = admin.role;
return;
}
}
} catch {}
}
try {
const decoded = jwt.verify(authHeader.slice(7), config.JWT_SECRET) as { sub: string; type: string };
if (decoded.type !== "admin") {
reply.status(403).send({ error: "Admin access required" });
// fallback: check if authenticated user is a linked team member
if (req.user) {
const admin = await prisma.adminUser.findUnique({
where: { linkedUserId: req.user.id },
select: { id: true, role: true },
});
if (admin) {
req.adminId = admin.id;
req.adminRole = admin.role;
}
}
});
app.decorate("requireAdmin", async (req: FastifyRequest, reply: FastifyReply) => {
// try admin JWT cookie first (super admin login flow)
const token = req.cookies?.echoboard_admin ?? null;
if (token && !(await isTokenBlocked(token))) {
try {
const decoded = jwt.verify(token, config.JWT_SECRET, { algorithms: ['HS256'], audience: 'echoboard:admin', issuer: 'echoboard' }) as { sub: string; type: string };
if (decoded.type === "admin") {
const admin = await prisma.adminUser.findUnique({ where: { id: decoded.sub }, select: { id: true, role: true } });
if (admin) {
req.adminId = admin.id;
req.adminRole = admin.role;
return;
}
}
} catch {}
}
// fallback: check if user is authenticated via cookie and linked to an admin
const cookieToken = req.cookies?.echoboard_token;
if (cookieToken && !(await isTokenBlocked(cookieToken))) {
const hash = hashToken(cookieToken);
const user = await prisma.user.findUnique({ where: { tokenHash: hash } });
if (user) {
const admin = await prisma.adminUser.findUnique({
where: { linkedUserId: user.id },
select: { id: true, role: true },
});
if (admin) {
req.user = user;
req.adminId = admin.id;
req.adminRole = admin.role;
return;
}
}
}
// try passkey token
const passkeyToken = req.cookies?.echoboard_passkey
|| (req.headers.authorization?.startsWith("Bearer ") ? req.headers.authorization.slice(7) : null);
if (passkeyToken && !(await isTokenBlocked(passkeyToken))) {
try {
const decoded = jwt.verify(passkeyToken, config.JWT_SECRET, { algorithms: ['HS256'], audience: 'echoboard:user', issuer: 'echoboard' }) as { sub: string; type: string };
if (decoded.type === "passkey") {
const user = await prisma.user.findUnique({ where: { id: decoded.sub } });
if (user) {
const admin = await prisma.adminUser.findUnique({
where: { linkedUserId: user.id },
select: { id: true, role: true },
});
if (admin) {
req.user = user;
req.adminId = admin.id;
req.adminRole = admin.role;
return;
}
}
}
} catch {}
}
reply.status(401).send({ error: "Unauthorized" });
});
app.decorate("requireRole", (...roles: string[]) => {
return async (req: FastifyRequest, reply: FastifyReply) => {
if (!req.adminId || !req.adminRole || !roles.includes(req.adminRole)) {
reply.status(403).send({ error: "Insufficient permissions" });
return;
}
req.adminId = decoded.sub;
} catch {
reply.status(401).send({ error: "Invalid admin token" });
}
};
});
}
@@ -95,7 +194,9 @@ declare module "fastify" {
interface FastifyInstance {
requireUser: (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
optionalUser: (req: FastifyRequest) => Promise<void>;
optionalAdmin: (req: FastifyRequest) => Promise<void>;
requireAdmin: (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
requireRole: (...roles: string[]) => (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
}
}

View File

@@ -2,26 +2,51 @@ import { FastifyInstance } from "fastify";
import fp from "fastify-plugin";
async function securityPlugin(app: FastifyInstance) {
app.addHook("onSend", async (_req, reply) => {
reply.header("Content-Security-Policy", [
"default-src 'self'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"font-src 'self'",
"connect-src 'self'",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
].join("; "));
app.addHook("onSend", async (req, reply) => {
const isEmbed = req.url.startsWith("/api/v1/embed/") || req.url.startsWith("/embed/");
if (isEmbed) {
// embed routes need to be frameable by third-party sites
reply.header("Content-Security-Policy", [
"default-src 'self'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"font-src 'self'",
"connect-src 'self'",
"frame-src 'none'",
"object-src 'none'",
"frame-ancestors *",
"base-uri 'self'",
"form-action 'none'",
].join("; "));
reply.header("Cross-Origin-Resource-Policy", "cross-origin");
reply.header("Access-Control-Allow-Origin", "*");
reply.header("Access-Control-Allow-Credentials", "false");
} else {
reply.header("Content-Security-Policy", [
"default-src 'self'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"font-src 'self'",
"connect-src 'self'",
"frame-src 'none'",
"object-src 'none'",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
].join("; "));
reply.header("X-Frame-Options", "DENY");
reply.header("Cross-Origin-Opener-Policy", "same-origin");
reply.header("Cross-Origin-Resource-Policy", "same-origin");
}
reply.header("Referrer-Policy", "no-referrer");
reply.header("X-Content-Type-Options", "nosniff");
reply.header("X-Frame-Options", "DENY");
reply.header("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
reply.header("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
reply.header("Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload");
reply.header("Permissions-Policy", "camera=(), microphone=(), geolocation=(), interest-cohort=()");
reply.header("X-DNS-Prefetch-Control", "off");
reply.header("Cross-Origin-Opener-Policy", "same-origin");
reply.header("Cross-Origin-Resource-Policy", "same-origin");
});
}