diff --git a/src/app/honig/actions.ts b/src/app/honig/actions.ts index 21ef4c3..94ac209 100644 --- a/src/app/honig/actions.ts +++ b/src/app/honig/actions.ts @@ -93,6 +93,19 @@ export async function toggleWishlistAction(formData: FormData): Promise { if (alreadyWishlisted) { await prisma.wishlistItem.deleteMany({ where: { customerId: customer.id, productId } }); } else { + // productId kommt unverifiziert aus dem Formular. Ohne diese Pruefung lief + // ein erfundener Wert in eine Fremdschluesselverletzung und damit in die + // allgemeine Fehlerseite — ein 500er als Antwort auf eine Eingabe, die + // schlicht ungueltig ist. Ein aus dem Verkauf genommenes Produkt gehoert + // ausserdem nicht neu auf eine Wunschliste; bereits gemerkte bleiben + // unberuehrt, damit sie sich weiterhin entfernen lassen. + const product = await prisma.product.findUnique({ + where: { id: productId }, + select: { isActive: true }, + }); + if (!product?.isActive) { + redirect(redirectPath); + } await prisma.wishlistItem.create({ data: { customerId: customer.id, productId } }); } diff --git a/src/lib/session.test.ts b/src/lib/session.test.ts new file mode 100644 index 0000000..183fdd1 --- /dev/null +++ b/src/lib/session.test.ts @@ -0,0 +1,164 @@ +// @vitest-environment node +// +// Abweichend von der globalen jsdom-Umgebung (vitest.config.ts): "jose" prueft +// seine Eingabe mit "instanceof Uint8Array", und das von jsdom bereitgestellte +// TextEncoder liefert ein Uint8Array aus einem anderen JS-Realm — die Pruefung +// schlaegt dann fehl, obwohl der Code stimmt. Im Betrieb laeuft dieses Modul +// ohnehin nie in einem DOM. + +/** + * Tests fuer die Token-Schicht der Anmeldung. + * + * Alle Token in session.ts sind mit DEMSELBEN Geheimnis signiert und tragen + * denselben Aussteller und dieselbe Zielgruppe. Was sie unterscheidet, ist + * allein das "type"-Feld. Damit ist die zentrale Frage nicht, ob ein gueltiger + * Token angenommen wird, sondern ob ein Token FUER EINEN ANDEREN ZWECK + * abgelehnt wird — sonst laesst sich der zweite Faktor umgehen, indem man das + * Challenge-Cookie in das Session-Cookie kopiert. + */ +import { describe, test, expect } from "vitest"; + +// Vor dem Import setzen: getSecretKey wirft ohne diesen Wert. +process.env.SESSION_SECRET = "test-secret-mit-mindestens-32-zeichen-laenge"; + +const { + signSessionToken, + verifySessionToken, + signTwoFactorChallenge, + verifyTwoFactorChallenge, + signTwoFactorSetup, + verifyTwoFactorSetup, + signTwoFactorBackupCodesDisplay, + verifyTwoFactorBackupCodesDisplay, + signEntraOAuthState, + verifyEntraOAuthState, +} = await import("./session"); + +const CUSTOMER_ID = "customer_123"; + +describe("Session-Token", () => { + test("gibt Kunde, Rolle und Sitzungsversion unveraendert zurueck", async () => { + const token = await signSessionToken({ + customerId: CUSTOMER_ID, + role: "ADMIN", + sessionVersion: 3, + }); + + await expect(verifySessionToken(token)).resolves.toEqual({ + customerId: CUSTOMER_ID, + role: "ADMIN", + sessionVersion: 3, + }); + }); + + test("stuft eine unbekannte Rolle auf KUNDE herunter statt sie zu uebernehmen", async () => { + // Die Rolle im Token ist nur der schnelle Vorfilter fuer den Proxy; + // autoritativ ist die Datenbank. Ein unerwarteter Wert darf trotzdem nie + // als etwas anderes als die geringste Berechtigung durchgehen. + const token = await signSessionToken({ + customerId: CUSTOMER_ID, + role: "SUPERADMIN" as never, + sessionVersion: 0, + }); + + await expect(verifySessionToken(token)).resolves.toMatchObject({ role: "KUNDE" }); + }); + + test("lehnt einen manipulierten Token ab", async () => { + const token = await signSessionToken({ + customerId: CUSTOMER_ID, + role: "KUNDE", + sessionVersion: 0, + }); + // Letztes Zeichen der Signatur veraendern. + const tampered = token.slice(0, -1) + (token.endsWith("A") ? "B" : "A"); + + await expect(verifySessionToken(tampered)).resolves.toBeNull(); + }); + + test("lehnt Unsinn ab, statt zu werfen", async () => { + await expect(verifySessionToken("kein.jwt.hier")).resolves.toBeNull(); + await expect(verifySessionToken("")).resolves.toBeNull(); + }); +}); + +describe("Token-Verwechslung", () => { + /** + * Der wichtigste Test dieser Datei. + * + * Das Challenge-Cookie wird ausgestellt, NACHDEM das Passwort stimmt, aber + * BEVOR der zweite Faktor geprueft ist. Gilt es als Session, ist die + * Zwei-Faktor-Pruefung eine Formalie, die sich mit einem Kopiervorgang im + * Browser ueberspringen laesst. + */ + test("ein 2FA-Challenge-Token ist KEINE gueltige Anmeldung", async () => { + const challenge = await signTwoFactorChallenge(CUSTOMER_ID); + + await expect(verifySessionToken(challenge)).resolves.toBeNull(); + }); + + test("ein 2FA-Einrichtungstoken ist KEINE gueltige Anmeldung", async () => { + const setup = await signTwoFactorSetup(CUSTOMER_ID, "JBSWY3DPEHPK3PXP"); + + await expect(verifySessionToken(setup)).resolves.toBeNull(); + }); + + test("ein Backup-Code-Anzeigetoken ist KEINE gueltige Anmeldung", async () => { + const display = await signTwoFactorBackupCodesDisplay(CUSTOMER_ID, ["aaaa-bbbb"]); + + await expect(verifySessionToken(display)).resolves.toBeNull(); + }); + + test("ein Entra-OAuth-State ist KEINE gueltige Anmeldung", async () => { + const state = await signEntraOAuthState("zufallswert", "code-verifier"); + + await expect(verifySessionToken(state)).resolves.toBeNull(); + }); + + test("ein Session-Token taugt umgekehrt fuer keinen der Sonderzwecke", async () => { + const session = await signSessionToken({ + customerId: CUSTOMER_ID, + role: "ADMIN", + sessionVersion: 0, + }); + + await expect(verifyTwoFactorChallenge(session)).resolves.toBeNull(); + await expect(verifyTwoFactorSetup(session)).resolves.toBeNull(); + await expect(verifyTwoFactorBackupCodesDisplay(session)).resolves.toBeNull(); + await expect(verifyEntraOAuthState(session)).resolves.toBeNull(); + }); + + test("die Sondertoken lassen sich auch untereinander nicht tauschen", async () => { + const challenge = await signTwoFactorChallenge(CUSTOMER_ID); + const setup = await signTwoFactorSetup(CUSTOMER_ID, "JBSWY3DPEHPK3PXP"); + + // Ein Einrichtungstoken darf keinen Anmeldeschritt abschliessen, und ein + // Challenge-Token traegt kein Secret, mit dem sich 2FA einrichten liesse. + await expect(verifyTwoFactorChallenge(setup)).resolves.toBeNull(); + await expect(verifyTwoFactorSetup(challenge)).resolves.toBeNull(); + }); +}); + +describe("Sondertoken im Normalfall", () => { + test("Challenge und Einrichtung geben ihren Inhalt zurueck", async () => { + const challenge = await signTwoFactorChallenge(CUSTOMER_ID); + await expect(verifyTwoFactorChallenge(challenge)).resolves.toEqual({ + customerId: CUSTOMER_ID, + }); + + const setup = await signTwoFactorSetup(CUSTOMER_ID, "JBSWY3DPEHPK3PXP"); + await expect(verifyTwoFactorSetup(setup)).resolves.toEqual({ + customerId: CUSTOMER_ID, + secret: "JBSWY3DPEHPK3PXP", + }); + }); + + test("der Entra-State haelt den PKCE-Verifier, der nie in die URL gehoert", async () => { + const state = await signEntraOAuthState("zufallswert", "code-verifier"); + + await expect(verifyEntraOAuthState(state)).resolves.toEqual({ + state: "zufallswert", + codeVerifier: "code-verifier", + }); + }); +}); diff --git a/src/lib/session.ts b/src/lib/session.ts index 5d87ef5..00aba82 100644 --- a/src/lib/session.ts +++ b/src/lib/session.ts @@ -62,6 +62,29 @@ export async function verifySessionToken(token: string): Promise