import { expect, test } from '@playwright/test' import type { BrowserContext, CDPSession, Page } from '@playwright/test' import { anmelden, fehlversucheLoeschen } from './hilfen' /** * Passkeys gegen einen echten Browser. * * WebAuthn laesst sich nicht sinnvoll mit Einheitstests pruefen: Was zaehlt, * passiert zwischen Browser und Authenticator. Chromium bringt dafuer einen * virtuellen Authenticator mit, der ueber das Debugprotokoll gesteuert wird - * er signiert echt, nur ohne Fingerabdrucksensor. * * Damit laesst sich der Ablauf vollstaendig durchspielen: einrichten, * abmelden, mit Passkey anmelden, entfernen. Und, wichtiger, der Fall, den * die Zusage dieser Anwendung traegt - ein Geraet OHNE Nutzerverifikation * darf nicht hereinkommen. */ type VirtuellerAuthenticator = { cdp: CDPSession id: string } async function authenticatorAnlegen( kontext: BrowserContext, seite: Page, verifiziert = true, ): Promise { const cdp = await kontext.newCDPSession(seite) await cdp.send('WebAuthn.enable') const { authenticatorId } = await cdp.send('WebAuthn.addVirtualAuthenticator', { options: { protocol: 'ctap2', // "internal" = im Geraet verbaut, wie Face ID oder Windows Hello. transport: 'internal', // Auffindbarer Schluessel - Voraussetzung fuer die Anmeldung ohne // vorherige Eingabe der E-Mail-Adresse. hasResidentKey: true, hasUserVerification: true, isUserVerified: verifiziert, automaticPresenceSimulation: true, }, }) return { cdp, id: authenticatorId } } async function passkeyEinrichten(seite: Page, name: string): Promise { await seite.goto('/konto') await seite.getByLabel('Neues Gerät').fill(name) await seite.getByRole('button', { name: 'Passkey einrichten' }).click() await expect(seite.getByText(`„${name}“ ist eingerichtet.`)).toBeVisible() } test.describe('Passkey', () => { test.beforeEach(async () => { await fehlversucheLoeschen() }) test('richtet einen Passkey ein und meldet sich anschliessend damit an', async ({ page, context, }) => { const authenticator = await authenticatorAnlegen(context, page) // --- 1. Auf dem gewoehnlichen Weg herein -------------------------------- await anmelden(page) // --- 2. Passkey einrichten --------------------------------------------- await passkeyEinrichten(page, 'Testgerät') // Er steht in der Liste, mit Datum und dem Hinweis "noch nicht benutzt". const eintrag = page.locator('li', { hasText: 'Testgerät' }) await expect(eintrag).toBeVisible() await expect(eintrag).toContainText('noch nicht benutzt') // --- 3. Abmelden -------------------------------------------------------- await context.clearCookies() await page.goto('/anmelden') await expect(page.getByRole('button', { name: 'Mit Passkey anmelden' })).toBeVisible() // --- 4. Mit Passkey anmelden ------------------------------------------- await Promise.all([ page.waitForURL((url) => !url.pathname.startsWith('/anmelden'), { timeout: 45_000 }), page.getByRole('button', { name: 'Mit Passkey anmelden' }).click(), ]) // Kein zweiter Schritt: Der Passkey IST der zweite Faktor. Wer hier auf // der Einrichtungsseite fuer TOTP landet, hat einen Fehler im Ablauf. expect(new URL(page.url()).pathname).not.toContain('/zwei-faktor') // --- 5. Die Benutzung ist vermerkt ------------------------------------- await page.goto('/konto') await expect(page.locator('li', { hasText: 'Testgerät' })).toContainText('zuletzt benutzt') // --- 6. Wieder entfernen, damit der naechste Lauf sauber startet ------- await page.locator('li', { hasText: 'Testgerät' }).getByRole('button').click() await page.getByRole('button', { name: 'Wirklich entfernen' }).click() await expect(page.getByText('„Testgerät“ wurde entfernt.')).toBeVisible() await authenticator.cdp.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId: authenticator.id, }) }) test('laesst ein Geraet ohne Nutzerverifikation nicht herein', async ({ page, context }) => { // Das ist die Zusage, auf der alles steht: Ein Passkey darf den zweiten // Faktor nur ersetzen, wenn das Geraet den Benutzer wirklich verifiziert // hat - Fingerabdruck, Gesicht oder PIN. Ein Schluessel, der auf blosses // Antippen hin signiert, ist ein Faktor, kein zweiter. // // Was dieser Test belegt: Die Anforderung erreicht das Geraet, und ein // Geraet ohne Verifikation kommt nicht herein. Abgewiesen wird dabei // schon vom Browser - im Protokoll steht deshalb kein fehlgeschlagener // Versuch. Die Pruefung auf dem Server (requireUserVerification und die // ausdrueckliche Abfrage von userVerified in anmeldungPruefen) ist der // Rueckhalt fuer den Fall, dass jemand die Antwort von Hand baut und den // Browser umgeht. Der laesst sich hier nicht nachstellen. const authenticator = await authenticatorAnlegen(context, page, true) await anmelden(page) await passkeyEinrichten(page, 'Ungesichertes Gerät') // Jetzt die Verifikation abschalten - so verhaelt sich ein Geraet ohne // eingerichtete Bildschirmsperre. await authenticator.cdp.send('WebAuthn.setUserVerified', { authenticatorId: authenticator.id, isUserVerified: false, }) await context.clearCookies() await page.goto('/anmelden') await page.getByRole('button', { name: 'Mit Passkey anmelden' }).click() // Entscheidend ist nicht der Wortlaut, sondern: Es geht nicht herein. await expect(page.getByRole('alert')).toBeVisible({ timeout: 30_000 }) await page.waitForTimeout(1000) expect(new URL(page.url()).pathname).toBe('/anmelden') // Aufraeumen: wieder verifizieren, anmelden, Passkey entfernen. await authenticator.cdp.send('WebAuthn.setUserVerified', { authenticatorId: authenticator.id, isUserVerified: true, }) await fehlversucheLoeschen() await anmelden(page) await page.goto('/konto') await page.locator('li', { hasText: 'Ungesichertes Gerät' }).getByRole('button').click() await page.getByRole('button', { name: 'Wirklich entfernen' }).click() await expect(page.getByText('„Ungesichertes Gerät“ wurde entfernt.')).toBeVisible() await authenticator.cdp.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId: authenticator.id, }) }) test('bestaetigt eine sensible Aktion mit dem Passkey statt mit Passwort und Code', async ({ page, context, }) => { const authenticator = await authenticatorAnlegen(context, page) await anmelden(page) await passkeyEinrichten(page, 'Bestätigungsgerät') // Der Vollexport verlangt eine frische Anmeldung. await page.goto('/berichte/export') await page.waitForURL(/\/(bestaetigen|berichte)/) if (new URL(page.url()).pathname === '/bestaetigen') { await Promise.all([ page.waitForURL((url) => url.pathname !== '/bestaetigen', { timeout: 45_000 }), page.getByRole('button', { name: 'Mit Passkey bestätigen' }).click(), ]) expect(new URL(page.url()).pathname).toContain('/berichte/export') } await page.goto('/konto') await page.locator('li', { hasText: 'Bestätigungsgerät' }).getByRole('button').click() await page.getByRole('button', { name: 'Wirklich entfernen' }).click() await expect(page.getByText('„Bestätigungsgerät“ wurde entfernt.')).toBeVisible() await authenticator.cdp.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId: authenticator.id, }) }) })