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,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>;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user