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,281 @@
|
||||
import { SignJWT } from "jose";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
SITZUNG_COOKIE,
|
||||
SITZUNG_LAUFZEIT_STUNDEN,
|
||||
SitzungsFehler,
|
||||
sitzungErzeugen,
|
||||
sitzungLesen,
|
||||
type Sitzung,
|
||||
} from "@/lib/sitzung";
|
||||
|
||||
// `tests/setup-env.ts` setzt einen Standardwert. Tests, die daran drehen, stellen ihn danach
|
||||
// wieder her, damit die Reihenfolge der Tests nichts aendert.
|
||||
const APP_SECRET_VORHER = process.env["SSHKV_APP_SECRET"];
|
||||
|
||||
function appSecretSetzen(wert: string): void {
|
||||
process.env["SSHKV_APP_SECRET"] = wert;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (APP_SECRET_VORHER === undefined) {
|
||||
delete process.env["SSHKV_APP_SECRET"];
|
||||
} else {
|
||||
process.env["SSHKV_APP_SECRET"] = APP_SECRET_VORHER;
|
||||
}
|
||||
});
|
||||
|
||||
const DATEN: Omit<Sitzung, "ausgestelltAm"> = {
|
||||
benutzerId: "b-0815",
|
||||
email: "anna.mueller@example.org",
|
||||
name: "Anna Müller",
|
||||
herkunft: "notfall",
|
||||
};
|
||||
|
||||
function schluessel(quelle: string): Uint8Array {
|
||||
return new TextEncoder().encode(quelle);
|
||||
}
|
||||
|
||||
/** Baut ein Token von Hand, damit sich Ablauf, Aussteller und Zielgruppe einzeln verbiegen lassen. */
|
||||
async function tokenBauen(optionen: {
|
||||
aussteller?: string;
|
||||
zielgruppe?: string;
|
||||
ausgestelltAm?: number;
|
||||
laeuftAbAm?: number;
|
||||
geheimnis?: string;
|
||||
inhalt?: Record<string, unknown>;
|
||||
}): Promise<string> {
|
||||
const jetzt = Math.floor(Date.now() / 1000);
|
||||
return await new SignJWT({
|
||||
email: DATEN.email,
|
||||
name: DATEN.name,
|
||||
herkunft: DATEN.herkunft,
|
||||
...optionen.inhalt,
|
||||
})
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setSubject(DATEN.benutzerId)
|
||||
.setIssuer(optionen.aussteller ?? "sshkv")
|
||||
.setAudience(optionen.zielgruppe ?? "sshkv")
|
||||
.setIssuedAt(optionen.ausgestelltAm ?? jetzt)
|
||||
.setExpirationTime(optionen.laeuftAbAm ?? jetzt + 3600)
|
||||
.sign(schluessel(optionen.geheimnis ?? APP_SECRET_VORHER ?? ""));
|
||||
}
|
||||
|
||||
/** Aendert genau ein Zeichen im angegebenen Abschnitt des Tokens. */
|
||||
function zeichenVerfaelschen(jwt: string, abschnitt: number): string {
|
||||
const teile = jwt.split(".");
|
||||
const teil = teile[abschnitt];
|
||||
if (teil === undefined || teil.length === 0) {
|
||||
throw new Error(`Abschnitt ${abschnitt} fehlt im Token.`);
|
||||
}
|
||||
teile[abschnitt] = (teil.startsWith("A") ? "B" : "A") + teil.slice(1);
|
||||
return teile.join(".");
|
||||
}
|
||||
|
||||
describe("Sitzung erzeugen und lesen", () => {
|
||||
it("ist verlustfrei, auch bei Umlauten im Namen", async () => {
|
||||
const jwt = await sitzungErzeugen(DATEN);
|
||||
const sitzung = await sitzungLesen(jwt);
|
||||
|
||||
expect(sitzung).toBeDefined();
|
||||
expect(sitzung?.benutzerId).toBe(DATEN.benutzerId);
|
||||
expect(sitzung?.email).toBe(DATEN.email);
|
||||
expect(sitzung?.name).toBe("Anna Müller");
|
||||
expect(sitzung?.herkunft).toBe("notfall");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Umlaute und Eszett", "Jörg Weiß-Bäcker"],
|
||||
["franzoesische Akzente", "Zoé Lefèvre"],
|
||||
["langer Doppelname", "Maximilian Freiherr von Hohenstein-Überlingen"],
|
||||
])("behaelt %s im Namen", async (_fall, name) => {
|
||||
const jwt = await sitzungErzeugen({ ...DATEN, name });
|
||||
expect((await sitzungLesen(jwt))?.name).toBe(name);
|
||||
});
|
||||
|
||||
it("setzt ausgestelltAm auf die aktuelle Zeit in Sekunden", async () => {
|
||||
const vorher = Math.floor(Date.now() / 1000);
|
||||
const sitzung = await sitzungLesen(await sitzungErzeugen(DATEN));
|
||||
const nachher = Math.floor(Date.now() / 1000);
|
||||
|
||||
expect(sitzung?.ausgestelltAm).toBeGreaterThanOrEqual(vorher);
|
||||
expect(sitzung?.ausgestelltAm).toBeLessThanOrEqual(nachher);
|
||||
});
|
||||
|
||||
it("uebernimmt beide Herkuenfte", async () => {
|
||||
for (const herkunft of ["notfall", "oidc"] as const) {
|
||||
const sitzung = await sitzungLesen(await sitzungErzeugen({ ...DATEN, herkunft }));
|
||||
expect(sitzung?.herkunft).toBe(herkunft);
|
||||
}
|
||||
});
|
||||
|
||||
it("signiert mit HS256 und laeuft nach der festgelegten Laufzeit ab", async () => {
|
||||
const jwt = await sitzungErzeugen(DATEN);
|
||||
const [kopfTeil, inhaltTeil] = jwt.split(".");
|
||||
const kopf = JSON.parse(Buffer.from(kopfTeil ?? "", "base64url").toString("utf8")) as {
|
||||
alg: string;
|
||||
};
|
||||
const inhalt = JSON.parse(Buffer.from(inhaltTeil ?? "", "base64url").toString("utf8")) as {
|
||||
iat: number;
|
||||
exp: number;
|
||||
iss: string;
|
||||
aud: string;
|
||||
};
|
||||
|
||||
expect(kopf.alg).toBe("HS256");
|
||||
expect(inhalt.exp - inhalt.iat).toBe(SITZUNG_LAUFZEIT_STUNDEN * 3600);
|
||||
expect(inhalt.iss).toBe("sshkv");
|
||||
expect(inhalt.aud).toBe("sshkv");
|
||||
});
|
||||
|
||||
it("nennt den Cookie-Namen und die Laufzeit unveraendert", () => {
|
||||
// Der Name steht im Proxy und in der Anmelderoute; eine Umbenennung meldet sich hier.
|
||||
expect(SITZUNG_COOKIE).toBe("sshkv_sitzung");
|
||||
expect(SITZUNG_LAUFZEIT_STUNDEN).toBe(8);
|
||||
});
|
||||
|
||||
it("legt den Namen nicht im Klartext offen, aber signiert ihn nachvollziehbar", async () => {
|
||||
// Ein JWT ist nur signiert, nicht verschluesselt -- das soll hier niemand fuer
|
||||
// Geheimhaltung halten. Der Test haelt fest, dass nichts Vertrauliches hineingehoert.
|
||||
const jwt = await sitzungErzeugen(DATEN);
|
||||
expect(jwt.split(".")).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sitzung lesen bei kaputtem Token", () => {
|
||||
it("gibt bei undefined undefined zurueck", async () => {
|
||||
await expect(sitzungLesen(undefined)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["leerer Wert", ""],
|
||||
["nur Leerzeichen", " "],
|
||||
["Klartext ohne Punkte", "keinTokenSondernText"],
|
||||
["zu wenige Abschnitte", "aaa.bbb"],
|
||||
["kein base64", "!!!.???.###"],
|
||||
])("gibt bei %s undefined zurueck", async (_fall, jwt) => {
|
||||
await expect(sitzungLesen(jwt)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("gibt bei einem an einem Zeichen verfaelschten Token undefined zurueck", async () => {
|
||||
const jwt = await sitzungErzeugen(DATEN);
|
||||
// Die Signatur passt danach nicht mehr -- genau das soll auffallen.
|
||||
await expect(sitzungLesen(zeichenVerfaelschen(jwt, 2))).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("gibt bei verfaelschtem Inhalt undefined zurueck", async () => {
|
||||
const jwt = await sitzungErzeugen(DATEN);
|
||||
await expect(sitzungLesen(zeichenVerfaelschen(jwt, 1))).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("gibt bei verfaelschtem Kopf undefined zurueck", async () => {
|
||||
const jwt = await sitzungErzeugen(DATEN);
|
||||
await expect(sitzungLesen(zeichenVerfaelschen(jwt, 0))).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("gibt bei einem abgeschnittenen Token undefined zurueck", async () => {
|
||||
const jwt = await sitzungErzeugen(DATEN);
|
||||
|
||||
await expect(sitzungLesen(jwt.slice(0, jwt.length - 5))).resolves.toBeUndefined();
|
||||
await expect(sitzungLesen(jwt.split(".").slice(0, 2).join("."))).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("gibt bei einem Token ohne Signatur undefined zurueck", async () => {
|
||||
// Der klassische alg-none-Versuch: Signatur weglassen und hoffen.
|
||||
const jwt = await sitzungErzeugen(DATEN);
|
||||
await expect(sitzungLesen(`${jwt.split(".").slice(0, 2).join(".")}.`)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("gibt bei einem mit anderem Schluessel signierten Token undefined zurueck", async () => {
|
||||
const fremd = await tokenBauen({ geheimnis: "ein-voellig-anderes-geheimnis-mit-genug-zeichen" });
|
||||
await expect(sitzungLesen(fremd)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("gibt bei einem Token undefined zurueck, dessen SSHKV_APP_SECRET inzwischen rotiert wurde", async () => {
|
||||
const jwt = await sitzungErzeugen(DATEN);
|
||||
appSecretSetzen("nach-der-rotation-ein-anderes-geheimnis-mit-laenge");
|
||||
|
||||
// Haelt fest, dass der Schluessel nicht am Zwischenspeicher vorbeilaeuft.
|
||||
await expect(sitzungLesen(jwt)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sitzung lesen bei abgelaufenem oder fremdem Token", () => {
|
||||
it("gibt bei einem abgelaufenen Token undefined zurueck", async () => {
|
||||
const jetzt = Math.floor(Date.now() / 1000);
|
||||
const abgelaufen = await tokenBauen({
|
||||
ausgestelltAm: jetzt - 13 * 3600,
|
||||
laeuftAbAm: jetzt - 3600,
|
||||
});
|
||||
|
||||
await expect(sitzungLesen(abgelaufen)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("nimmt ein Token an, das gerade noch laeuft", async () => {
|
||||
const jetzt = Math.floor(Date.now() / 1000);
|
||||
const knapp = await tokenBauen({ laeuftAbAm: jetzt + 60 });
|
||||
|
||||
expect(await sitzungLesen(knapp)).toBeDefined();
|
||||
});
|
||||
|
||||
it("gibt bei falschem Aussteller undefined zurueck", async () => {
|
||||
// Ein mit demselben Geheimnis signiertes Token aus einem anderen Zweck darf nicht als
|
||||
// Sitzung durchrutschen.
|
||||
const fremd = await tokenBauen({ aussteller: "proxledger" });
|
||||
await expect(sitzungLesen(fremd)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("gibt bei falscher Zielgruppe undefined zurueck", async () => {
|
||||
const fremd = await tokenBauen({ zielgruppe: "einladung" });
|
||||
await expect(sitzungLesen(fremd)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["fehlende E-Mail", { email: undefined }],
|
||||
["fehlender Name", { name: undefined }],
|
||||
["fehlende Herkunft", { herkunft: undefined }],
|
||||
["unbekannte Herkunft", { herkunft: "ldap" }],
|
||||
["Name als Zahl", { name: 42 }],
|
||||
])("gibt bei %s undefined zurueck", async (_fall, inhalt) => {
|
||||
const luecke = await tokenBauen({ inhalt });
|
||||
await expect(sitzungLesen(luecke)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SSHKV_APP_SECRET pruefen", () => {
|
||||
it.each([
|
||||
["fehlendes", ""],
|
||||
["viel zu kurzes", "kurz"],
|
||||
["knapp zu kurzes (31 Zeichen)", "a".repeat(31)],
|
||||
])("wirft beim Erzeugen bei %s SSHKV_APP_SECRET mit Hinweis auf openssl", async (_fall, wert) => {
|
||||
appSecretSetzen(wert);
|
||||
|
||||
await expect(sitzungErzeugen(DATEN)).rejects.toThrow(SitzungsFehler);
|
||||
await expect(sitzungErzeugen(DATEN)).rejects.toThrow(/openssl rand -base64 48/);
|
||||
await expect(sitzungErzeugen(DATEN)).rejects.toThrow(/mindestens 32 Zeichen/);
|
||||
});
|
||||
|
||||
it("wirft auch beim Lesen bei zu kurzem SSHKV_APP_SECRET", async () => {
|
||||
// Die eine Ausnahme von "sitzungLesen wirft nicht": ein zu kurzes Geheimnis ist ein
|
||||
// Konfigurationsfehler und darf nicht als "nicht angemeldet" durchgehen.
|
||||
const jwt = await sitzungErzeugen(DATEN);
|
||||
appSecretSetzen("kurz");
|
||||
|
||||
await expect(sitzungLesen(jwt)).rejects.toThrow(SitzungsFehler);
|
||||
await expect(sitzungLesen(undefined)).rejects.toThrow(/openssl rand -base64 48/);
|
||||
});
|
||||
|
||||
it("nimmt genau 32 Zeichen an", async () => {
|
||||
appSecretSetzen("b".repeat(32));
|
||||
|
||||
const sitzung = await sitzungLesen(await sitzungErzeugen(DATEN));
|
||||
expect(sitzung?.benutzerId).toBe(DATEN.benutzerId);
|
||||
});
|
||||
|
||||
it("stellt den Standardwert aus setup-env wieder her", async () => {
|
||||
// Haelt fest, dass die Aufraeumroutine oben greift -- sonst wuerden die Tests je nach
|
||||
// Reihenfolge unterschiedlich ausgehen.
|
||||
expect(process.env["SSHKV_APP_SECRET"]).toBe(APP_SECRET_VORHER);
|
||||
expect(await sitzungLesen(await sitzungErzeugen(DATEN))).toBeDefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user