spb/chat-spboucher-ai
Public
1import "server-only";2import { createHash, randomBytes, timingSafeEqual, createHmac } from "node:crypto";3import { hash as argonHash, verify as argonVerify } from "@node-rs/argon2";4import { cookies, headers } from "next/headers";5import { sql } from "./db";6import { env } from "./env";78export const SESSION_COOKIE = "csa_session";9const ARGON = { memoryCost: 65536, timeCost: 3, parallelism: 2, algorithm: 2 as const }; // argon2id, 64 MiB1011export async function hashPassword(pw: string): Promise<string> {12 return argonHash(pw, ARGON);13}14export async function verifyPassword(hash: string, pw: string): Promise<boolean> {15 try { return await argonVerify(hash, pw); } catch { return false; }16}1718function tokenId(token: string): string {19 return createHash("sha256").update(token).digest("hex");20}2122export interface SessionInfo { id: string; createdAt: Date; expiresAt: Date; lastSeen: Date; userAgent: string | null; ip: string | null; current: boolean }2324export async function ownerExists(): Promise<boolean> {25 const r = await sql`SELECT 1 FROM owner WHERE id = 1`;26 return r.length > 0;27}2829// ---------------------------------------------------------------- rate limiting30const MAX_FAILS = 5; const WINDOW_MIN = 15;3132export async function loginAllowed(ip: string): Promise<{ ok: boolean; retryAfterSec?: number }> {33 const rows = await sql<{ n: number; last: Date | null }[]>`34 SELECT count(*)::int AS n, max(at) AS last FROM login_attempts35 WHERE success = false AND at > now() - make_interval(mins => ${WINDOW_MIN}) AND (ip = ${ip} OR ${ip} = '*')`;36 const global = await sql<{ n: number }[]>`SELECT count(*)::int AS n FROM login_attempts WHERE success = false AND at > now() - interval '15 minutes'`;37 if (rows[0].n >= MAX_FAILS || global[0].n >= MAX_FAILS * 4) {38 const last = rows[0].last ? new Date(rows[0].last).getTime() : Date.now();39 return { ok: false, retryAfterSec: Math.max(30, Math.ceil((last + WINDOW_MIN * 60_000 - Date.now()) / 1000)) };40 }41 return { ok: true };42}4344export async function recordAttempt(ip: string, success: boolean) {45 await sql`INSERT INTO login_attempts (ip, success) VALUES (${ip}, ${success})`;46 if (success) await sql`DELETE FROM login_attempts WHERE ip = ${ip}`;47}4849// ---------------------------------------------------------------- sessions50export async function createSession(meta: { userAgent?: string | null; ip?: string | null }): Promise<string> {51 const token = randomBytes(32).toString("base64url");52 const expires = new Date(Date.now() + env.SESSION_TTL_DAYS * 86_400_000);53 await sql`INSERT INTO sessions (id, expires_at, user_agent, ip) VALUES (${tokenId(token)}, ${expires}, ${meta.userAgent ?? null}, ${meta.ip ?? null})`;54 // keep 5 min cleanup cheap55 await sql`DELETE FROM sessions WHERE expires_at < now() - interval '7 days'`;56 return token;57}5859export async function setSessionCookie(token: string) {60 const c = await cookies();61 c.set(SESSION_COOKIE, token, {62 httpOnly: true, sameSite: "strict", secure: env.NODE_ENV === "production", path: "/",63 maxAge: env.SESSION_TTL_DAYS * 86_400,64 });65}6667export async function clearSessionCookie() {68 const c = await cookies();69 c.set(SESSION_COOKIE, "", { httpOnly: true, sameSite: "strict", secure: env.NODE_ENV === "production", path: "/", maxAge: 0 });70}7172/** Returns the session id if the request carries a valid, non-revoked, non-expired session. */73export async function getSession(): Promise<{ id: string } | null> {74 const c = await cookies();75 const token = c.get(SESSION_COOKIE)?.value;76 if (!token) return null;77 const id = tokenId(token);78 const rows = await sql<{ id: string; stale: boolean }[]>`79 SELECT id, (last_seen < now() - interval '10 minutes') AS stale FROM sessions80 WHERE id = ${id} AND revoked_at IS NULL AND expires_at > now()`;81 if (!rows.length) return null;82 if (rows[0].stale) await sql`UPDATE sessions SET last_seen = now() WHERE id = ${id}`;83 return { id };84}8586export async function requireSession(): Promise<{ id: string }> {87 const s = await getSession();88 if (!s) throw new AuthError();89 return s;90}9192export class AuthError extends Error { status = 401; constructor() { super("Unauthorized"); } }9394export async function revokeSession(id: string) {95 await sql`UPDATE sessions SET revoked_at = now() WHERE id = ${id}`;96}97export async function revokeAllSessions(exceptId?: string) {98 await sql`UPDATE sessions SET revoked_at = now() WHERE revoked_at IS NULL AND id <> ${exceptId ?? ""}`;99}100export async function listSessions(currentId: string): Promise<SessionInfo[]> {101 const rows = await sql`SELECT id, created_at, expires_at, last_seen, user_agent, ip FROM sessions WHERE revoked_at IS NULL AND expires_at > now() ORDER BY last_seen DESC`;102 return rows.map(r => ({ id: r.id, createdAt: r.created_at, expiresAt: r.expires_at, lastSeen: r.last_seen, userAgent: r.user_agent, ip: r.ip, current: r.id === currentId }));103}104105// ---------------------------------------------------------------- CSRF (origin check for mutating requests)106export async function assertSameOrigin(): Promise<void> {107 const h = await headers();108 const origin = h.get("origin"); const host = h.get("x-forwarded-host") ?? h.get("host");109 if (!origin) return; // same-origin fetch without Origin header (older UAs) — cookie is SameSite=Strict anyway110 const o = new URL(origin).host;111 if (host && o !== host) throw Object.assign(new Error("Cross-origin request rejected"), { status: 403 });112}113114export async function clientIp(): Promise<string> {115 const h = await headers();116 return (h.get("x-forwarded-for") ?? "").split(",")[0].trim() || h.get("x-real-ip") || "local";117}118119/** Signed, short-lived token for one-time URLs (e.g. file download links in <img src>). */120export function signToken(payload: string, ttlSec = 3600): string {121 const exp = Math.floor(Date.now() / 1000) + ttlSec;122 const body = `${payload}.${exp}`;123 const sig = createHmac("sha256", env.SESSION_SECRET).update(body).digest("base64url");124 return Buffer.from(body).toString("base64url") + "." + sig;125}126export function verifyToken(token: string): string | null {127 const [b, sig] = token.split(".");128 if (!b || !sig) return null;129 const body = Buffer.from(b, "base64url").toString();130 const expect = createHmac("sha256", env.SESSION_SECRET).update(body).digest("base64url");131 if (expect.length !== sig.length || !timingSafeEqual(Buffer.from(expect), Buffer.from(sig))) return null;132 const i = body.lastIndexOf("."); const exp = Number(body.slice(i + 1));133 if (!exp || exp < Date.now() / 1000) return null;134 return body.slice(0, i);135}136