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,125 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { passwortHashen } from "@/lib/passwort-hash";
|
||||
import { base32Dekodieren, totpCode, totpGeheimnisErzeugen, totpSchritt } from "@/lib/totp";
|
||||
import {
|
||||
AnmeldeFehler,
|
||||
notfallAnmelden,
|
||||
oidcBenutzerUebernehmen,
|
||||
totpEinrichtungAbschliessen,
|
||||
} from "@/server/services/anmeldung";
|
||||
import { datenbankLeeren } from "./hilfen/db";
|
||||
|
||||
/** Notfall-Admin mit Pflicht-TOTP und OIDC-Kontouebernahme gegen die Datenbank (#004). */
|
||||
|
||||
const PASSWORT = "notfall-test-passwort-123";
|
||||
|
||||
async function notfallAnlegen() {
|
||||
return prisma.benutzer.create({
|
||||
data: {
|
||||
anmeldename: "notfall",
|
||||
name: "Notfall-Admin",
|
||||
rolle: "ADMIN",
|
||||
istNotfall: true,
|
||||
passwortHash: await passwortHashen(PASSWORT),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function codeZu(geheimnis: string, zeit: Date, versatz = 0) {
|
||||
return totpCode(base32Dekodieren(geheimnis), totpSchritt(zeit.getTime()) + versatz);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await datenbankLeeren();
|
||||
});
|
||||
|
||||
describe("Notfall-Admin", () => {
|
||||
it("fuehrt beim ersten Mal zur TOTP-Einrichtung, nicht in die Anwendung", async () => {
|
||||
await notfallAnlegen();
|
||||
const ergebnis = await notfallAnmelden("notfall", PASSWORT, "");
|
||||
expect(ergebnis.art).toBe("totpEinrichten");
|
||||
});
|
||||
|
||||
it("richtet TOTP ein und meldet danach nur mit gueltigem Code an", async () => {
|
||||
const benutzer = await notfallAnlegen();
|
||||
const geheimnis = totpGeheimnisErzeugen();
|
||||
const t0 = new Date("2026-10-12T10:00:00Z");
|
||||
|
||||
await expect(totpEinrichtungAbschliessen(benutzer.id, geheimnis, "000000", t0)).rejects.toThrow(
|
||||
AnmeldeFehler,
|
||||
);
|
||||
await totpEinrichtungAbschliessen(benutzer.id, geheimnis, codeZu(geheimnis, t0), t0);
|
||||
|
||||
const gespeichert = await prisma.benutzer.findUniqueOrThrow({ where: { id: benutzer.id } });
|
||||
// Das Geheimnis liegt verschluesselt, nicht im Klartext.
|
||||
expect(Buffer.from(gespeichert.totpGeheimnis ?? []).toString("utf8")).not.toContain(geheimnis);
|
||||
|
||||
const t1 = new Date(t0.getTime() + 60_000);
|
||||
await expect(notfallAnmelden("notfall", PASSWORT, "123456", t1)).rejects.toThrow(AnmeldeFehler);
|
||||
const ergebnis = await notfallAnmelden("notfall", PASSWORT, codeZu(geheimnis, t1), t1);
|
||||
expect(ergebnis.art).toBe("angemeldet");
|
||||
// Derselbe Code ein zweites Mal: abgelehnt.
|
||||
await expect(notfallAnmelden("notfall", PASSWORT, codeZu(geheimnis, t1), t1)).rejects.toThrow(
|
||||
AnmeldeFehler,
|
||||
);
|
||||
});
|
||||
|
||||
it("verraet nicht, ob Name oder Passwort falsch war", async () => {
|
||||
await notfallAnlegen();
|
||||
const a = await notfallAnmelden("gibtsnicht", PASSWORT, "").catch((f: Error) => f.message);
|
||||
const b = await notfallAnmelden("notfall", "falsch-falsch-falsch", "").catch((f: Error) => f.message);
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("sperrt nach fuenf Fehlversuchen, auch fuer das richtige Passwort", async () => {
|
||||
await notfallAnlegen();
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
await expect(notfallAnmelden("notfall", "falsch-falsch-falsch", "")).rejects.toThrow();
|
||||
}
|
||||
await expect(notfallAnmelden("notfall", PASSWORT, "")).rejects.toThrow(/Zu viele Fehlversuche/);
|
||||
});
|
||||
|
||||
it("protokolliert Fehlversuche ohne Passwort", async () => {
|
||||
await notfallAnlegen();
|
||||
await notfallAnmelden("notfall", "geheimes-falsches-pw", "").catch(() => undefined);
|
||||
const eintraege = await prisma.protokoll.findMany();
|
||||
expect(eintraege.map((e) => e.aktion)).toContain("anmeldung.notfall.fehlgeschlagen");
|
||||
expect(
|
||||
JSON.stringify(eintraege, (_, wert: unknown) => (typeof wert === "bigint" ? String(wert) : wert)),
|
||||
).not.toContain("geheimes-falsches-pw");
|
||||
});
|
||||
});
|
||||
|
||||
describe("OIDC-Konto", () => {
|
||||
const basis = {
|
||||
sub: "sub-1",
|
||||
email: "florian@example.de",
|
||||
emailBestaetigt: true,
|
||||
name: "Florian",
|
||||
ziel: "/",
|
||||
erneut: false,
|
||||
};
|
||||
|
||||
it("legt das Konto an und nimmt die Rolle aus den Gruppen", async () => {
|
||||
const benutzer = await oidcBenutzerUebernehmen({ ...basis, gruppen: ["sshkv-operators"] });
|
||||
expect(benutzer.rolle).toBe("OPERATOR");
|
||||
const wieder = await oidcBenutzerUebernehmen({ ...basis, gruppen: ["sshkv-readers"] });
|
||||
expect(wieder.id).toBe(benutzer.id);
|
||||
expect(wieder.rolle).toBe("LESER");
|
||||
});
|
||||
|
||||
it("verweigert ohne sshkv-Gruppe", async () => {
|
||||
await expect(oidcBenutzerUebernehmen({ ...basis, gruppen: [] })).rejects.toMatchObject({
|
||||
grund: "keine-gruppe",
|
||||
});
|
||||
expect(await prisma.benutzer.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("verknuepft nur ueber eine bestaetigte Adresse mit einem bestehenden Konto", async () => {
|
||||
await prisma.benutzer.create({ data: { email: basis.email, name: "Alt", rolle: "LESER" } });
|
||||
await expect(
|
||||
oidcBenutzerUebernehmen({ ...basis, emailBestaetigt: false, gruppen: ["sshkv-admins"] }),
|
||||
).rejects.toMatchObject({ grund: "konto-konflikt" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user