Passkeys (WebAuthn/FIDO2) als zweiter Anmeldeweg - auf der Anmeldeseite und bei der erneuten Bestaetigung vor sensiblen Aktionen. Verwaltet unter "Mein Konto". Der Passkey ERSETZT den zweiten Faktor, statt zu ihm hinzuzukommen. Das ist tragbar, weil er zwei Faktoren in sich vereint: Besitz des Geraets und dessen Entsperrung. Damit das keine Annahme bleibt, wird die Nutzerverifikation angefordert (userVerification: 'required') und serverseitig nachgeprueft - eine Antwort ohne sie wird abgewiesen. TOTP muss vorher eingerichtet sein. Ein Passkey haengt an einem Geraet; waere er der einzige Weg hinein, koennte ein verlorenes Handy die Akte schliessen. Aufbau: - Die Pruefung laeuft vollstaendig im Credentials-Provider "passkey", nicht in der Server Action davor. Ein "war schon geprueft" ueber die Grenze zu reichen waere die Stelle, an der sich Anmeldungen faelschen lassen. - Challenges liegen serverseitig und werden per DELETE genau einmal verbraucht - die Datenbank entscheidet, wer sie bekommt. - Bereichskennung und Herkunft kommen aus AUTH_URL, nicht aus einer zweiten Konfigurationsquelle. - Ohne https wird der Knopf gar nicht erst angeboten. - Rate-Limiting, Kontosperre und Audit-Log gelten unveraendert; die Anmeldung nennt den verwendeten Passkey namentlich. Zwei Anmerkungen zur Migration: - "prisma migrate diff" wollte zusaetzlich die Trigramm-Indizes aus prisma/sql/suche.sql loeschen, die Prisma nicht kennt. Die Zeilen sind entfernt und im Migrationskopf vermerkt - ein DROP haette die unscharfe Suche stillgelegt, ohne dass etwas fehlgeschlagen waere. - "prisma format" hat das Schema durchgaengig neu ausgerichtet. Ohne Whitespace betrachtet sind es 88 hinzugefuegte Zeilen und keine geloeschte. Geprueft: 21 Einheitstests, drei End-to-End-Tests gegen Chromiums virtuellen Authenticator (einrichten, anmelden, bestaetigen, entfernen, Geraet ohne Verifikation abgewiesen), beide Geraeteprofile. 496 Tests, null Typfehler, Produktionsbau steht. Nebenbei: Die Zahlen im README waren veraltet (24 statt 33 Modelle, 46 statt 79 Seiten). Sie stehen jetzt mit dem Befehl daneben, mit dem man sie nachzaehlt.
194 lines
7.2 KiB
TypeScript
194 lines
7.2 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
import {
|
|
MAX_PASSKEYS_JE_BENUTZER,
|
|
PASSKEY_MELDUNG,
|
|
PasskeyFehler,
|
|
challengeAusAntwort,
|
|
passkeysMoeglich,
|
|
} from '@/lib/auth/passkey'
|
|
import { passkeyBrowserfehler } from '@/lib/passkey-browser'
|
|
import { env } from '@/lib/env'
|
|
|
|
/**
|
|
* Passkeys — die Teile, die sich ohne Datenbank und ohne Browser prüfen
|
|
* lassen.
|
|
*
|
|
* Der Schwerpunkt liegt auf der Herleitung von `rpId` und Herkunft aus
|
|
* `AUTH_URL`. Das ist bei WebAuthn die Stelle, an der eine Installation
|
|
* still kaputtgeht: Stimmt die Bereichskennung nicht, lässt sich zwar ein
|
|
* Passkey einrichten, aber nie wieder verwenden — und die Fehlermeldung des
|
|
* Browsers zeigt auf alles Mögliche, nur nicht auf die Konfiguration.
|
|
*
|
|
* Dieselbe Fehlerklasse hat uns bei `APP_BIND` und `ENC_KEY_V2` schon zwei
|
|
* Fassungen gekostet: eine Umgebungsvariable, die falsch ausgewertet wird,
|
|
* und ein Fehlerbild, das woandershin zeigt.
|
|
*/
|
|
|
|
let vorher: string | undefined
|
|
|
|
beforeEach(() => {
|
|
vorher = process.env.AUTH_URL
|
|
})
|
|
|
|
afterEach(() => {
|
|
if (vorher === undefined) delete process.env.AUTH_URL
|
|
else process.env.AUTH_URL = vorher
|
|
})
|
|
|
|
describe('Bereichskennung aus AUTH_URL', () => {
|
|
it('nimmt den nackten Hostnamen als rpId, ohne Schema und ohne Port', () => {
|
|
process.env.AUTH_URL = 'https://cockpit.example.de'
|
|
expect(env.passkey.rpId).toBe('cockpit.example.de')
|
|
expect(env.passkey.herkunft).toBe('https://cockpit.example.de')
|
|
})
|
|
|
|
it('lässt den Port in der Herkunft stehen, aber nicht in der rpId', () => {
|
|
// Die Herkunft wird zeichengenau mit dem verglichen, was im Browser in
|
|
// der Adresszeile stand - der Port gehoert dazu. Die rpId ist dagegen
|
|
// die Domain, an die der Schluessel gebunden wird; ein Port darf dort
|
|
// nicht auftauchen, sonst weist der Authenticator sie zurueck.
|
|
process.env.AUTH_URL = 'https://cockpit.example.de:8443'
|
|
expect(env.passkey.rpId).toBe('cockpit.example.de')
|
|
expect(env.passkey.herkunft).toBe('https://cockpit.example.de:8443')
|
|
})
|
|
|
|
it('verträgt einen Schrägstrich am Ende', () => {
|
|
process.env.AUTH_URL = 'https://cockpit.example.de/'
|
|
expect(env.passkey.herkunft).toBe('https://cockpit.example.de')
|
|
})
|
|
|
|
it('erkennt eine Unterdomain als eigene Kennung', () => {
|
|
// Ein Passkey fuer betreuung.example.de gilt NICHT fuer example.de -
|
|
// genau das ist der Phishingschutz.
|
|
process.env.AUTH_URL = 'https://betreuung.example.de'
|
|
expect(env.passkey.rpId).toBe('betreuung.example.de')
|
|
})
|
|
})
|
|
|
|
describe('Verfügbarkeit', () => {
|
|
it('bietet Passkeys über https an', () => {
|
|
process.env.AUTH_URL = 'https://cockpit.example.de'
|
|
expect(env.passkey.sicherAufrufbar).toBe(true)
|
|
expect(passkeysMoeglich()).toBe(true)
|
|
})
|
|
|
|
it('bietet sie auf localhost an, auch ohne https', () => {
|
|
// Der Browser wertet localhost als sicheren Kontext - sonst liesse
|
|
// sich in der Entwicklung nichts ausprobieren.
|
|
process.env.AUTH_URL = 'http://localhost:3000'
|
|
expect(env.passkey.sicherAufrufbar).toBe(true)
|
|
})
|
|
|
|
it('bietet sie NICHT über einfaches http an', () => {
|
|
// Wichtig, dass das der Server entscheidet und nicht der Browser: Ein
|
|
// Knopf, der beim Klick eine kryptische DOMException wirft, ist
|
|
// schlechter als gar kein Knopf.
|
|
process.env.AUTH_URL = 'http://cockpit.example.de'
|
|
expect(env.passkey.sicherAufrufbar).toBe(false)
|
|
expect(passkeysMoeglich()).toBe(false)
|
|
})
|
|
|
|
it('bietet sie auch über eine nackte IP ohne https nicht an', () => {
|
|
process.env.AUTH_URL = 'http://10.0.0.5:3000'
|
|
expect(env.passkey.sicherAufrufbar).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('Challenge aus der Antwort des Browsers', () => {
|
|
function clientData(inhalt: unknown): string {
|
|
return Buffer.from(JSON.stringify(inhalt), 'utf8').toString('base64url')
|
|
}
|
|
|
|
it('liest die Challenge aus den clientData', () => {
|
|
const daten = clientData({
|
|
type: 'webauthn.get',
|
|
challenge: 'AbCdEf-1234_xyz',
|
|
origin: 'https://cockpit.example.de',
|
|
})
|
|
expect(challengeAusAntwort(daten)).toBe('AbCdEf-1234_xyz')
|
|
})
|
|
|
|
it('weist eine Antwort ohne Challenge ab', () => {
|
|
const daten = clientData({ type: 'webauthn.get', origin: 'https://cockpit.example.de' })
|
|
expect(() => challengeAusAntwort(daten)).toThrowError(PasskeyFehler)
|
|
})
|
|
|
|
it('weist eine leere Challenge ab', () => {
|
|
expect(() => challengeAusAntwort(clientData({ challenge: '' }))).toThrowError(PasskeyFehler)
|
|
})
|
|
|
|
it('weist eine Challenge ab, die keine Zeichenkette ist', () => {
|
|
expect(() => challengeAusAntwort(clientData({ challenge: 42 }))).toThrowError(PasskeyFehler)
|
|
})
|
|
|
|
it('weist Unsinn ab, statt daran abzustürzen', () => {
|
|
expect(() => challengeAusAntwort('das ist kein base64url-JSON')).toThrowError(PasskeyFehler)
|
|
expect(() => challengeAusAntwort('')).toThrowError(PasskeyFehler)
|
|
})
|
|
})
|
|
|
|
describe('Meldungen', () => {
|
|
it('hat zu jedem Grund einen Satz, den man jemandem zeigen kann', () => {
|
|
// Ein fehlender Eintrag wuerde als "undefined" auf dem Bildschirm
|
|
// landen. Der Typ verhindert das beim Uebersetzen; hier wird geprueft,
|
|
// dass auch nichts Leeres durchrutscht.
|
|
for (const [grund, text] of Object.entries(PASSKEY_MELDUNG)) {
|
|
expect(text.length, `Meldung für "${grund}"`).toBeGreaterThan(20)
|
|
expect(text, `Meldung für "${grund}"`).not.toMatch(/undefined/)
|
|
}
|
|
})
|
|
|
|
it('nennt in der Obergrenze die tatsächliche Zahl', () => {
|
|
expect(PASSKEY_MELDUNG['zu-viele']).toContain(String(MAX_PASSKEYS_JE_BENUTZER))
|
|
})
|
|
|
|
it('erklärt bei fehlender Nutzerverifikation, warum abgelehnt wird', () => {
|
|
// Das ist die Meldung, die am ehesten nach Schikane aussieht. Sie muss
|
|
// den Grund nennen, sonst haelt man sie fuer einen Fehler.
|
|
expect(PASSKEY_MELDUNG['nicht-verifiziert']).toMatch(/Fingerabdruck|PIN/)
|
|
})
|
|
})
|
|
|
|
describe('Fehler des Browsers', () => {
|
|
function dom(name: string): Error {
|
|
const fehler = new Error('irgendwas Englisches')
|
|
fehler.name = name
|
|
return fehler
|
|
}
|
|
|
|
it('deutet NotAllowedError als Abbruch, nicht als Verbot', () => {
|
|
// Der Name ist irrefuehrend: Er kommt auch, wenn schlicht niemand den
|
|
// Finger aufgelegt hat. "Nicht erlaubt" waere die falsche Auskunft.
|
|
const text = passkeyBrowserfehler(dom('NotAllowedError'))
|
|
expect(text).toMatch(/Abgebrochen/)
|
|
expect(text).not.toMatch(/verboten|nicht erlaubt/i)
|
|
})
|
|
|
|
it('erklärt beim Einrichten, dass dieses Gerät schon einen Passkey hat', () => {
|
|
expect(passkeyBrowserfehler(dom('InvalidStateError'), true)).toMatch(/bereits/)
|
|
})
|
|
|
|
it('sagt bei SecurityError, dass die Adresse nicht passt', () => {
|
|
expect(passkeyBrowserfehler(dom('SecurityError'))).toMatch(/Adress/)
|
|
})
|
|
|
|
it('fällt bei unbekannten Fehlern auf einen brauchbaren Satz zurück', () => {
|
|
expect(passkeyBrowserfehler(dom('VoellingNeuerFehler'))).toMatch(/noch einmal/)
|
|
expect(passkeyBrowserfehler('gar kein Fehlerobjekt')).toMatch(/noch einmal/)
|
|
expect(passkeyBrowserfehler(undefined)).toMatch(/noch einmal/)
|
|
})
|
|
|
|
it('gibt nie die englische Originalmeldung weiter', () => {
|
|
for (const name of [
|
|
'NotAllowedError',
|
|
'InvalidStateError',
|
|
'SecurityError',
|
|
'NotSupportedError',
|
|
'AbortError',
|
|
'Unbekannt',
|
|
]) {
|
|
expect(passkeyBrowserfehler(dom(name))).not.toContain('irgendwas Englisches')
|
|
}
|
|
})
|
|
})
|