import "server-only"; import { createHash, randomBytes, timingSafeEqual, createHmac } from "node:crypto"; import { hash as argonHash, verify as argonVerify } from "@node-rs/argon2"; import { cookies, headers } from "next/headers"; import { sql } from "./db"; import { env } from "./env"; export const SESSION_COOKIE = "csa_session"; const ARGON = { memoryCost: 65536, timeCost: 3, parallelism: 2, algorithm: 2 as const }; // argon2id, 64 MiB export async function hashPassword(pw: string): Promise { return argonHash(pw, ARGON); } export async function verifyPassword(hash: string, pw: string): Promise { try { return await argonVerify(hash, pw); } catch { return false; } } function tokenId(token: string): string { return createHash("sha256").update(token).digest("hex"); } export interface SessionInfo { id: string; createdAt: Date; expiresAt: Date; lastSeen: Date; userAgent: string | null; ip: string | null; current: boolean } export async function ownerExists(): Promise { const r = await sql`SELECT 1 FROM owner WHERE id = 1`; return r.length > 0; } // ---------------------------------------------------------------- rate limiting const MAX_FAILS = 5; const WINDOW_MIN = 15; export async function loginAllowed(ip: string): Promise<{ ok: boolean; retryAfterSec?: number }> { const rows = await sql<{ n: number; last: Date | null }[]>` SELECT count(*)::int AS n, max(at) AS last FROM login_attempts WHERE success = false AND at > now() - make_interval(mins => ${WINDOW_MIN}) AND (ip = ${ip} OR ${ip} = '*')`; const global = await sql<{ n: number }[]>`SELECT count(*)::int AS n FROM login_attempts WHERE success = false AND at > now() - interval '15 minutes'`; if (rows[0].n >= MAX_FAILS || global[0].n >= MAX_FAILS * 4) { const last = rows[0].last ? new Date(rows[0].last).getTime() : Date.now(); return { ok: false, retryAfterSec: Math.max(30, Math.ceil((last + WINDOW_MIN * 60_000 - Date.now()) / 1000)) }; } return { ok: true }; } export async function recordAttempt(ip: string, success: boolean) { await sql`INSERT INTO login_attempts (ip, success) VALUES (${ip}, ${success})`; if (success) await sql`DELETE FROM login_attempts WHERE ip = ${ip}`; } // ---------------------------------------------------------------- sessions export async function createSession(meta: { userAgent?: string | null; ip?: string | null }): Promise { const token = randomBytes(32).toString("base64url"); const expires = new Date(Date.now() + env.SESSION_TTL_DAYS * 86_400_000); await sql`INSERT INTO sessions (id, expires_at, user_agent, ip) VALUES (${tokenId(token)}, ${expires}, ${meta.userAgent ?? null}, ${meta.ip ?? null})`; // keep 5 min cleanup cheap await sql`DELETE FROM sessions WHERE expires_at < now() - interval '7 days'`; return token; } export async function setSessionCookie(token: string) { const c = await cookies(); c.set(SESSION_COOKIE, token, { httpOnly: true, sameSite: "strict", secure: env.NODE_ENV === "production", path: "/", maxAge: env.SESSION_TTL_DAYS * 86_400, }); } export async function clearSessionCookie() { const c = await cookies(); c.set(SESSION_COOKIE, "", { httpOnly: true, sameSite: "strict", secure: env.NODE_ENV === "production", path: "/", maxAge: 0 }); } /** Returns the session id if the request carries a valid, non-revoked, non-expired session. */ export async function getSession(): Promise<{ id: string } | null> { const c = await cookies(); const token = c.get(SESSION_COOKIE)?.value; if (!token) return null; const id = tokenId(token); const rows = await sql<{ id: string; stale: boolean }[]>` SELECT id, (last_seen < now() - interval '10 minutes') AS stale FROM sessions WHERE id = ${id} AND revoked_at IS NULL AND expires_at > now()`; if (!rows.length) return null; if (rows[0].stale) await sql`UPDATE sessions SET last_seen = now() WHERE id = ${id}`; return { id }; } export async function requireSession(): Promise<{ id: string }> { const s = await getSession(); if (!s) throw new AuthError(); return s; } export class AuthError extends Error { status = 401; constructor() { super("Unauthorized"); } } export async function revokeSession(id: string) { await sql`UPDATE sessions SET revoked_at = now() WHERE id = ${id}`; } export async function revokeAllSessions(exceptId?: string) { await sql`UPDATE sessions SET revoked_at = now() WHERE revoked_at IS NULL AND id <> ${exceptId ?? ""}`; } export async function listSessions(currentId: string): Promise { 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`; 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 })); } // ---------------------------------------------------------------- CSRF (origin check for mutating requests) export async function assertSameOrigin(): Promise { const h = await headers(); const origin = h.get("origin"); const host = h.get("x-forwarded-host") ?? h.get("host"); if (!origin) return; // same-origin fetch without Origin header (older UAs) — cookie is SameSite=Strict anyway const o = new URL(origin).host; if (host && o !== host) throw Object.assign(new Error("Cross-origin request rejected"), { status: 403 }); } export async function clientIp(): Promise { const h = await headers(); return (h.get("x-forwarded-for") ?? "").split(",")[0].trim() || h.get("x-real-ip") || "local"; } /** Signed, short-lived token for one-time URLs (e.g. file download links in ). */ export function signToken(payload: string, ttlSec = 3600): string { const exp = Math.floor(Date.now() / 1000) + ttlSec; const body = `${payload}.${exp}`; const sig = createHmac("sha256", env.SESSION_SECRET).update(body).digest("base64url"); return Buffer.from(body).toString("base64url") + "." + sig; } export function verifyToken(token: string): string | null { const [b, sig] = token.split("."); if (!b || !sig) return null; const body = Buffer.from(b, "base64url").toString(); const expect = createHmac("sha256", env.SESSION_SECRET).update(body).digest("base64url"); if (expect.length !== sig.length || !timingSafeEqual(Buffer.from(expect), Buffer.from(sig))) return null; const i = body.lastIndexOf("."); const exp = Number(body.slice(i + 1)); if (!exp || exp < Date.now() / 1000) return null; return body.slice(0, i); }