Personen lassen sich anlegen, bearbeiten und archivieren; ihr Detail zeigt ihre Schluessel. Unter "Meine Schluessel" hinterlegt jede Rolle eigene Public Keys (Freigabe Frage 4), wirksam erst nach Zuweisung durch einen Operator oder Admin. Warum so: - Die eigene Person entsteht beim ersten Aufruf oder wird verknuepft: per benutzerId, sonst per E-Mail - aber nur, wenn der OIDC-Anbieter die Adresse bestaetigt hat. Dafuer merkt sich Benutzer jetzt emailBestaetigt (Migration); sonst koennte jemand mit selbst gewaehlter Adresse die Keys einer fremden Person sehen. - Die Self-Service-Services nehmen nur den angemeldeten Benutzer und leiten die Person selbst ab; fremde Schluessel-IDs gelten als nicht vorhanden. - Entfernen nur fuer eigene, selbst hinterlegte Keys ohne Verwendung, bedingt geloescht, damit eine gleichzeitige Zuweisung nicht per Cascade verschwindet. - Archivieren statt Loeschen: wer bis wann welchen Key hatte, bleibt nachvollziehbar. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
246 lines
8.7 KiB
TypeScript
246 lines
8.7 KiB
TypeScript
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,
|
||
// Fuer die Verknuepfung im Self-Service (#013).
|
||
emailBestaetigt: anspruecke.emailBestaetigt,
|
||
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);
|
||
}
|
||
}
|