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
+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);
}
}