diff --git a/docs/HARDWAREKEY.md b/docs/HARDWAREKEY.md new file mode 100644 index 0000000..24fb30a --- /dev/null +++ b/docs/HARDWAREKEY.md @@ -0,0 +1,476 @@ +# Hardwarekey als zweiter Faktor — Konzept und Übernahme + +**Stand: Deploymentcenter 2.9.2, 21. September 2026** + +Dieses Dokument beschreibt die Hardwarekey-Anmeldung so, wie sie im +Deploymentcenter läuft, und zwar mit dem Ziel, sie in einem anderen Projekt +**gleichwertig** nachzubauen. Es ist bewusst konzeptlastig: Der PHP-Code ist +die Referenz, nicht der Kern. Der Kern sind die Entscheidungen — und +besonders die drei Dinge, die *wie* eine Prüfung aussehen und keine sind. + +--- + +## 1. Was das Verfahren leistet — und was nicht + +**Leistet:** Nach dem Passwort verlangt die Anmeldung einen Gegenstand, der +physisch vorliegen muss. Es ist beweisbar, dass der private Schlüssel in +einem Sicherheitselement entstanden ist und es nie verlassen hat. Die +Anmeldung ist an die Adresse gebunden (RP-ID), also phishing-resistent: Eine +nachgebaute Seite bekommt keine gültige Signatur, weil der Browser die echte +Herkunft in die signierten Daten schreibt. + +**Leistet nicht:** + +- **Keine Prüfung bei der Anmeldung.** Attestation gibt es im Standard nur + bei der *Registrierung*. Die Aussage lautet: „Dieser Schlüssel ist + nachweislich in Hardware entstanden" — und sie trägt danach, weil der + private Teil diese Hardware nicht verlassen kann. Bei jeder späteren + Anmeldung wird nur noch die Signatur gegen den gespeicherten öffentlichen + Schlüssel geprüft. +- **Kein Schutz gegen einen kompromittierten Client.** Wer den Browser + kontrolliert, kann eine echte Zeremonie gegen einen echten Schlüssel + auslösen. Dagegen hilft Attestation nicht und soll es nicht. +- **Keine Aussage über den Menschen.** `userVerification` steht hier auf + `discouraged`, es wird also keine PIN und kein Fingerabdruck verlangt. Wer + den Schlüssel in der Hand hat, kommt hinein. Siehe §8. + +--- + +## 2. Die drei Fallen + +Das ist der wichtigste Abschnitt. Alle drei waren in der ersten Fassung +(2.9.0) vorhanden und wurden erst im Betrieb als wirkungslos erkannt. + +### 2.1 `authenticatorAttachment: "cross-platform"` ist keine Prüfung + +```js +authenticatorSelection: { authenticatorAttachment: 'cross-platform' } +``` + +Liest sich wie „nur angesteckte Hardware". Steuert aber nur, was der Browser +dem Benutzer **anbietet**. Ein Passwortverwalter meldet sich ebenfalls als +`cross-platform` und kommt anstandslos durch. Die Angabe darf drinbleiben — +sie verbessert die Auswahl —, aber sie sichert nichts zu. + +### 2.2 `response.authenticatorAttachment` ist wertlos + +Das Feld setzt der **Client**. Der Client ist genau die Instanz, gegen die +serverseitig nichts zu beweisen ist. Wer darauf eine Regel stützt, hat eine +Regel, die der Angreifer selbst ausfüllt. + +### 2.3 Ein `x5c` allein beweist nichts + +Der verführerischste Fehler. „Es ist ein Zertifikat dabei, also ist es +Hardware" — nein. Jeder Authenticator kann sich selbst eines ausstellen. + +Dieser Fall wurde gegen den fertigen Server gefahren, mit: + +- eigenem EC-Schlüsselpaar, +- selbst ausgestelltem Zertifikat, Betreff `CN=Yubico U2F EE Serial + 99999999, O=Yubico AB, OU=Authenticator Attestation`, +- der AAGUID eines echten YubiKey 5 NFC — in `authData` **und** in der + Zertifikatserweiterung `1.3.6.1.4.1.45724.1.1.4`, +- einer kryptografisch einwandfreien ES256-Signatur über + `authData || SHA256(clientDataJSON)`. + +Antwort: *„Die Zertifikatskette endet nicht bei einer hinterlegten +Herstellerwurzel."* Abgelehnt an der **Kette**, nicht an der Signatur. Genau +das ist die Probe, die eine Umsetzung bestehen muss. Kommt die Ablehnung +wegen der Signatur, hat der Test sich selbst falsch gebaut und beweist +nichts. + +> **Die einzige Eigenschaft, die Software nicht herstellen kann, ist eine +> Kette bis zu einer Wurzel, deren privaten Schlüssel der Hersteller hat.** +> Alles andere ist abschreibbar. + +### 2.4 Und die AAGUID? + +Eine AAGUID ist eine **Behauptung im Datenstrom**. Als alleiniges Kriterium +wertlos. Sinnvoll erst *zusätzlich* zur Wurzelprüfung — und dann muss die +AAGUID aus `authData` gegen die im Zertifikat gegengeprüft werden, sonst +filtert man wieder nach einer Behauptung. + +**Empfehlung: Modellliste leer lassen.** Hersteller vergeben neuen +Firmwareständen neue AAGUIDs. Eine gepflegte Liste ist eine Liste, die +irgendwann einen echten Schlüssel abweist. + +--- + +## 3. Datenmodell + +```sql +CREATE TABLE user_credentials ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + credential_id VARBINARY(255) NOT NULL, -- roh vom Authenticator + public_key TEXT NOT NULL, -- PEM, nicht COSE (siehe unten) + alg INT NOT NULL DEFAULT -7, -- -7 = ES256, -257 = RS256 + sign_count BIGINT UNSIGNED NOT NULL DEFAULT 0, + aaguid VARBINARY(16) NULL, + attested TINYINT(1) NOT NULL DEFAULT 0, -- <- einzige Entscheidungsspalte + attestation_fmt VARCHAR(32) NULL, + attestation_ca VARCHAR(190) NULL, + attestation_root VARCHAR(190) NULL, + transports VARCHAR(128) NULL, + label VARCHAR(64) NOT NULL DEFAULT 'Hardwarekey', + created_at DATETIME NOT NULL, + last_used_at DATETIME NULL, + UNIQUE KEY ux_credential_id (credential_id), + KEY ix_credential_user (user_id) +); +``` + +Vier Entscheidungen, die man sonst zweimal trifft: + +| Feld | Warum so | +|---|---| +| `public_key` als **PEM** | Geprüft wird mit `openssl_verify()`, das ein PEM will. Die Wandlung COSE→PEM einmal bei der Aufnahme zu bezahlen ist billiger als bei jeder Anmeldung. | +| `alg` **gespeichert** | Ließe sich aus dem PEM herleiten. Die Prüfung soll aber nicht davon abhängen, dass das Herleiten richtig geraten hat. | +| `credential_id` **UNIQUE global** | Die Kennung ist geräteweit eindeutig. Ohne UNIQUE landet derselbe Schlüssel an zwei Konten. | +| `attested` als **einzige** Entscheidungsspalte | Die drei Textspalten kommen aus einem Zertifikat, also von außen. Sie taugen zur Anzeige, nicht als Vergleichsgrundlage im Code. Das Bit hat der Server selbst gesetzt. | + +Dazu am Benutzer: `recovery_hash`, `recovery_created_at`, `recovery_used_at` +für den Notfalltoken (§7). + +--- + +## 4. Ablauf + +### Registrierung (verlangt bestehende Anmeldung + CSRF) + +``` +Browser Server + |-- POST ?action=register_options ------------>| + | erzeugt 32 Byte challenge, + | legt sie in die Sitzung + |<-- publicKey{ challenge, rp, user, | + | pubKeyCredParams, attestation, | + | excludeCredentials } | + | | + | navigator.credentials.create() | + | | + |-- POST ?action=register_verify ------------->| + | { clientDataJSON, attestationObject } | + | 1. clientData prüfen + | 2. authData zerlegen + | 3. Attestation prüfen <- §5 + | 4. Regel anwenden <- §6 + |<-- { attested, root, aaguid } | +``` + +### Anmeldung (zweite Stufe, noch niemand angemeldet) + +Der Zwischenzustand ist wichtig: Nach richtigem Passwort steht in der +Sitzung nur `pending_user_id` — `isLoggedIn()` bleibt **false**. Erst der +zweite Faktor macht daraus eine Anmeldung. Wer hier eine „halbe" Anmeldung +baut, hat die Sicherheitslücke schon eingebaut. + +```php +const PENDING_TIMEOUT = 300; // Zwischenzustand verfällt nach 5 Minuten +``` + +--- + +## 5. Der Kern: Attestationsprüfung + +Reihenfolge ist nicht beliebig. **Erst die Signatur, dann die Kette** — nur +so lässt sich später unterscheiden, ob ein Schlüssel kaputt oder bloß nicht +zugelassen ist. + +```php +public static function inspect( + array $attestationObject, // CBOR-dekodiert + string $authDataRaw, + string $clientDataJson, + ?string $aaguidFromAuthData, + array $trustedRoots // PEM je Wurzel +): array { + $format = $attestationObject['fmt'] ?? 'unbekannt'; + $stmt = $attestationObject['attStmt'] ?? []; + + // "none": Passwortverwalter, eingebaute Passkeys. Keine Behauptung. + if ($format === 'none' || $stmt === []) { + return ['attested' => false, + 'reason' => 'Der Authenticator hat keine Attestation mitgeschickt.']; + } + + $chain = $stmt['x5c'] ?? null; + + // "self attestation": beweist, dass der Schlüssel zu sich selbst passt. + // Über Hardware sagt das nichts. + if (!is_array($chain) || $chain === []) { + return ['attested' => false, + 'reason' => 'Die Attestation ist selbst unterschrieben.']; + } + + $leafPem = self::derToPem($chain[0]); + $parsed = openssl_x509_parse($leafPem); + + self::checkValidity($parsed); // abgelaufen? + + $clientDataHash = hash('sha256', $clientDataJson, true); + + if ($format === 'packed') { // CTAP2, Normalfall + self::verifyPacked($leafPem, $stmt['sig'], + $authDataRaw . $clientDataHash, $stmt); + self::checkAaguidExtension($parsed, $aaguidFromAuthData); + } elseif ($format === 'fido-u2f') { // CTAP1, ältere Keys + self::verifyU2f($leafPem, $stmt['sig'], $authDataRaw, $clientDataHash); + } else { + // tpm, android-key, apple: nicht umgesetzt. Ablehnen ist ehrlicher + // als durchwinken — ein ungeprüftes Format als "geprüft" zu führen + // wäre genau der Fehler, den diese Klasse behebt. + throw new \RuntimeException("Format \"$format\" wird hier nicht geprüft."); + } + + $root = self::chainVerifies($chain, $trustedRoots); // <- die Aussage + if ($root === null) { + throw new \RuntimeException( + 'Die Zertifikatskette endet nicht bei einer hinterlegten Herstellerwurzel.'); + } + + return ['attested' => true, 'root' => $root, 'format' => $format]; +} +``` + +### 5.1 Kettenprüfung — gegen *eigene* Wurzeln + +```php +private static function chainVerifies(array $derChain, array $trustedRoots): ?string +{ + if ($trustedRoots === []) { + throw new \RuntimeException('Keine Herstellerwurzeln hinterlegt.'); + } + + $intermediates = array_map([self::class, 'derToPem'], array_slice($derChain, 1)); + $current = self::derToPem($derChain[0]); + + for ($step = 0; $step < 6; $step++) { + foreach ($trustedRoots as $rootPem) { + if (self::signedBy($current, $rootPem)) { + return self::describe(openssl_x509_parse($rootPem)); + } + } + $next = null; + foreach ($intermediates as $i => $kandidat) { + if (self::signedBy($current, $kandidat)) { + $next = $kandidat; unset($intermediates[$i]); break; + } + } + if ($next === null) { return null; } + self::checkValidity(openssl_x509_parse($next)); + $current = $next; + } + return null; +} + +private static function signedBy(string $certPem, string $issuerPem): bool +{ + $key = openssl_pkey_get_public($issuerPem); + return $key !== false && openssl_x509_verify($certPem, $key) === 1; +} +``` + +> **Nicht `openssl_x509_checkpurpose()` benutzen.** Dessen Wurzelspeicher ist +> der des Betriebssystems, und dort steht die Web-PKI — also genau die +> falsche Menge. Eine Attestationskette soll bei *einem* Hersteller enden +> und nirgends sonst. + +### 5.2 `fido-u2f` — anderes signiertes Material + +Ältere Schlüssel über CTAP1 signieren nicht `authData`, sondern: + +``` +0x00 | rpIdHash | clientDataHash | credentialId | 0x04 | x | y +``` + +Erbe aus der Zeit vor WebAuthn. Wer das Format weglässt, weist ältere +Hardware ab. U2F kennt nur ES256, und die AAGUID ist dort immer Null — eine +Modellliste greift für diese Geräte also grundsätzlich nicht. + +### 5.3 Klonerkennung + +```php +if (!($storedSignCount === 0 && $neuerZaehler === 0) + && $neuerZaehler <= $storedSignCount) { + throw new \RuntimeException('Zähler nicht gestiegen — möglicherweise ein Duplikat.'); +} +``` + +Die Ausnahme ist notwendig: Ein Teil der Hardware führt den Zähler gar nicht +und meldet konstant 0. „Zählt nicht" darf nicht wie „geklont" aussehen. + +--- + +## 6. Die Regel — drei Stufen, nicht ein Schalter + +| Stufe | Aufnahme | Anmeldung | `attestation` in den Vorgaben | +|---|---|---|---| +| `off` | jeder | alle | `none` | +| `register` | nur nachgewiesen | alle | `direct` | +| `strict` | nur nachgewiesen | nur nachgewiesene | `direct` | + +**Warum die Mittelstufe zwingend ist:** Bei `attestation: "none"` ersetzt der +**Browser** die AAGUID durch 16 Nullbytes, bevor der Server sie sieht. Alle +vor der Umstellung registrierten Schlüssel haben daher *keine* Herkunft — +auch echte Yubikeys. Ein einzelner Schalter sperrt in dem Moment, in dem man +ihn umlegt, den eigenen Zugang aus. + +**Der Übergang nach `strict` muss bewacht sein:** + +```php +public function blockingReason(string $desiredLevel, int $actingUserId): ?string +{ + if ($desiredLevel !== self::STRICT) { return null; } + + $stmt = $this->db->prepare( + 'SELECT COUNT(*) FROM user_credentials WHERE user_id = :u AND attested = 1'); + $stmt->execute([':u' => $actingUserId]); + if ((int)$stmt->fetchColumn() > 0) { return null; } + + return 'Kein nachgewiesener Schlüssel am eigenen Konto. ' + . '"strict" würde die eigene Anmeldung sperren.'; +} +``` + +> Eine Einstellung, die sich selbst aussperrt, ist keine Einstellung, +> sondern eine Falle. + +**Und die Regel zweimal prüfen.** `allowCredentials` ist eine Vorgabe an den +Browser, keine Zusage des Clients: + +```php +// login_options: filtert die Auswahl +$ids = $repo->rawIdsForUser($userId, $policy->appliesToLogin()); + +// login_verify: entscheidet wirklich +if ($policy->appliesToLogin() && (int)$credential['attested'] !== 1) { + Auth::recordSecondFactorFailure($db, 'not_attested'); + Http::fail(403, 'not_attested', '...'); +} +``` + +--- + +## 7. Ersatzweg: der Notfalltoken + +Ohne ihn ist ein verlorener Schlüssel ein verlorenes Konto. + +- Nur der **Hash** in der Datenbank, Klartext genau einmal bei der Erzeugung + sichtbar. +- **Einmalgebrauch**, Hash wird beim Verbrauch geleert. +- `recovery_used_at` bleibt stehen: Ohne diese Spur sieht „keiner + hinterlegt" genauso aus wie „es gab nie einen", während es in Wahrheit + heißt: *jemand hat ihn benutzt*. +- Alphabet ohne `0/O` und `1/I`, Gruppen zu vier Zeichen — er wird + abgeschrieben und vorgelesen. +- **Läuft als gewöhnliches Formular, nicht über JavaScript.** Ein Ersatzweg + mit denselben Voraussetzungen wie der Hauptweg ist keiner. + +Der Notfalltoken gilt **unabhängig von der Regel**. Das ist Absicht: Er ist +der Weg zurück aus einer Fehlkonfiguration. + +--- + +## 8. Bewusst offene Punkte + +| Punkt | Stand | Wenn das Zielprojekt mehr braucht | +|---|---|---| +| `userVerification` | `discouraged` — keine PIN | Auf `required` stellen; verlangt dann PIN/Biometrie am Schlüssel und prüft das UV-Bit in `authData`. | +| FIDO Metadata Service | nicht angebunden | Kanonischer Weg zu „jede zertifizierte Hardware". Signierter JWT-Abruf mit Auffrischung — eine laufende Außenabhängigkeit in der Anmeldestrecke. | +| `tpm` / `apple` | abgelehnt | Windows Hello und Secure Enclave sind hardwaregestützt, sitzen aber im selben Gerät wie der erste Faktor. | +| Enterprise Attestation | nein | Liefert eine geräteindividuelle Kennung. Braucht Browser-Richtlinien auf verwalteten Rechnern. | + +--- + +## 9. Portierung in ein anderes Projekt + +### 9.1 Was sprachunabhängig ist + +Alles in §2 bis §7. Die Fallen, die Reihenfolge der Prüfungen, das +Datenmodell, die drei Stufen, der Notfalltoken. + +### 9.2 Was zu ersetzen ist + +| PHP | Entsprechung .NET | Entsprechung Node | +|---|---|---| +| `openssl_x509_parse` | `X509Certificate2` | `crypto.X509Certificate` | +| `openssl_x509_verify($c,$k)` | `X509Certificate2.Verify()` bzw. Signaturprüfung über `X509Chain` mit eigenem `CustomTrustStore` | `cert.verify(publicKey)` | +| `openssl_verify` | `ECDsa.VerifyData` / `RSA.VerifyData` | `crypto.verify` | +| eigener CBOR-Leser | `PeterO.Cbor` | `cbor` | + +> **.NET-Hinweis:** Für die Kettenprüfung `X509ChainPolicy.TrustMode = +> CustomRootTrust` setzen und die Herstellerwurzeln in +> `CustomTrustStore` legen. Ohne das prüft .NET gegen den +> Betriebssystemspeicher — derselbe Fehler wie +> `openssl_x509_checkpurpose()`. + +### 9.3 Wurzelzertifikate + +Yubico veröffentlicht sie unter +. Für WebAuthn sind +**drei** relevant: + +- Yubico U2F Root CA Serial 457200631 (2014) — YubiKey 4 und 5 +- Yubico FIDO Root CA Serial 450203556 (2024) +- Yubico Attestation Root 1 (2024-12) + +Die dort ebenfalls veröffentlichten Wurzeln für **PIV** und **OpenPGP** +gehören *nicht* dazu — sie signieren anderes und vergrößerten nur die Menge +des Akzeptierten. + +Andere Hersteller (SoloKeys, Nitrokey, Feitian, Token2) brauchen ihre +eigenen Wurzeln. Fehlen sie, wird echte Hardware abgewiesen — das ist eine +Konfigurationslücke, kein Urteil über das Gerät. + +### 9.4 RP-ID + +Ein registrierter Schlüssel gilt **nur** für die Kennung, unter der er +aufgenommen wurde. Test- und Produktivinstanz brauchen je eine eigene +Registrierung. Das ist kein Mangel, sondern der Kern des Verfahrens — genau +deshalb läuft ein Anmeldeversuch auf einer nachgebauten Seite ins Leere. + +--- + +## 10. Prüfliste für die Übernahme + +Eine Umsetzung gilt erst als fertig, wenn **alle** Punkte belegt sind: + +- [ ] Ein echter Hardwarekey wird aufgenommen und zeigt die erreichte Wurzel +- [ ] Ein Passwortverwalter-Passkey (`fmt: none`) wird abgewiesen +- [ ] **Ein selbst ausgestelltes Zertifikat mit gültiger Signatur und + abgeschriebener AAGUID wird abgewiesen — an der KETTE, nicht an der + Signatur** *(§2.3; ohne diesen Nachweis ist nichts belegt)* +- [ ] Zwischenzustand: `isLoggedIn()` bleibt nach richtigem Passwort false +- [ ] Zwischenzustand verfällt nach 5 Minuten +- [ ] Zähler-Rückschritt wird erkannt, beide-Null bleibt erlaubt +- [ ] Notfalltoken funktioniert ohne JavaScript und ist danach verbraucht +- [ ] `strict` lässt sich nicht einschalten, solange kein nachgewiesener + Schlüssel am eigenen Konto hängt +- [ ] Ein Konto zu löschen entfernt seine Credential-Zeilen *(sonst ist der + Schlüssel wegen UNIQUE dauerhaft verbrannt)* +- [ ] Fehlermeldungen kommen beim Benutzer **lesbar** an + +Der letzte Punkt steht nicht aus Höflichkeit dort: Genau hier hatte diese +Umsetzung einen Fehler. Die API verpackt Fehler als +`{ error: { code, message } }`, das JavaScript las `data.message || +data.error` — und zeigte `[object Object]` an, ausgerechnet dort, wo die +Begründung der Ablehnung stehen sollte. + +--- + +## 11. Referenzdateien im Deploymentcenter + +| Datei | Inhalt | +|---|---| +| `src/Core/Webauthn/Attestation.php` | Attestationsprüfung, Kette, AAGUID-Gegenprobe | +| `src/Core/Webauthn/HardwarePolicy.php` | Die drei Stufen, Modellliste, Bewachung | +| `src/Core/Webauthn/WebauthnServer.php` | Die beiden Zeremonien | +| `src/Core/Webauthn/CoseKey.php` | COSE → DER/PEM, von Hand | +| `src/Core/Webauthn/Cbor.php` | CBOR-Leser, nur so groß wie nötig | +| `src/Core/Webauthn/CredentialRepo.php` | Schlüssel und Notfalltoken | +| `src/Core/Webauthn/roots/yubico.pem` | Die drei Wurzeln | +| `public/api/auth/v1/index.php` | Die vier Aktionen | +| `sql/migrations/019_*.sql`, `021_*.sql` | Datenmodell | +| `tests/e2e/suite_hardware.py` | Fünf Fälle, ohne Krypto-Bibliothek | +| `docs/UPGRADE.md` §17, §18 | Einführung und Verschärfung im Betrieb | diff --git a/public/index.php b/public/index.php index d361596..2498a0e 100644 --- a/public/index.php +++ b/public/index.php @@ -6930,12 +6930,12 @@ Antwort: - - +
+ + +

Eine Gerätekennung je Zeile. Leer lassen ist der Normalfall: Die Wurzelprüfung beantwortet bereits „stammt von diesem Hersteller“. Die Liste @@ -9210,6 +9210,24 @@ document.addEventListener('DOMContentLoaded', () => { : (tone === 'ok' ? 'var(--success)' : 'var(--text-muted)'); }; + /** + * Holt den lesbaren Text aus einer Fehlerantwort. + * + * Die API verpackt Fehler als { error: { code, message } }. Ein + * "data.message || data.error" griff deshalb auf das OBJEKT zu, und der + * Browser machte daraus "[object Object]" - ausgerechnet an der Stelle, + * an der die Begruendung stehen sollte, warum ein Schluessel abgelehnt + * wurde. Beide Formen zu lesen ist kein Zierrat: Die aeltere, flache + * Form kommt weiterhin vor. + */ + const fehlertext = data => { + const huelle = data && data.error; + if (huelle && typeof huelle === 'object' && huelle.message) { return huelle.message; } + if (data && typeof data.message === 'string') { return data.message; } + if (typeof huelle === 'string') { return huelle; } + return 'Unerwartete Antwort des Servers.'; + }; + const post = async (action, payload) => { const response = await fetch('/api/auth/v1/?action=' + action, { method: 'POST', @@ -9219,7 +9237,7 @@ document.addEventListener('DOMContentLoaded', () => { }); const data = await response.json().catch(() => ({})); if (!response.ok) { - throw new Error(data.message || data.error || 'Unerwartete Antwort des Servers.'); + throw new Error(fehlertext(data)); } return data; }; diff --git a/public/login.php b/public/login.php index 5d45b58..a44aa67 100644 --- a/public/login.php +++ b/public/login.php @@ -513,6 +513,22 @@ $pendingUsername = Auth::pendingUsername(); status.classList.toggle('is-error', !!isError); }; + /** + * Der lesbare Text aus einer Fehlerantwort. + * + * Die API verpackt Fehler als { error: { code, message } }. Wer nur + * data.error liest, zeigt "[object Object]" an - hier waere das + * besonders misslich, denn an dieser Stelle steht der einzige Hinweis, + * warum die Anmeldung nicht weitergeht. + */ + const fehlertext = data => { + const huelle = data && data.error; + if (huelle && typeof huelle === 'object' && huelle.message) { return huelle.message; } + if (data && typeof data.message === 'string') { return data.message; } + if (typeof huelle === 'string') { return huelle; } + return 'Unerwartete Antwort des Servers.'; + }; + if (!window.PublicKeyCredential) { setStatus('Dieser Browser kennt keine Hardwarekeys. Bitte den Notfalltoken benutzen.', true); button.disabled = true; @@ -528,7 +544,7 @@ $pendingUsername = Auth::pendingUsername(); }); const data = await response.json().catch(() => ({})); if (!response.ok) { - throw new Error(data.message || data.error || 'Unerwartete Antwort des Servers.'); + throw new Error(fehlertext(data)); } return data; };