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