Sitzung, OIDC-Fluss und Rueckruf aus Laufzettel uebernommen und auf sshkv angepasst. Abweichungen mit Absicht: - Ohne passende sshkv-Gruppe gibt es keinen Zugang, auch keinen Lesezugriff: schon die Hostliste verraet, wer wohin darf. - Notfall-Admin nur mit TOTP (Freigabe Frage 5). Ein richtiges Passwort fuehrt beim ersten Mal nur zur Einrichtung. Jeder Code gilt einmal, fuenf Fehlversuche sperren 15 Minuten. - Kritische Rechte verlangen eine Bestaetigung, die hoechstens fuenf Minuten alt ist. Bei OIDC mit prompt=login, max_age=0 und Pruefung von auth_time, sonst zaehlte eine laufende SSO-Sitzung als Beweis. - Sitzungen laufen acht statt zwoelf Stunden. Die Seiten folgen, sobald das Design-System (#002) steht. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
115 lines
4.4 KiB
TypeScript
115 lines
4.4 KiB
TypeScript
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<Benutzer, "id" | "aktiv" | "sitzungenGueltigAb"> | 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<Angemeldet | undefined> => {
|
||
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<Angemeldet> {
|
||
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<Angemeldet> {
|
||
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;
|
||
}
|