spb/wp-spboucher-ai
Public
1import "server-only";2import { cookies, headers } from "next/headers";3import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";4import { cache } from "react";5import { sql } from "@/db";67export const COOKIE = process.env.NODE_ENV === "production" ? "__Host-wp_session" : "wp_session";8const IDLE_DAYS = 7, ABS_DAYS = 30, ROTATE_HOURS = 12;9const sha = (s: string) => createHash("sha256").update(s).digest("hex");10export function clientIp(h: Headers) { return (h.get("x-forwarded-for")?.split(",")[0] ?? h.get("x-real-ip") ?? "0.0.0.0").trim(); }11const cookieOpts = { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "strict" as const, path: "/" };1213export async function createSession(userId: string, opts: { mfaPassed?: boolean } = {}) {14 const token = randomBytes(32).toString("base64url"); const h = await headers(); const now = Date.now();15 await sql`INSERT INTO sessions (user_id, token_hash, csrf_secret, user_agent, ip, expires_at, absolute_expires_at, mfa_passed)16 VALUES (${userId}, ${sha(token)}, ${randomBytes(32).toString("base64url")}, ${h.get("user-agent")?.slice(0, 300) ?? null}, ${clientIp(h)},17 ${new Date(now + IDLE_DAYS * 864e5)}, ${new Date(now + ABS_DAYS * 864e5)}, ${opts.mfaPassed ?? false})`;18 return token;19}20export async function setSessionCookie(token: string) { (await cookies()).set(COOKIE, token, { ...cookieOpts, maxAge: ABS_DAYS * 86400 }); }21export async function clearSessionCookie() { (await cookies()).set(COOKIE, "", { ...cookieOpts, maxAge: 0 }); }2223export type SessionUser = { id: string; email: string; displayName: string; role: string; totpEnabled: boolean; mustChangePassword: boolean; sessionId: string; mfaPassed: boolean; csrfSecret: string };2425export const getSession = cache(async (): Promise<SessionUser | null> => {26 const token = (await cookies()).get(COOKIE)?.value; if (!token) return null;27 const rows = await sql`SELECT s.id AS session_id, s.mfa_passed, s.csrf_secret, s.last_seen_at, u.id, u.email, u.display_name, u.role, u.totp_enabled, u.must_change_password28 FROM sessions s JOIN users u ON u.id = s.user_id29 WHERE s.token_hash = ${sha(token)} AND s.revoked_at IS NULL AND s.expires_at > now() AND s.absolute_expires_at > now() LIMIT 1`;30 if (!rows.length) return null;31 const r = rows[0];32 if (Date.now() - new Date(r.last_seen_at).getTime() > 5 * 60_000)33 sql`UPDATE sessions SET last_seen_at = now(), expires_at = now() + ${IDLE_DAYS + " days"}::interval WHERE id = ${r.session_id}`.catch(() => {});34 return { id: r.id, email: r.email, displayName: r.display_name, role: r.role, totpEnabled: r.totp_enabled, mustChangePassword: r.must_change_password, sessionId: r.session_id, mfaPassed: r.mfa_passed, csrfSecret: r.csrf_secret };35});3637export async function rotateSession(sessionId: string): Promise<string> {38 const token = randomBytes(32).toString("base64url");39 await sql`UPDATE sessions SET token_hash = ${sha(token)}, rotated_at = now() WHERE id = ${sessionId}`;40 return token;41}42export async function maybeRotate(s: SessionUser) {43 const [row] = await sql`SELECT rotated_at FROM sessions WHERE id = ${s.sessionId}`;44 if (row && Date.now() - new Date(row.rotated_at).getTime() > ROTATE_HOURS * 3600e3) { const tk = await rotateSession(s.sessionId); await setSessionCookie(tk); }45}46export async function markMfaPassed(sessionId: string) { await sql`UPDATE sessions SET mfa_passed = true WHERE id = ${sessionId}`; }47export async function revokeSession(sessionId: string) { await sql`UPDATE sessions SET revoked_at = now() WHERE id = ${sessionId}`; }48export async function revokeOtherSessions(userId: string, keep: string) { await sql`UPDATE sessions SET revoked_at = now() WHERE user_id = ${userId} AND revoked_at IS NULL AND id <> ${keep}`; }4950export function csrfToken(s: SessionUser) { return createHmac("sha256", process.env.SESSION_SECRET ?? "dev").update(s.csrfSecret).digest("base64url"); }51export function verifyCsrf(s: SessionUser, token: string | null | undefined) {52 if (!token) return false; const a = Buffer.from(csrfToken(s)); const b = Buffer.from(token); return a.length === b.length && timingSafeEqual(a, b);53}54