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>
292 lines
11 KiB
TypeScript
292 lines
11 KiB
TypeScript
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);
|
||
}
|
||
}
|