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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user