spb/autoscholar
Public
1import "server-only";2import { RateLimiter, SESSION_COOKIE } from "@autoscholar/auth";3import {4 type AuthenticatedSession,5 forbidden,6 getEnv,7 resolveSession,8 unauthorized,9 verifySessionCsrf,10} from "@autoscholar/core";11import { cookies } from "next/headers";12import { db } from "./db";13import { clientIp } from "./ip";1415export const loginLimiterIp = new RateLimiter(20, 15 * 60_000);16export const loginLimiterUser = new RateLimiter(10, 15 * 60_000);17export const adminMutationLimiter = new RateLimiter(240, 60_000);18export const publicLimiter = new RateLimiter(600, 60_000);19export const uploadLimiter = new RateLimiter(30, 10 * 60_000);2021export function cookieOptions() {22 const env = getEnv();23 const secure = env.NODE_ENV === "production" || env.APP_BASE_URL.startsWith("https://");24 return { httpOnly: true, secure, sameSite: "strict" as const, path: "/", maxAge: 12 * 60 * 60 };25}2627export async function currentSession(): Promise<AuthenticatedSession | null> {28 const jar = await cookies();29 const token = jar.get(SESSION_COOKIE)?.value;30 if (!token) return null;31 return resolveSession(db(), token);32}3334export async function sessionToken(): Promise<string | null> {35 const jar = await cookies();36 return jar.get(SESSION_COOKIE)?.value ?? null;37}3839/** Server-side guard for admin APIs. `allowPendingRotation` lets the change-password endpoints run before rotation. */40export async function requireAdmin(41 req: Request,42 opts: { mutation?: boolean; allowPendingRotation?: boolean } = {},43): Promise<AuthenticatedSession & { ip: string | null }> {44 const token = await sessionToken();45 const sess = token ? await resolveSession(db(), token) : null;46 if (!sess) throw unauthorized();47 if (sess.mustChangePassword && !opts.allowPendingRotation)48 throw forbidden("password rotation required before using the admin API");49 if (opts.mutation || !["GET", "HEAD"].includes(req.method)) {50 const presented = req.headers.get("x-csrf-token");51 if (!(await verifySessionCsrf(db(), token!, presented))) throw forbidden("invalid CSRF token");52 const origin = req.headers.get("origin");53 const base = new URL(getEnv().APP_BASE_URL).origin;54 if (origin && origin !== base && !isLocalOrigin(origin)) throw forbidden("cross-origin request rejected");55 const key = `${sess.userId}`;56 if (!adminMutationLimiter.check(key).allowed)57 throw new (await import("@autoscholar/core")).AppError("rate_limited", "too many requests", 429);58 }59 return { ...sess, ip: clientIp(req) };60}6162function isLocalOrigin(origin: string): boolean {63 try {64 const u = new URL(origin);65 return (66 ["localhost", "127.0.0.1", "[::1]"].includes(u.hostname) || /^192\.168\.\d+\.\d+$/.test(u.hostname)67 );68 } catch {69 return false;70 }71}72