Files
sshkeyvault/src/server/services/anmeldung.ts
T
florian.wildeandClaude Opus 5.5 4219701b29 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>
2026-10-12 22:36:25 +02:00

292 lines
11 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 { 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";
import { protokollieren } from "@/server/services/protokoll";
import { versuchZaehlen, zaehlerLoeschen } from "@/server/services/ratenbegrenzung";
/**
* Anmeldelogik ohne Next (#004, #035): OIDC-Konto uebernehmen, Passwort-Konten 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 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.";
// ---------------------------------------------------------------- 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",
});
}
// ---------------------------------------------------------------- Lokale Konten
/**
* 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.
*
* 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 kontoAnmelden(
anmeldename: string,
passwort: string,
code: string,
jetzt = new Date(),
): 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: `${praefix(benutzer)}.gesperrt`,
ergebnis: "VERWEIGERT",
details: { anmeldename: name },
});
throw new AnmeldeFehler(
`Zu viele Fehlversuche. Bitte in ${Math.ceil(versuch.wiederInSekunden / 60)} Minuten erneut versuchen.`,
);
}
const passwortStimmt =
benutzer && passwortKonto(benutzer) ? await passwortPruefen(benutzer.passwortHash!, passwort) : false;
if (!benutzer || !passwortStimmt || !benutzer.aktiv) {
await protokollieren({
akteur: "System",
aktion: `${praefix(benutzer)}.fehlgeschlagen`,
ergebnis: "VERWEIGERT",
details: { anmeldename: name },
});
throw new AnmeldeFehler(FALSCHE_ANGABEN);
}
if (benutzer.passwortWechselNoetig) return { art: "passwortSetzen", benutzer };
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: `${praefix(benutzer)}.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: 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
* das Konto 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 ||
!passwortKonto(benutzer) ||
benutzer.totpEingerichtetAm ||
benutzer.passwortWechselNoetig
) {
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: benutzer.istNotfall ? "notfall.totp_eingerichtet" : "konto.totp_eingerichtet",
ergebnis: "ERFOLG",
});
return aktualisiert;
}
/** 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 kontoAnmelden(benutzer.anmeldename, passwort, code);
if (ergebnis.art !== "angemeldet" || ergebnis.benutzer.id !== benutzer.id) {
throw new AnmeldeFehler(FALSCHE_ANGABEN);
}
}