import "server-only"; import { createHash, randomBytes } from "node:crypto"; import { cookies } from "next/headers"; import { query, queryOne } from "@/lib/db"; import type { Role } from "@/lib/types"; export const SESSION_COOKIE = "session"; const SESSION_LIFETIME_MS = 30 * 24 * 60 * 60 * 1000; // 30 Tage const REFRESH_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000; // unter 7 Tage Restlaufzeit verlängern export interface SessionUser { id: string; email: string; full_name: string; role: Role; password_auth_enabled: boolean; } export interface Session { user: SessionUser; expiresAt: Date; } function hashToken(token: string): string { return createHash("sha256").update(token).digest("hex"); } function isSecureCookie(): boolean { return (process.env.APP_URL ?? "").startsWith("https://"); } /** Nur lesend – sicher in Server Components, Route Handlern und Server Actions. */ export async function getSession(): Promise { const token = (await cookies()).get(SESSION_COOKIE)?.value; if (!token) return null; const row = await queryOne<{ expires_at: Date; id: string; email: string; full_name: string; role: Role; password_auth_enabled: boolean; }>( `select s.expires_at, u.id, u.email, u.full_name, u.role, u.password_auth_enabled from sessions s join users u on u.id = s.user_id where s.token_hash = $1 and s.expires_at > now()`, [hashToken(token)] ); if (!row) return null; return { expiresAt: row.expires_at, user: { id: row.id, email: row.email, full_name: row.full_name, role: row.role, password_auth_enabled: row.password_auth_enabled, }, }; } export class UnauthorizedError extends Error {} export class ForbiddenError extends Error {} export async function requireSession(): Promise { const session = await getSession(); if (!session) throw new UnauthorizedError(); return session; } export async function requireRole(roles: Role[]): Promise { const session = await requireSession(); if (!roles.includes(session.user.role)) throw new ForbiddenError(); return session; } /** Legt eine neue Sitzung an und setzt das Session-Cookie. Nur in Route * Handlern/Server Actions aufrufbar (dort ist cookies().set() erlaubt). */ export async function createSession(userId: string): Promise { const token = randomBytes(32).toString("base64url"); const expiresAt = new Date(Date.now() + SESSION_LIFETIME_MS); await query( "insert into sessions (token_hash, user_id, expires_at) values ($1, $2, $3)", [hashToken(token), userId, expiresAt] ); (await cookies()).set(SESSION_COOKIE, token, { httpOnly: true, secure: isSecureCookie(), sameSite: "lax", path: "/", expires: expiresAt, }); } /** Löscht die Sitzung serverseitig (nicht nur das Cookie), damit ein * abgefangener Token nicht mehr verwendbar ist. */ export async function destroySession(): Promise { const store = await cookies(); const token = store.get(SESSION_COOKIE)?.value; if (token) { await query("delete from sessions where token_hash = $1", [hashToken(token)]); } store.delete(SESSION_COOKIE); } /** Wandelt UnauthorizedError/ForbiddenError in die passende JSON-Antwort * um; gibt null zurück, wenn err ein anderer Fehler ist (dann soll der * Aufrufer ihn weiterwerfen). Reduziert Wiederholung des immer gleichen * try/catch-Musters in den Route-Handlern. */ export function authErrorResponse(err: unknown): Response | null { if (err instanceof UnauthorizedError) { return NextResponseJson({ error: "unauthorized" }, 401); } if (err instanceof ForbiddenError) { return NextResponseJson({ error: "forbidden" }, 403); } return null; } function NextResponseJson(body: unknown, status: number): Response { return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" }, }); } export { REFRESH_THRESHOLD_MS, hashToken, isSecureCookie };