Files
sshkeyvault/src/server/services/anmeldung.ts
T
florian.wildeandClaude Opus 5.5 b5b43f41f6 feat(personen): Personen und Self-Service Meine Schluessel (#013)
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>
2026-10-12 10:38:55 +02:00

246 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}