Files
sshkeyvault/src/server/auth/sitzungspflicht.ts
T
florian.wildeandClaude Opus 5.5 5ef90310e1 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>
2026-10-12 09:17:44 +02:00

115 lines
4.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}