import fp from "fastify-plugin"; import type { FastifyReply, FastifyRequest } from "fastify"; import { and, db, eq, gt, sessions, users, adminSessions, adminUsers } from "@spinza/database"; import { ADMIN_COOKIE, ADMIN_SESSION_TTL_HOURS, SESSION_COOKIE, SESSION_TTL_DAYS } from "@spinza/shared"; import { randomToken, sha256 } from "../lib/crypto"; import { getJson, redis, setJson } from "../lib/redis"; import { config, allowedOrigins } from "../config"; import { errors } from "../lib/errors"; import { clientIp, logSecurity } from "../lib/security"; export interface SessionUser { id: string; username: string; status: string; } export interface AdminUser { id: string; username: string; role: string; } declare module "fastify" { interface FastifyRequest { user: SessionUser | null; sessionId: string | null; admin: AdminUser | null; } } const SESSION_CACHE_TTL = 60; export async function createSession(reply: FastifyReply, req: FastifyRequest, userId: string): Promise { const token = randomToken(32); const tokenHash = sha256(token); const expiresAt = new Date(Date.now() + SESSION_TTL_DAYS * 86400_000); await db.insert(sessions).values({ userId, tokenHash, expiresAt, userAgent: (req.headers["user-agent"] ?? "").slice(0, 300), ip: clientIp(req) }); reply.setCookie(SESSION_COOKIE, token, { httpOnly: true, secure: config.cookieSecure, sameSite: "lax", path: "/", maxAge: SESSION_TTL_DAYS * 86400, }); } export async function destroySession(reply: FastifyReply, req: FastifyRequest): Promise { const token = req.cookies[SESSION_COOKIE]; if (token) { const h = sha256(token); await db.delete(sessions).where(eq(sessions.tokenHash, h)); await redis().del(`sess:${h}`); } reply.clearCookie(SESSION_COOKIE, { path: "/" }); } export async function destroyAllSessions(userId: string): Promise { const rows = await db.select({ tokenHash: sessions.tokenHash }).from(sessions).where(eq(sessions.userId, userId)); await db.delete(sessions).where(eq(sessions.userId, userId)); if (rows.length) await redis().del(...rows.map((r) => `sess:${r.tokenHash}`)); } async function resolveUser(token: string): Promise<{ user: SessionUser; sessionId: string } | null> { const h = sha256(token); const cached = await getJson<{ user: SessionUser; sessionId: string }>(`sess:${h}`); if (cached) return cached; const row = await db .select({ id: users.id, username: users.username, status: users.status, sessionId: sessions.id }) .from(sessions) .innerJoin(users, eq(users.id, sessions.userId)) .where(and(eq(sessions.tokenHash, h), gt(sessions.expiresAt, new Date()))) .limit(1); if (!row.length) return null; const value = { user: { id: row[0].id, username: row[0].username, status: row[0].status }, sessionId: row[0].sessionId }; await setJson(`sess:${h}`, value, SESSION_CACHE_TTL); // Touch last_seen occasionally (cheap update). db.update(sessions).set({ lastSeenAt: new Date() }).where(eq(sessions.id, value.sessionId)).catch(() => {}); return value; } export async function createAdminSession(reply: FastifyReply, req: FastifyRequest, adminId: string): Promise { const token = randomToken(32); const expiresAt = new Date(Date.now() + ADMIN_SESSION_TTL_HOURS * 3600_000); await db.insert(adminSessions).values({ adminId, tokenHash: sha256(token), expiresAt, ip: clientIp(req) }); reply.setCookie(ADMIN_COOKIE, token, { httpOnly: true, secure: config.cookieSecure, sameSite: "strict", path: "/", maxAge: ADMIN_SESSION_TTL_HOURS * 3600 }); } export async function destroyAdminSession(reply: FastifyReply, req: FastifyRequest): Promise { const token = req.cookies[ADMIN_COOKIE]; if (token) await db.delete(adminSessions).where(eq(adminSessions.tokenHash, sha256(token))); reply.clearCookie(ADMIN_COOKIE, { path: "/" }); } async function resolveAdmin(token: string): Promise { const row = await db .select({ id: adminUsers.id, username: adminUsers.username, role: adminUsers.role, disabled: adminUsers.disabled }) .from(adminSessions) .innerJoin(adminUsers, eq(adminUsers.id, adminSessions.adminId)) .where(and(eq(adminSessions.tokenHash, sha256(token)), gt(adminSessions.expiresAt, new Date()))) .limit(1); if (!row.length || row[0].disabled) return null; return { id: row[0].id, username: row[0].username, role: row[0].role }; } export const authPlugin = fp(async (app) => { app.decorateRequest("user", null); app.decorateRequest("sessionId", null); app.decorateRequest("admin", null); app.addHook("onRequest", async (req) => { const token = req.cookies?.[SESSION_COOKIE]; if (token) { const r = await resolveUser(token); if (r) { req.user = r.user; req.sessionId = r.sessionId; } } if (req.url.startsWith("/api/admin")) { const at = req.cookies?.[ADMIN_COOKIE]; if (at) req.admin = await resolveAdmin(at); } }); // CSRF: state-changing requests must come from an allowed origin. app.addHook("preHandler", async (req) => { if (req.method === "GET" || req.method === "HEAD" || req.method === "OPTIONS") return; const origin = req.headers.origin ?? (req.headers.referer ? new URL(req.headers.referer).origin : null); const fetchSite = req.headers["sec-fetch-site"]; if (fetchSite === "same-origin" || fetchSite === "none") return; if (origin && allowedOrigins.has(origin.replace(/\/$/, ""))) return; // Requests forwarded by the Next.js server keep the browser's Origin header. Missing origin from a // non-browser client is accepted only when there is no session cookie to protect. if (!origin && !req.cookies?.[SESSION_COOKIE] && !req.cookies?.[ADMIN_COOKIE]) return; await logSecurity(req, "csrf.rejected", { meta: { origin, path: req.url } }); throw errors.forbidden("Cross-site request rejected."); }); }); export function requireUser(req: FastifyRequest): SessionUser { if (!req.user) throw errors.unauthorized(); if (req.user.status !== "active") throw errors.forbidden("This account is suspended."); return req.user; } export function requireAdmin(req: FastifyRequest): AdminUser { if (config.adminIpAllowlist.length && !config.adminIpAllowlist.includes(clientIp(req))) throw errors.forbidden("Admin access is restricted."); if (!req.admin) throw errors.unauthorized("Admin sign-in required."); return req.admin; }