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
+104
View File
@@ -0,0 +1,104 @@
import { cookies } from "next/headers";
import type { Benutzer } from "@/generated/prisma/client";
import {
SITZUNG_COOKIE,
SITZUNG_LAUFZEIT_STUNDEN,
sitzungErzeugen,
type SitzungsHerkunft,
} from "@/lib/sitzung";
/**
* Setzen und Loeschen des Sitzungscookies (#004).
*
* Getrennt von src/lib/sitzung.ts, weil dort nichts aus Next stehen soll – so
* bleibt das Signieren und Pruefen ohne Framework testbar.
*/
/**
* Ein Secure-Cookie ueber http kommt nie beim Server an, und die Anmeldung
* laeuft dann endlos im Kreis. Massgeblich ist das Schema von SSHKV_APP_URL, nicht das
* der eingehenden Anfrage – hinter einem Reverse Proxy ist die intern oft http,
* obwohl der Benutzer https spricht.
*/
let fehlendeAppUrlGemeldet = false;
export function ueberHttps(): boolean {
const basis = process.env["SSHKV_APP_URL"];
if (!basis) {
// Ohne SSHKV_APP_URL faellt das Secure-Flag weg. In der Entwicklung ueber http ist
// das richtig; in Produktion waere es ein Sitzungscookie im Klartext, und das
// soll nicht unbemerkt bleiben.
if (process.env["NODE_ENV"] === "production" && !fehlendeAppUrlGemeldet) {
fehlendeAppUrlGemeldet = true;
console.warn(
"SSHKV_APP_URL ist nicht gesetzt. Das Sitzungscookie wird ohne Secure-Flag gesetzt. " +
"Hinter HTTPS muss SSHKV_APP_URL auf die oeffentliche Adresse zeigen.",
);
}
return false;
}
try {
return new URL(basis).protocol === "https:";
} catch {
return false;
}
}
export async function sitzungSetzen(benutzer: Benutzer, herkunft: SitzungsHerkunft): Promise<void> {
const token = await sitzungErzeugen({
benutzerId: benutzer.id,
email: benutzer.email ?? benutzer.anmeldename ?? "",
name: benutzer.name,
herkunft,
});
const laden = await cookies();
laden.set(SITZUNG_COOKIE, token, {
httpOnly: true,
sameSite: "lax",
secure: ueberHttps(),
path: "/",
maxAge: SITZUNG_LAUFZEIT_STUNDEN * 60 * 60,
});
}
export async function sitzungBeenden(): Promise<void> {
const laden = await cookies();
laden.delete(SITZUNG_COOKIE);
}
/** Basis, die es nicht gibt – dient nur dazu, das Ziel aufzuloesen. */
const WEGWERF_BASIS = "http://ziel.invalid";
/**
* Nimmt nur Ziele innerhalb der eigenen Anwendung an.
*
* Ohne diese Pruefung waere `?ziel=https://fremd.example` eine offene
* Weiterleitung: ein Angreifer koennte einen Anmeldelink verschicken, der nach
* erfolgreicher Anmeldung auf seiner Seite landet.
*
* Geprueft wird ueber das Aufloesen gegen eine Wegwerf-Basis, nicht ueber
* Praefixe. Eine Praefix-Pruefung laesst sich austricksen: `/<TAB>/fremd.example`
* faengt mit einem Schraegstrich an und ist kein `//`, aber Browser entfernen
* Tabulator, Zeilenumbruch und Wagenruecklauf beim Verarbeiten einer URL – uebrig
* bleibt `//fremd.example`, also ein fremder Host. Dasselbe gilt fuer kodierte
* Varianten, Backslashes und `..`. Das Aufloesen macht genau das, was der Browser
* auch tut, und der Vergleich der Herkunft erschlaegt damit alle Formen auf
* einmal.
*/
export function sicheresZiel(ziel: string | null | undefined): string {
if (!ziel || !ziel.startsWith("/")) return "/";
try {
const aufgeloest = new URL(ziel, WEGWERF_BASIS);
if (aufgeloest.origin !== WEGWERF_BASIS) return "/";
const fertig = aufgeloest.pathname + aufgeloest.search + aufgeloest.hash;
// Zweite Runde: aus `/..//fremd.example` macht das Aufloesen `//fremd.example`,
// und genau das waere als Location-Kopfzeile wieder ein fremder Host. Ein
// Ergebnis, das selbst nicht mehr eindeutig zur eigenen Anwendung gehoert,
// wird verworfen.
if (!fertig.startsWith("/") || fertig.startsWith("//")) return "/";
return fertig;
} catch {
return "/";
}
}
+255
View File
@@ -0,0 +1,255 @@
import * as oidc from "openid-client";
import type { Rolle } from "@/generated/prisma/enums";
import { kurzzeitTokenErzeugen, kurzzeitTokenLesen } from "@/lib/kurzzeit-token";
import { staerksteRolle } from "@/server/auth/rechte";
/**
* Anmeldung ueber einen generischen OIDC-Anbieter (#004, Muster aus Laufzettel). Ziel ist
* authentik, aber es wird nichts Anbieter-Eigenes benutzt: alles kommt aus der Discovery.
* In der Entwicklung steht ein Mock-Anbieter in docker-compose.test.yml (#011).
*
* Kein Auth.js – v5 steht dauerhaft auf Beta (docs/PLAN.md).
*
* Dieselbe Strecke dient der erneuten Bestaetigung vor kritischen Aktionen: mit
* `prompt=login` und `max_age=0` muss sich der Benutzer beim Anbieter wirklich neu
* anmelden, und `auth_time` im ID-Token beweist, dass das nach dem Start geschah.
*/
/** Zweck des Kurzzeit-Tokens, das den Fluss ueber die Weiterleitung traegt. */
const FLUSS_ZWECK = "oidc-fluss";
/** Der Fluss ist eine Weiterleitung und zurueck – zehn Minuten sind reichlich. */
const FLUSS_GUELTIGKEIT_SEKUNDEN = 600;
export const OIDC_FLUSS_COOKIE = "sshkv_oidc";
export class OidcFehler extends Error {
constructor(nachricht: string, optionen?: ErrorOptions) {
super(nachricht, optionen);
this.name = "OidcFehler";
}
}
function umgebung(name: string): string | undefined {
const wert = process.env[name]?.trim();
return wert ? wert : undefined;
}
/** OIDC ist abgeschaltet, solange nicht alle drei Angaben vorliegen. */
export function oidcAktiv(): boolean {
return Boolean(
umgebung("SSHKV_OIDC_ISSUER") && umgebung("SSHKV_OIDC_CLIENT_ID") && umgebung("SSHKV_OIDC_CLIENT_SECRET"),
);
}
export function oidcName(): string {
return umgebung("SSHKV_OIDC_NAME") ?? "Single Sign-on";
}
/**
* Die Rueckruf-Adresse wird aus SSHKV_APP_URL abgeleitet und nicht aus der eingehenden
* Anfrage. Hinter einem Reverse Proxy ist der Host der Anfrage der interne –
* genau dort geht die Anmeldung sonst schief.
*/
export function rueckrufAdresse(): string {
const basis = umgebung("SSHKV_APP_URL");
if (!basis) {
throw new OidcFehler("SSHKV_APP_URL ist nicht gesetzt. Sie steckt in der OIDC-Rueckruf-Adresse.");
}
return new URL("/api/auth/oidc/rueckruf", basis).toString();
}
/**
* Die Discovery wird einmal je Aussteller geholt und behalten. Sie kostet einen
* Netzaufruf, den nicht jede Anmeldung bezahlen soll.
*/
let entdeckt:
{ aussteller: string; kundennummer: string; konfiguration: Promise<oidc.Configuration> } | undefined;
function konfigurationHolen(): Promise<oidc.Configuration> {
const aussteller = umgebung("SSHKV_OIDC_ISSUER");
const kundennummer = umgebung("SSHKV_OIDC_CLIENT_ID");
const geheimnis = umgebung("SSHKV_OIDC_CLIENT_SECRET");
if (!aussteller || !kundennummer || !geheimnis) {
throw new OidcFehler(
"OIDC ist nicht vollstaendig konfiguriert. Erwartet werden OIDC_ISSUER, " +
"SSHKV_OIDC_CLIENT_ID und OIDC_CLIENT_SECRET.",
);
}
if (entdeckt?.aussteller !== aussteller || entdeckt.kundennummer !== kundennummer) {
entdeckt = {
aussteller,
kundennummer,
konfiguration: oidc.discovery(new URL(aussteller), kundennummer, geheimnis).catch((fehler) => {
// Beim naechsten Versuch erneut holen, statt den Fehler ewig zu behalten.
entdeckt = undefined;
throw new OidcFehler(`Discovery bei ${aussteller} fehlgeschlagen.`, { cause: fehler });
}),
};
}
return entdeckt.konfiguration;
}
/** Nur fuer Tests: den gemerkten Discovery-Stand verwerfen. */
export function konfigurationVergessen(): void {
entdeckt = undefined;
}
export type FlussStart = {
/** Adresse, zu der der Browser weitergeleitet wird. */
readonly adresse: string;
/** Inhalt des Fluss-Cookies, das den Rueckruf wiedererkennbar macht. */
readonly flussToken: string;
};
/**
* Beginnt die Anmeldung. `ziel` ist die Seite, die der Benutzer eigentlich
* wollte – nach dem Rueckruf landet er dort und nicht stumpf auf der Uebersicht.
* Mit `erneut` wird daraus die Bestaetigung vor einer kritischen Aktion.
*/
export async function anmeldungStarten(ziel: string, erneut = false): Promise<FlussStart> {
const konfiguration = await konfigurationHolen();
const pruefwert = oidc.randomPKCECodeVerifier();
const herausforderung = await oidc.calculatePKCECodeChallenge(pruefwert);
const zustand = oidc.randomState();
const einmalwert = oidc.randomNonce();
const adresse = oidc.buildAuthorizationUrl(konfiguration, {
redirect_uri: rueckrufAdresse(),
scope: umgebung("SSHKV_OIDC_SCOPE") ?? "openid profile email groups",
code_challenge: herausforderung,
code_challenge_method: "S256",
state: zustand,
nonce: einmalwert,
...(erneut ? { prompt: "login", max_age: "0" } : {}),
});
const flussToken = await kurzzeitTokenErzeugen(
FLUSS_ZWECK,
{
pruefwert,
zustand,
einmalwert,
ziel,
erneut: erneut ? "ja" : "nein",
gestartetAm: String(Math.floor(Date.now() / 1000)),
},
FLUSS_GUELTIGKEIT_SEKUNDEN,
);
return { adresse: adresse.toString(), flussToken };
}
export type OidcAnspruecke = {
readonly sub: string;
readonly email: string;
/**
* Hat der Anbieter die Adresse bestaetigt? Entscheidend beim Verknuepfen mit
* einem bestehenden Konto: laesst ein Anbieter selbst gewaehlte, unbestaetigte
* Adressen zu, waere das sonst die Uebernahme eines fremden Kontos.
*/
readonly emailBestaetigt: boolean;
readonly name: string;
readonly gruppen: readonly string[];
/** Ziel, das vor der Anmeldung angefordert wurde. */
readonly ziel: string;
/** War das eine erneute Bestaetigung (Re-Auth) und keine gewoehnliche Anmeldung? */
readonly erneut: boolean;
};
/** Spielraum fuer Uhrenabweichung zwischen sshkv und Anbieter. */
const UHR_TOLERANZ_SEKUNDEN = 30;
function textAus(wert: unknown): string | undefined {
return typeof wert === "string" && wert.trim() ? wert.trim() : undefined;
}
function gruppenAus(wert: unknown): readonly string[] {
if (!Array.isArray(wert)) return [];
return wert.filter((eintrag): eintrag is string => typeof eintrag === "string");
}
/**
* Schliesst die Anmeldung ab. Prueft `state` und `nonce` gegen das Fluss-Cookie –
* ohne das waere der Rueckruf faelschbar.
*/
export async function anmeldungAbschliessen(
aktuelleAdresse: URL,
flussToken: string | undefined,
): Promise<OidcAnspruecke> {
const fluss = await kurzzeitTokenLesen(FLUSS_ZWECK, flussToken);
if (!fluss?.["pruefwert"] || !fluss["zustand"] || !fluss["einmalwert"]) {
throw new OidcFehler(
"Der Anmeldevorgang ist abgelaufen oder wurde nicht von hier begonnen. Bitte erneut anmelden.",
);
}
const konfiguration = await konfigurationHolen();
let antwort;
try {
antwort = await oidc.authorizationCodeGrant(konfiguration, aktuelleAdresse, {
pkceCodeVerifier: fluss["pruefwert"],
expectedState: fluss["zustand"],
expectedNonce: fluss["einmalwert"],
});
} catch (fehler) {
throw new OidcFehler("Der Anbieter hat die Anmeldung abgelehnt.", { cause: fehler });
}
const anspruecke = antwort.claims();
if (!anspruecke) {
throw new OidcFehler("Der Anbieter hat kein ID-Token geliefert.");
}
const sub = textAus(anspruecke.sub);
const email = textAus(anspruecke["email"]);
if (!sub) throw new OidcFehler("Das ID-Token enthaelt keine Kennung (sub).");
if (!email) {
throw new OidcFehler(
"Das ID-Token enthaelt keine E-Mail-Adresse. Der Scope 'email' muss beim Anbieter freigegeben sein.",
);
}
const erneut = fluss["erneut"] === "ja";
if (erneut) {
// Ohne auth_time laesst sich nicht beweisen, dass wirklich neu angemeldet wurde – ein
// Anbieter, der die laufende SSO-Sitzung einfach wiederverwendet, zaehlt nicht.
const anmeldezeit = anspruecke["auth_time"];
const gestartet = Number(fluss["gestartetAm"] ?? "0");
if (typeof anmeldezeit !== "number" || anmeldezeit + UHR_TOLERANZ_SEKUNDEN < gestartet) {
throw new OidcFehler(
"Der Anbieter hat keine erneute Anmeldung bestätigt (auth_time fehlt oder ist zu alt).",
);
}
}
return {
sub,
erneut,
email: email.toLowerCase(),
emailBestaetigt: anspruecke["email_verified"] === true,
name: textAus(anspruecke["name"]) ?? textAus(anspruecke["preferred_username"]) ?? email,
gruppen: gruppenAus(anspruecke["groups"]),
ziel: fluss["ziel"] ?? "/",
};
}
/**
* Bildet die Gruppen des Anbieters auf eine Rolle ab – bei **jeder** Anmeldung neu, damit
* eine Aenderung in authentik sofort greift. Wer in mehreren Gruppen steht, bekommt die
* staerkste Rolle.
*
* Anders als in Laufzettel gibt es **keinen** Standard: ohne passende Gruppe kein Zugang.
* sshkv verwaltet root-Zugaenge; „jeder im Verzeichnis darf lesen“ verriete bereits, welche
* Hosts es gibt und wer wohin darf.
*/
export function rolleAusGruppen(gruppen: readonly string[]): Rolle | undefined {
const zuordnung: readonly (readonly [string, Rolle])[] = [
[umgebung("SSHKV_OIDC_GROUP_ADMIN") ?? "sshkv-admins", "ADMIN"],
[umgebung("SSHKV_OIDC_GROUP_OPERATOR") ?? "sshkv-operators", "OPERATOR"],
[umgebung("SSHKV_OIDC_GROUP_READER") ?? "sshkv-readers", "LESER"],
];
const vorhanden = new Set(gruppen);
return staerksteRolle(zuordnung.filter(([gruppe]) => vorhanden.has(gruppe)).map(([, rolle]) => rolle));
}
+58
View File
@@ -0,0 +1,58 @@
import type { Rolle } from "@/generated/prisma/enums";
/**
* Rechte je Rolle (#004, Plan Abschnitt 4).
*
* Eine Tabelle statt verstreuter `rolle === "ADMIN"`-Abfragen: wer wissen will, was ein
* Operator darf, liest es hier – und `tests/rechte.test.ts` haelt die heiklen Zeilen fest.
* „Bestaetigung“ heisst: zusaetzlich frische Re-Auth (siehe bestaetigung.ts).
*/
export type Recht =
| "lesen"
| "eigeneSchluesselHinterlegen"
| "hostsVerwalten"
| "hostKeyEntscheiden"
| "schluesselVerwalten"
| "publicKeyAusrollen"
| "privateKeyAusrollen"
| "privateKeyAnzeigen"
| "deploymentKeyVerwalten"
| "massenwiderruf"
| "zielkontenVerwalten"
| "einstellungenVerwalten";
export const RECHTE: Readonly<Record<Recht, readonly Rolle[]>> = {
lesen: ["ADMIN", "OPERATOR", "LESER"],
// Freigabe Frage 4: jeder darf eigene Public Keys hinterlegen; wirksam erst nach Zuweisung.
eigeneSchluesselHinterlegen: ["ADMIN", "OPERATOR", "LESER"],
hostsVerwalten: ["ADMIN", "OPERATOR"],
hostKeyEntscheiden: ["ADMIN", "OPERATOR"],
schluesselVerwalten: ["ADMIN", "OPERATOR"],
publicKeyAusrollen: ["ADMIN", "OPERATOR"],
// Freigabe Frage 3: wer einen Private Key auf einen Host legen darf, auf den er selbst
// Zugriff hat, kann ihn faktisch lesen – deshalb nur Admins.
privateKeyAusrollen: ["ADMIN"],
privateKeyAnzeigen: ["ADMIN"],
deploymentKeyVerwalten: ["ADMIN"],
massenwiderruf: ["ADMIN"],
zielkontenVerwalten: ["ADMIN"],
einstellungenVerwalten: ["ADMIN"],
};
/** Rechte, die zusaetzlich eine frische Bestaetigung (Re-Auth) verlangen. */
export const BESTAETIGUNG_NOETIG: ReadonlySet<Recht> = new Set<Recht>([
"privateKeyAnzeigen",
"deploymentKeyVerwalten",
"massenwiderruf",
"zielkontenVerwalten",
]);
export function darf(rolle: Rolle, recht: Recht): boolean {
return RECHTE[recht].includes(rolle);
}
const STAERKE: Readonly<Record<Rolle, number>> = { LESER: 0, OPERATOR: 1, ADMIN: 2 };
export function staerksteRolle(rollen: readonly Rolle[]): Rolle | undefined {
return [...rollen].sort((a, b) => STAERKE[b] - STAERKE[a])[0];
}
+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;
}
+243
View File
@@ -0,0 +1,243 @@
import type { Benutzer } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { entschluesseln, verschluesseln } from "@/lib/geheimnisse";
import { passwortPruefen } from "@/lib/passwort-hash";
import { totpPruefen } from "@/lib/totp";
import type { OidcAnspruecke } from "@/server/auth/oidc";
import { rolleAusGruppen } from "@/server/auth/oidc";
import { protokollieren } from "@/server/services/protokoll";
import { versuchZaehlen, zaehlerLoeschen } from "@/server/services/ratenbegrenzung";
/**
* Anmeldelogik ohne Next (#004): OIDC-Konto uebernehmen, Notfall-Admin pruefen, TOTP
* einrichten, erneute Bestaetigung. Die Next-Schicht setzt danach nur noch das Cookie.
*/
/** `grund` landet als Code in der Adresse der Anmeldeseite – die Meldung selbst nie. */
export type AnmeldeGrund = "allgemein" | "keine-gruppe" | "konto-konflikt" | "deaktiviert" | "fremdes-konto";
export class AnmeldeFehler extends Error {
constructor(
nachricht: string,
readonly grund: AnmeldeGrund = "allgemein",
) {
super(nachricht);
this.name = "AnmeldeFehler";
}
}
/** Fuenf Fehlversuche je Anmeldename in 15 Minuten. */
const NOTFALL_VERSUCHE = 5;
const NOTFALL_FENSTER_SEKUNDEN = 15 * 60;
/** Allgemeine Meldung – verraet nicht, ob der Name, das Passwort oder der Code falsch war. */
const FALSCHE_ANGABEN = "Anmeldung fehlgeschlagen. Bitte Angaben prüfen.";
// ---------------------------------------------------------------- OIDC
/**
* Legt das Konto beim ersten Login an oder aktualisiert es. Die Rolle kommt bei jeder
* Anmeldung neu aus den Gruppen – wer in authentik herausfaellt, verliert sofort den Zugang.
*/
export async function oidcBenutzerUebernehmen(anspruecke: OidcAnspruecke): Promise<Benutzer> {
const rolle = rolleAusGruppen(anspruecke.gruppen);
if (!rolle) {
await protokollieren({
akteur: "System",
aktion: "anmeldung.verweigert",
ergebnis: "VERWEIGERT",
details: { email: anspruecke.email, grund: "keine sshkv-Gruppe" },
});
throw new AnmeldeFehler(
"Ihr Konto ist keiner SSHKeyVault-Gruppe zugeordnet. Bitte beim Administrator Zugriff beantragen.",
"keine-gruppe",
);
}
let benutzer = await prisma.benutzer.findUnique({ where: { oidcSub: anspruecke.sub } });
if (!benutzer) {
const perEmail = await prisma.benutzer.findUnique({ where: { email: anspruecke.email } });
if (perEmail) {
// Ein bestehendes Konto nur ueber eine vom Anbieter bestaetigte Adresse verknuepfen –
// sonst koennte jemand mit selbst gewaehlter Adresse ein fremdes Konto uebernehmen.
if (!anspruecke.emailBestaetigt || perEmail.oidcSub || perEmail.istNotfall) {
throw new AnmeldeFehler(
"Für diese E-Mail-Adresse besteht bereits ein anderes Konto.",
"konto-konflikt",
);
}
benutzer = perEmail;
}
}
const daten = {
oidcSub: anspruecke.sub,
email: anspruecke.email,
name: anspruecke.name,
rolle,
letzteAnmeldungAm: new Date(),
};
const ergebnis = benutzer
? await prisma.benutzer.update({ where: { id: benutzer.id }, data: daten })
: await prisma.benutzer.create({ data: daten });
if (!ergebnis.aktiv) {
throw new AnmeldeFehler("Dieses Konto ist deaktiviert.", "deaktiviert");
}
await protokollieren({
akteur: { id: ergebnis.id, name: ergebnis.name },
aktion: benutzer ? "anmeldung.oidc" : "anmeldung.oidc.erstmals",
ergebnis: "ERFOLG",
details: { rolle },
});
return ergebnis;
}
/** Erneute Bestaetigung per OIDC: nur fuer das Konto, das schon angemeldet ist. */
export async function oidcBestaetigungVermerken(
benutzerId: string,
anspruecke: OidcAnspruecke,
): Promise<void> {
const benutzer = await prisma.benutzer.findUnique({ where: { id: benutzerId } });
if (!benutzer || benutzer.oidcSub !== anspruecke.sub) {
throw new AnmeldeFehler(
"Die Bestätigung kam von einem anderen Konto als dem angemeldeten.",
"fremdes-konto",
);
}
await prisma.benutzer.update({ where: { id: benutzerId }, data: { bestaetigtAm: new Date() } });
await protokollieren({
akteur: { id: benutzer.id, name: benutzer.name },
aktion: "bestaetigung.oidc",
ergebnis: "ERFOLG",
});
}
// ---------------------------------------------------------------- Notfall-Admin
export type NotfallErgebnis =
| { readonly art: "angemeldet"; readonly benutzer: Benutzer }
| { readonly art: "totpEinrichten"; readonly benutzer: Benutzer };
/**
* Prueft Anmeldename, Passwort und – sobald eingerichtet – den TOTP-Code.
*
* Ohne eingerichtetes TOTP fuehrt ein richtiges Passwort nur zur Einrichtung, nie direkt
* in die Anwendung (Freigabe Frage 5: TOTP ist Pflicht).
*/
export async function notfallAnmelden(
anmeldename: string,
passwort: string,
code: string,
jetzt = new Date(),
): Promise<NotfallErgebnis> {
const zaehler = `notfall:${anmeldename.toLowerCase()}`;
const versuch = await versuchZaehlen(zaehler, NOTFALL_VERSUCHE, NOTFALL_FENSTER_SEKUNDEN);
if (!versuch.erlaubt) {
await protokollieren({
akteur: "System",
aktion: "anmeldung.notfall.gesperrt",
ergebnis: "VERWEIGERT",
details: { anmeldename },
});
throw new AnmeldeFehler(
`Zu viele Fehlversuche. Bitte in ${Math.ceil(versuch.wiederInSekunden / 60)} Minuten erneut versuchen.`,
);
}
const benutzer = await prisma.benutzer.findUnique({ where: { anmeldename } });
const passwortStimmt =
benutzer?.istNotfall && benutzer.passwortHash
? await passwortPruefen(benutzer.passwortHash, passwort)
: false;
if (!benutzer || !passwortStimmt || !benutzer.aktiv) {
await protokollieren({
akteur: "System",
aktion: "anmeldung.notfall.fehlgeschlagen",
ergebnis: "VERWEIGERT",
details: { anmeldename },
});
throw new AnmeldeFehler(FALSCHE_ANGABEN);
}
if (!benutzer.totpGeheimnis || !benutzer.totpEingerichtetAm) {
return { art: "totpEinrichten", benutzer };
}
const schritt = totpPruefen(
entschluesseln(Buffer.from(benutzer.totpGeheimnis).toString("utf8")),
code,
jetzt.getTime(),
benutzer.totpLetzterSchritt === null ? null : Number(benutzer.totpLetzterSchritt),
);
if (schritt === undefined) {
await protokollieren({
akteur: { id: benutzer.id, name: benutzer.name },
aktion: "anmeldung.notfall.totp_falsch",
ergebnis: "VERWEIGERT",
});
throw new AnmeldeFehler(FALSCHE_ANGABEN);
}
await zaehlerLoeschen(zaehler);
const aktualisiert = await prisma.benutzer.update({
where: { id: benutzer.id },
data: { totpLetzterSchritt: BigInt(schritt), letzteAnmeldungAm: jetzt, bestaetigtAm: jetzt },
});
await protokollieren({
akteur: { id: benutzer.id, name: benutzer.name },
aktion: "anmeldung.notfall",
ergebnis: "ERFOLG",
});
return { art: "angemeldet", benutzer: aktualisiert };
}
/**
* Schliesst die TOTP-Einrichtung ab. Das Geheimnis kommt aus dem kurzlebigen
* Einrichtungs-Token und wird erst gespeichert, wenn ein Code dazu passt – sonst saesse
* der Notfall-Admin mit einem Geheimnis fest, das in keiner App steht.
*/
export async function totpEinrichtungAbschliessen(
benutzerId: string,
geheimnisBase32: string,
code: string,
jetzt = new Date(),
): Promise<Benutzer> {
const benutzer = await prisma.benutzer.findUnique({ where: { id: benutzerId } });
if (!benutzer?.istNotfall || benutzer.totpEingerichtetAm) {
throw new AnmeldeFehler("TOTP ist für dieses Konto bereits eingerichtet.");
}
const schritt = totpPruefen(geheimnisBase32, code, jetzt.getTime());
if (schritt === undefined) {
throw new AnmeldeFehler(
"Der Code passt nicht. Bitte die Uhrzeit des Geräts prüfen und erneut versuchen.",
);
}
const aktualisiert = await prisma.benutzer.update({
where: { id: benutzerId },
data: {
totpGeheimnis: Buffer.from(verschluesseln(geheimnisBase32), "utf8"),
totpEingerichtetAm: jetzt,
totpLetzterSchritt: BigInt(schritt),
letzteAnmeldungAm: jetzt,
bestaetigtAm: jetzt,
},
});
await protokollieren({
akteur: { id: benutzer.id, name: benutzer.name },
aktion: "notfall.totp_eingerichtet",
ergebnis: "ERFOLG",
});
return aktualisiert;
}
/** Erneute Bestaetigung des Notfall-Admins: Passwort und TOTP noch einmal. */
export async function notfallBestaetigen(benutzer: Benutzer, passwort: string, code: string): Promise<void> {
if (!benutzer.istNotfall || !benutzer.anmeldename) {
throw new AnmeldeFehler("Dieses Konto bestätigt sich über den Identitätsanbieter.");
}
const ergebnis = await notfallAnmelden(benutzer.anmeldename, passwort, code);
if (ergebnis.art !== "angemeldet" || ergebnis.benutzer.id !== benutzer.id) {
throw new AnmeldeFehler(FALSCHE_ANGABEN);
}
}
+41
View File
@@ -0,0 +1,41 @@
import type { Prisma } from "@/generated/prisma/client";
import type { ProtokollErgebnis } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
/**
* Schreibt einen Eintrag ins revisionssichere Protokoll (#005).
*
* Hash und Verkettung berechnet die Datenbank (Trigger), hier wird nur eingefuegt.
* `details` laeuft vor dem Schreiben durch `schwaerzen()` (#006), damit kein Private Key
* und kein Passwort hineingeraet – auch nicht aus Versehen ueber ein Fehlerobjekt.
*/
export type ProtokollEintrag = {
readonly akteur: { readonly id: string; readonly name: string } | "System";
readonly aktion: string;
readonly ergebnis: ProtokollErgebnis;
readonly hostName?: string | null;
readonly zielbenutzer?: string | null;
readonly fingerprint?: string | null;
readonly diff?: string | null;
readonly details?: Record<string, unknown>;
};
export async function protokollieren(
eintrag: ProtokollEintrag,
client: Prisma.TransactionClient = prisma,
): Promise<void> {
const akteur = eintrag.akteur === "System" ? { id: null, name: "System" } : eintrag.akteur;
await client.protokoll.create({
data: {
akteurId: akteur.id,
akteurName: akteur.name,
aktion: eintrag.aktion,
ergebnis: eintrag.ergebnis,
hostName: eintrag.hostName ?? null,
zielbenutzer: eintrag.zielbenutzer ?? null,
fingerprint: eintrag.fingerprint ?? null,
diff: eintrag.diff ?? null,
details: (eintrag.details as Prisma.InputJsonValue | undefined) ?? undefined,
},
});
}
+37
View File
@@ -0,0 +1,37 @@
import { prisma } from "@/lib/db";
/**
* Rate-Limit mit festen Zeitfenstern in der Datenbank (#004).
*
* In der Datenbank statt im Speicher, weil Web-Prozesse neu starten und es spaeter mehr
* als einen geben kann – ein Zaehler im Speicher waere nach jedem Deploy wieder bei null.
* Ein einziges UPSERT haelt das auch bei gleichzeitigen Anfragen konsistent.
*/
export async function versuchZaehlen(
schluessel: string,
hoechstens: number,
fensterSekunden: number,
): Promise<{ erlaubt: boolean; wiederInSekunden: number }> {
const zeilen = await prisma.$queryRaw<{ anzahl: number; fensterStart: Date }[]>`
INSERT INTO "Ratenbegrenzung" ("schluessel", "fensterStart", "anzahl")
VALUES (${schluessel}, now(), 1)
ON CONFLICT ("schluessel") DO UPDATE SET
"anzahl" = CASE
WHEN "Ratenbegrenzung"."fensterStart" < now() - make_interval(secs => ${fensterSekunden}) THEN 1
ELSE "Ratenbegrenzung"."anzahl" + 1 END,
"fensterStart" = CASE
WHEN "Ratenbegrenzung"."fensterStart" < now() - make_interval(secs => ${fensterSekunden}) THEN now()
ELSE "Ratenbegrenzung"."fensterStart" END
RETURNING "anzahl", "fensterStart"`;
const zeile = zeilen[0];
if (!zeile) return { erlaubt: true, wiederInSekunden: 0 };
const ende = zeile.fensterStart.getTime() + fensterSekunden * 1000;
return {
erlaubt: zeile.anzahl <= hoechstens,
wiederInSekunden: Math.max(0, Math.ceil((ende - Date.now()) / 1000)),
};
}
export async function zaehlerLoeschen(schluessel: string): Promise<void> {
await prisma.ratenbegrenzung.deleteMany({ where: { schluessel } });
}