TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
1import fp from "fastify-plugin";2import type { FastifyReply, FastifyRequest } from "fastify";3import { and, db, eq, gt, sessions, users, adminSessions, adminUsers } from "@spinza/database";4import { ADMIN_COOKIE, ADMIN_SESSION_TTL_HOURS, SESSION_COOKIE, SESSION_TTL_DAYS } from "@spinza/shared";5import { randomToken, sha256 } from "../lib/crypto";6import { getJson, redis, setJson } from "../lib/redis";7import { config, allowedOrigins } from "../config";8import { errors } from "../lib/errors";9import { clientIp, logSecurity } from "../lib/security";1011export interface SessionUser {12 id: string;13 username: string;14 status: string;15}1617export interface AdminUser {18 id: string;19 username: string;20 role: string;21}2223declare module "fastify" {24 interface FastifyRequest {25 user: SessionUser | null;26 sessionId: string | null;27 admin: AdminUser | null;28 }29}3031const SESSION_CACHE_TTL = 60;3233export async function createSession(reply: FastifyReply, req: FastifyRequest, userId: string): Promise<void> {34 const token = randomToken(32);35 const tokenHash = sha256(token);36 const expiresAt = new Date(Date.now() + SESSION_TTL_DAYS * 86400_000);37 await db.insert(sessions).values({ userId, tokenHash, expiresAt, userAgent: (req.headers["user-agent"] ?? "").slice(0, 300), ip: clientIp(req) });38 reply.setCookie(SESSION_COOKIE, token, {39 httpOnly: true,40 secure: config.cookieSecure,41 sameSite: "lax",42 path: "/",43 maxAge: SESSION_TTL_DAYS * 86400,44 });45}4647export async function destroySession(reply: FastifyReply, req: FastifyRequest): Promise<void> {48 const token = req.cookies[SESSION_COOKIE];49 if (token) {50 const h = sha256(token);51 await db.delete(sessions).where(eq(sessions.tokenHash, h));52 await redis().del(`sess:${h}`);53 }54 reply.clearCookie(SESSION_COOKIE, { path: "/" });55}5657export async function destroyAllSessions(userId: string): Promise<void> {58 const rows = await db.select({ tokenHash: sessions.tokenHash }).from(sessions).where(eq(sessions.userId, userId));59 await db.delete(sessions).where(eq(sessions.userId, userId));60 if (rows.length) await redis().del(...rows.map((r) => `sess:${r.tokenHash}`));61}6263async function resolveUser(token: string): Promise<{ user: SessionUser; sessionId: string } | null> {64 const h = sha256(token);65 const cached = await getJson<{ user: SessionUser; sessionId: string }>(`sess:${h}`);66 if (cached) return cached;67 const row = await db68 .select({ id: users.id, username: users.username, status: users.status, sessionId: sessions.id })69 .from(sessions)70 .innerJoin(users, eq(users.id, sessions.userId))71 .where(and(eq(sessions.tokenHash, h), gt(sessions.expiresAt, new Date())))72 .limit(1);73 if (!row.length) return null;74 const value = { user: { id: row[0].id, username: row[0].username, status: row[0].status }, sessionId: row[0].sessionId };75 await setJson(`sess:${h}`, value, SESSION_CACHE_TTL);76 // Touch last_seen occasionally (cheap update).77 db.update(sessions).set({ lastSeenAt: new Date() }).where(eq(sessions.id, value.sessionId)).catch(() => {});78 return value;79}8081export async function createAdminSession(reply: FastifyReply, req: FastifyRequest, adminId: string): Promise<void> {82 const token = randomToken(32);83 const expiresAt = new Date(Date.now() + ADMIN_SESSION_TTL_HOURS * 3600_000);84 await db.insert(adminSessions).values({ adminId, tokenHash: sha256(token), expiresAt, ip: clientIp(req) });85 reply.setCookie(ADMIN_COOKIE, token, { httpOnly: true, secure: config.cookieSecure, sameSite: "strict", path: "/", maxAge: ADMIN_SESSION_TTL_HOURS * 3600 });86}8788export async function destroyAdminSession(reply: FastifyReply, req: FastifyRequest): Promise<void> {89 const token = req.cookies[ADMIN_COOKIE];90 if (token) await db.delete(adminSessions).where(eq(adminSessions.tokenHash, sha256(token)));91 reply.clearCookie(ADMIN_COOKIE, { path: "/" });92}9394async function resolveAdmin(token: string): Promise<AdminUser | null> {95 const row = await db96 .select({ id: adminUsers.id, username: adminUsers.username, role: adminUsers.role, disabled: adminUsers.disabled })97 .from(adminSessions)98 .innerJoin(adminUsers, eq(adminUsers.id, adminSessions.adminId))99 .where(and(eq(adminSessions.tokenHash, sha256(token)), gt(adminSessions.expiresAt, new Date())))100 .limit(1);101 if (!row.length || row[0].disabled) return null;102 return { id: row[0].id, username: row[0].username, role: row[0].role };103}104105export const authPlugin = fp(async (app) => {106 app.decorateRequest("user", null);107 app.decorateRequest("sessionId", null);108 app.decorateRequest("admin", null);109110 app.addHook("onRequest", async (req) => {111 const token = req.cookies?.[SESSION_COOKIE];112 if (token) {113 const r = await resolveUser(token);114 if (r) {115 req.user = r.user;116 req.sessionId = r.sessionId;117 }118 }119 if (req.url.startsWith("/api/admin")) {120 const at = req.cookies?.[ADMIN_COOKIE];121 if (at) req.admin = await resolveAdmin(at);122 }123 });124125 // CSRF: state-changing requests must come from an allowed origin.126 app.addHook("preHandler", async (req) => {127 if (req.method === "GET" || req.method === "HEAD" || req.method === "OPTIONS") return;128 const origin = req.headers.origin ?? (req.headers.referer ? new URL(req.headers.referer).origin : null);129 const fetchSite = req.headers["sec-fetch-site"];130 if (fetchSite === "same-origin" || fetchSite === "none") return;131 if (origin && allowedOrigins.has(origin.replace(/\/$/, ""))) return;132 // Requests forwarded by the Next.js server keep the browser's Origin header. Missing origin from a133 // non-browser client is accepted only when there is no session cookie to protect.134 if (!origin && !req.cookies?.[SESSION_COOKIE] && !req.cookies?.[ADMIN_COOKIE]) return;135 await logSecurity(req, "csrf.rejected", { meta: { origin, path: req.url } });136 throw errors.forbidden("Cross-site request rejected.");137 });138});139140export function requireUser(req: FastifyRequest): SessionUser {141 if (!req.user) throw errors.unauthorized();142 if (req.user.status !== "active") throw errors.forbidden("This account is suspended.");143 return req.user;144}145146export function requireAdmin(req: FastifyRequest): AdminUser {147 if (config.adminIpAllowlist.length && !config.adminIpAllowlist.includes(clientIp(req))) throw errors.forbidden("Admin access is restricted.");148 if (!req.admin) throw errors.unauthorized("Admin sign-in required.");149 return req.admin;150}151