feat(anmeldung): lokale Benutzerkonten mit Pflicht-TOTP (#035)

Ohne authentik gab es nur den Notfall-Admin – ein geteiltes Konto ohne
Rollen und ohne erkennbare Akteure im Audit-Log. Admins legen jetzt
lokale Konten an; sshkv erzeugt ein Einmal-Passwort, das bei der ersten
Anmeldung ersetzt wird, danach ist TOTP Pflicht.

Rollen, Sperren (beendet Sitzungen sofort) und Zurücksetzen unter
Einstellungen → Benutzer, nur für Admins mit frischer Bestätigung.
SSHKV_LOKALE_KONTEN=0 schaltet lokale Konten ab; der Notfall-Admin
bleibt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 22:36:25 +02:00
co-authored by Claude Opus 5.5
parent d9eec3d991
commit 4219701b29
41 changed files with 1295 additions and 109 deletions
+75 -29
View File
@@ -1,7 +1,8 @@
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 { lokaleKontenAktiv, passwortFehler } from "@/lib/lokale-konten";
import { passwortHashen, passwortPruefen } from "@/lib/passwort-hash";
import { totpPruefen } from "@/lib/totp";
import type { OidcAnspruecke } from "@/server/auth/oidc";
import { rolleAusGruppen } from "@/server/auth/oidc";
@@ -9,7 +10,7 @@ import { protokollieren } from "@/server/services/protokoll";
import { versuchZaehlen, zaehlerLoeschen } from "@/server/services/ratenbegrenzung";
/**
* Anmeldelogik ohne Next (#004): OIDC-Konto uebernehmen, Notfall-Admin pruefen, TOTP
* Anmeldelogik ohne Next (#004, #035): OIDC-Konto uebernehmen, Passwort-Konten pruefen, TOTP
* einrichten, erneute Bestaetigung. Die Next-Schicht setzt danach nur noch das Cookie.
*/
@@ -27,8 +28,8 @@ export class AnmeldeFehler extends Error {
}
/** Fuenf Fehlversuche je Anmeldename in 15 Minuten. */
const NOTFALL_VERSUCHE = 5;
const NOTFALL_FENSTER_SEKUNDEN = 15 * 60;
const KONTO_VERSUCHE = 5;
const KONTO_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.";
@@ -115,53 +116,67 @@ export async function oidcBestaetigungVermerken(
});
}
// ---------------------------------------------------------------- Notfall-Admin
// ---------------------------------------------------------------- Lokale Konten
export type NotfallErgebnis =
/**
* Passwort-Konten: der Notfall-Admin (#004) und von Admins angelegte lokale Konten (#035).
* Beide melden sich mit Anmeldename, Passwort und Pflicht-TOTP an.
*/
export type KontoErgebnis =
| { readonly art: "angemeldet"; readonly benutzer: Benutzer }
| { readonly art: "passwortSetzen"; readonly benutzer: Benutzer }
| { readonly art: "totpEinrichten"; readonly benutzer: Benutzer };
/** Darf sich dieses Konto mit Passwort anmelden? OIDC-Konten nie, lokale nur wenn eingeschaltet. */
export function passwortKonto(benutzer: Pick<Benutzer, "passwortHash" | "oidcSub" | "istNotfall">): boolean {
return Boolean(benutzer.passwortHash) && !benutzer.oidcSub && (benutzer.istNotfall || lokaleKontenAktiv());
}
/** Protokoll-Praefix: der Notfall-Admin bleibt im Audit-Log als solcher erkennbar. */
const praefix = (benutzer: Pick<Benutzer, "istNotfall"> | null) =>
benutzer?.istNotfall ? "anmeldung.notfall" : "anmeldung.lokal";
/**
* 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).
* Ein Einmal-Passwort fuehrt nur zum Setzen eines eigenen, ein Konto ohne TOTP nur zur
* Einrichtung – nie direkt in die Anwendung (Freigabe Frage 5: TOTP ist Pflicht).
*/
export async function notfallAnmelden(
export async function kontoAnmelden(
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);
): Promise<KontoErgebnis> {
const name = anmeldename.trim().toLowerCase();
const zaehler = `konto:${name}`;
const versuch = await versuchZaehlen(zaehler, KONTO_VERSUCHE, KONTO_FENSTER_SEKUNDEN);
const benutzer = await prisma.benutzer.findUnique({ where: { anmeldename: name } });
if (!versuch.erlaubt) {
await protokollieren({
akteur: "System",
aktion: "anmeldung.notfall.gesperrt",
aktion: `${praefix(benutzer)}.gesperrt`,
ergebnis: "VERWEIGERT",
details: { anmeldename },
details: { anmeldename: name },
});
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;
benutzer && passwortKonto(benutzer) ? await passwortPruefen(benutzer.passwortHash!, passwort) : false;
if (!benutzer || !passwortStimmt || !benutzer.aktiv) {
await protokollieren({
akteur: "System",
aktion: "anmeldung.notfall.fehlgeschlagen",
aktion: `${praefix(benutzer)}.fehlgeschlagen`,
ergebnis: "VERWEIGERT",
details: { anmeldename },
details: { anmeldename: name },
});
throw new AnmeldeFehler(FALSCHE_ANGABEN);
}
if (benutzer.passwortWechselNoetig) return { art: "passwortSetzen", benutzer };
if (!benutzer.totpGeheimnis || !benutzer.totpEingerichtetAm) {
return { art: "totpEinrichten", benutzer };
}
@@ -175,7 +190,7 @@ export async function notfallAnmelden(
if (schritt === undefined) {
await protokollieren({
akteur: { id: benutzer.id, name: benutzer.name },
aktion: "anmeldung.notfall.totp_falsch",
aktion: `${praefix(benutzer)}.totp_falsch`,
ergebnis: "VERWEIGERT",
});
throw new AnmeldeFehler(FALSCHE_ANGABEN);
@@ -188,16 +203,42 @@ export async function notfallAnmelden(
});
await protokollieren({
akteur: { id: benutzer.id, name: benutzer.name },
aktion: "anmeldung.notfall",
aktion: praefix(benutzer),
ergebnis: "ERFOLG",
});
return { art: "angemeldet", benutzer: aktualisiert };
}
/**
* Ersetzt das Einmal-Passwort durch ein eigenes (#035). Nur moeglich, solange der Wechsel
* aussteht – das Kurzzeit-Token dafuer stellt allein ein richtiges Einmal-Passwort aus.
*/
export async function eigenesPasswortSetzen(benutzerId: string, neu: string): Promise<Benutzer> {
const benutzer = await prisma.benutzer.findUnique({ where: { id: benutzerId } });
if (!benutzer || !passwortKonto(benutzer) || !benutzer.passwortWechselNoetig || !benutzer.aktiv) {
throw new AnmeldeFehler("Für dieses Konto steht kein Passwortwechsel aus.");
}
const fehler = passwortFehler(neu, benutzer.anmeldename);
if (fehler) throw new AnmeldeFehler(fehler);
if (await passwortPruefen(benutzer.passwortHash!, neu)) {
throw new AnmeldeFehler("Bitte ein anderes Passwort als das Einmal-Passwort wählen.");
}
const aktualisiert = await prisma.benutzer.update({
where: { id: benutzerId },
data: { passwortHash: await passwortHashen(neu), passwortWechselNoetig: false },
});
await protokollieren({
akteur: { id: benutzer.id, name: benutzer.name },
aktion: "konto.passwort_gesetzt",
ergebnis: "ERFOLG",
});
return 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.
* das Konto mit einem Geheimnis fest, das in keiner App steht.
*/
export async function totpEinrichtungAbschliessen(
benutzerId: string,
@@ -206,7 +247,12 @@ export async function totpEinrichtungAbschliessen(
jetzt = new Date(),
): Promise<Benutzer> {
const benutzer = await prisma.benutzer.findUnique({ where: { id: benutzerId } });
if (!benutzer?.istNotfall || benutzer.totpEingerichtetAm) {
if (
!benutzer ||
!passwortKonto(benutzer) ||
benutzer.totpEingerichtetAm ||
benutzer.passwortWechselNoetig
) {
throw new AnmeldeFehler("TOTP ist für dieses Konto bereits eingerichtet.");
}
const schritt = totpPruefen(geheimnisBase32, code, jetzt.getTime());
@@ -227,18 +273,18 @@ export async function totpEinrichtungAbschliessen(
});
await protokollieren({
akteur: { id: benutzer.id, name: benutzer.name },
aktion: "notfall.totp_eingerichtet",
aktion: benutzer.istNotfall ? "notfall.totp_eingerichtet" : "konto.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) {
/** Erneute Bestaetigung eines Passwort-Kontos: Passwort und TOTP noch einmal. */
export async function kontoBestaetigen(benutzer: Benutzer, passwort: string, code: string): Promise<void> {
if (!passwortKonto(benutzer) || !benutzer.anmeldename) {
throw new AnmeldeFehler("Dieses Konto bestätigt sich über den Identitätsanbieter.");
}
const ergebnis = await notfallAnmelden(benutzer.anmeldename, passwort, code);
const ergebnis = await kontoAnmelden(benutzer.anmeldename, passwort, code);
if (ergebnis.art !== "angemeldet" || ergebnis.benutzer.id !== benutzer.id) {
throw new AnmeldeFehler(FALSCHE_ANGABEN);
}