feat(auth): Anmeldung per OIDC und Notfall-Admin mit TOTP (#004)

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>
This commit is contained in:
2026-10-12 09:17:44 +02:00
co-authored by Claude Opus 5.5
parent 97e179d068
commit 5ef90310e1
20 changed files with 2320 additions and 0 deletions
+114
View File
@@ -0,0 +1,114 @@
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;
}