import { cache } from "react"; import { cookies } from "next/headers"; import { redirect } from "next/navigation"; import type { Benutzer } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { SITZUNG_COOKIE, sitzungLesen, type Sitzung } from "@/lib/sitzung"; import { BESTAETIGUNG_NOETIG, darf, type Recht } from "@/server/auth/rechte"; /** * Wer ist angemeldet und was darf er (#004, Muster aus Laufzettel). * * Rolle und Aktiv-Status kommen bei **jeder** Anfrage aus der Datenbank, nicht aus dem * Cookie: eine entzogene Rolle greift sofort, und ein deaktiviertes Konto kommt nicht mit * einem gueltigen Cookie bis zum Ablauf weiter. */ export class RechteFehler extends Error { constructor(nachricht: string) { super(nachricht); this.name = "RechteFehler"; } } /** Kritische Aktion ohne frische Bestaetigung – die Oberflaeche leitet dann zu /bestaetigen. */ export class BestaetigungFehler extends Error { constructor() { super("Diese Aktion verlangt eine erneute Bestätigung Ihrer Anmeldung."); this.name = "BestaetigungFehler"; } } /** Wie lange eine erneute Bestaetigung fuer kritische Aktionen gilt. */ export const BESTAETIGUNG_GUELTIG_MS = 5 * 60 * 1000; export type Urteil = | { readonly gueltig: true } | { readonly gueltig: false; readonly grund: "unbekannt" | "deaktiviert" | "widerrufen" }; /** Reine Entscheidung, ob eine Sitzung noch traegt – ohne Datenbank und ohne Next testbar. */ export function sitzungBewerten( sitzung: Sitzung, benutzer: Pick | null, ): Urteil { if (!benutzer || benutzer.id !== sitzung.benutzerId) { return { gueltig: false, grund: "unbekannt" }; } if (!benutzer.aktiv) { return { gueltig: false, grund: "deaktiviert" }; } // iat steht in Sekunden und ist abgerundet; eine Sekunde Spiel, damit die Sitzung, die // im selben Augenblick wie `sitzungenGueltigAb` entsteht, nicht sofort verfaellt. if (sitzung.ausgestelltAm * 1000 + 1000 < benutzer.sitzungenGueltigAb.getTime()) { return { gueltig: false, grund: "widerrufen" }; } return { gueltig: true }; } /** Ist die letzte Bestaetigung frisch genug? Rein, damit testbar. */ export function bestaetigungFrisch(bestaetigtAm: Date | null, jetzt: Date = new Date()): boolean { if (!bestaetigtAm) return false; const alter = jetzt.getTime() - bestaetigtAm.getTime(); return alter >= 0 && alter <= BESTAETIGUNG_GUELTIG_MS; } export type Angemeldet = { readonly benutzer: Benutzer; readonly sitzung: Sitzung; }; /** Der angemeldete Benutzer oder `undefined`; `cache` teilt die Abfrage innerhalb einer Anfrage. */ export const angemeldet = cache(async (): Promise => { const laden = await cookies(); const sitzung = await sitzungLesen(laden.get(SITZUNG_COOKIE)?.value); if (!sitzung) return undefined; const benutzer = await prisma.benutzer.findUnique({ where: { id: sitzung.benutzerId } }); const urteil = sitzungBewerten(sitzung, benutzer); if (!urteil.gueltig || !benutzer) return undefined; // Ein Notfall-Admin ohne eingerichtetes TOTP ist nur halb angemeldet und kommt nicht // ueber die Einrichtung hinaus – siehe /anmelden/totp. if (benutzer.istNotfall && !benutzer.totpEingerichtetAm) return undefined; return { benutzer, sitzung }; }); /** * Fuer Seiten: verlangt eine Anmeldung und optional ein Recht. Ohne Anmeldung geht es zur * Anmeldeseite (mit Ruecksprungziel), ohne Recht auf /kein-zugriff. */ export async function sitzungErforderlich(recht: Recht = "lesen", ziel?: string): Promise { const vorhanden = await angemeldet(); if (!vorhanden) { redirect(ziel ? `/anmelden?ziel=${encodeURIComponent(ziel)}` : "/anmelden"); } if (!darf(vorhanden.benutzer.rolle, recht)) { redirect("/kein-zugriff"); } return vorhanden; } /** * Fuer Server Actions und Route Handler: wirft statt weiterzuleiten. Rechte aus * `BESTAETIGUNG_NOETIG` verlangen zusaetzlich eine frische Bestaetigung. */ export async function rechtErforderlich(recht: Recht): Promise { const vorhanden = await angemeldet(); if (!vorhanden) { throw new RechteFehler("Nicht angemeldet."); } if (!darf(vorhanden.benutzer.rolle, recht)) { throw new RechteFehler("Dafür fehlt Ihrer Rolle die Berechtigung."); } if (BESTAETIGUNG_NOETIG.has(recht) && !bestaetigungFrisch(vorhanden.benutzer.bestaetigtAm)) { throw new BestaetigungFehler(); } return vorhanden; }