initial project setup
Fastify + Prisma backend, React + Vite frontend, Docker deployment. Multi-board feedback platform with anonymous cookie auth, passkey upgrade path, ALTCHA spam protection, plugin system, and full privacy-first architecture.
This commit is contained in:
102
packages/api/src/middleware/auth.ts
Normal file
102
packages/api/src/middleware/auth.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
|
||||
import fp from "fastify-plugin";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { PrismaClient, User } from "@prisma/client";
|
||||
import { hashToken } from "../services/encryption.js";
|
||||
import { config } from "../config.js";
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyRequest {
|
||||
user?: User;
|
||||
adminId?: string;
|
||||
}
|
||||
}
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function authPlugin(app: FastifyInstance) {
|
||||
app.decorateRequest("user", undefined);
|
||||
app.decorateRequest("adminId", undefined);
|
||||
|
||||
app.decorate("requireUser", async (req: FastifyRequest, reply: FastifyReply) => {
|
||||
// try cookie auth first
|
||||
const token = req.cookies?.echoboard_token;
|
||||
if (token) {
|
||||
const hash = hashToken(token);
|
||||
const user = await prisma.user.findUnique({ where: { tokenHash: hash } });
|
||||
if (user) {
|
||||
req.user = user;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// try bearer token (passkey sessions)
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
try {
|
||||
const decoded = jwt.verify(authHeader.slice(7), config.JWT_SECRET) as { sub: string; type: string };
|
||||
if (decoded.type === "passkey") {
|
||||
const user = await prisma.user.findUnique({ where: { id: decoded.sub } });
|
||||
if (user) {
|
||||
req.user = user;
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// invalid token
|
||||
}
|
||||
}
|
||||
|
||||
reply.status(401).send({ error: "Not authenticated" });
|
||||
});
|
||||
|
||||
app.decorate("optionalUser", async (req: FastifyRequest) => {
|
||||
const token = req.cookies?.echoboard_token;
|
||||
if (token) {
|
||||
const hash = hashToken(token);
|
||||
const user = await prisma.user.findUnique({ where: { tokenHash: hash } });
|
||||
if (user) req.user = user;
|
||||
return;
|
||||
}
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
try {
|
||||
const decoded = jwt.verify(authHeader.slice(7), config.JWT_SECRET) as { sub: string; type: string };
|
||||
if (decoded.type === "passkey") {
|
||||
const user = await prisma.user.findUnique({ where: { id: decoded.sub } });
|
||||
if (user) req.user = user;
|
||||
}
|
||||
} catch {
|
||||
// invalid
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
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" });
|
||||
return;
|
||||
}
|
||||
req.adminId = decoded.sub;
|
||||
} catch {
|
||||
reply.status(401).send({ error: "Invalid admin token" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyInstance {
|
||||
requireUser: (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
||||
optionalUser: (req: FastifyRequest) => Promise<void>;
|
||||
requireAdmin: (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
||||
}
|
||||
}
|
||||
|
||||
export default fp(authPlugin, { name: "auth" });
|
||||
28
packages/api/src/middleware/security.ts
Normal file
28
packages/api/src/middleware/security.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
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("; "));
|
||||
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("X-DNS-Prefetch-Control", "off");
|
||||
reply.header("Cross-Origin-Opener-Policy", "same-origin");
|
||||
reply.header("Cross-Origin-Resource-Policy", "same-origin");
|
||||
});
|
||||
}
|
||||
|
||||
export default fp(securityPlugin, { name: "security" });
|
||||
Reference in New Issue
Block a user