SPB Git forge

spb/spinza

Public
8commits 1branches 0releases
1.6 MBsize
maindefault branch
16 days agolast push
TypeScript 97.6% SQL 1.4% JavaScript 0.5%
6.3 KB · 151 lines typescript
Raw Blame History
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