check(Http::clientIp())) { Http::fail(429, 'rate_limited', 'Zu viele Anfragen. Bitte kurz warten.'); } switch ($action) { // ----------------------------------------------------------------- // Anmelden, zweite Stufe // ----------------------------------------------------------------- case 'login_options': { $userId = Auth::pendingUserId(); if ($userId <= 0) { Http::fail(401, 'no_pending_login', 'Es steht keine Anmeldung offen. Bitte von vorn beginnen.'); } // Bei "strict" kommen nur geprueft in Hardware entstandene Schluessel // in die Auswahl. Ein danach leeres Ergebnis ist kein Fehlerfall, // sondern der erwartete Zustand direkt nach dem Umschalten - und die // Meldung muss deshalb sagen, was zu tun ist, statt bloss "geht // nicht". $strict = $policy->appliesToLogin(); $ids = $repo->rawIdsForUser($userId, $strict); if ($ids === []) { if ($strict && $repo->countForUser($userId) > 0) { Http::fail( 409, 'no_attested_credentials', 'Fuer dieses Konto ist zwar ein Schluessel hinterlegt, aber keiner davon hat ' . 'sich als Hardware ausgewiesen. Die Anmeldung laeuft ueber den Notfalltoken; ' . 'danach den Hardwarekey neu aufnehmen.' ); } Http::fail(409, 'no_credentials', 'Fuer dieses Konto ist kein Hardwarekey hinterlegt.'); } $challenge = WebauthnServer::challenge(); Auth::storeChallenge($challenge); Http::ok(['publicKey' => WebauthnServer::assertionOptions($challenge, $ids)]); break; } case 'login_verify': { $userId = Auth::pendingUserId(); if ($userId <= 0) { Http::fail(401, 'no_pending_login', 'Es steht keine Anmeldung offen. Bitte von vorn beginnen.'); } $challenge = Auth::takeChallenge(); if ($challenge === '') { Http::fail(400, 'challenge_expired', 'Die Anfrage ist abgelaufen. Bitte erneut versuchen.'); } $credentialId = WebauthnServer::b64uDecode((string)Http::str('id', '')); $clientDataJson = WebauthnServer::b64uDecode((string)Http::str('clientDataJSON', '')); $authenticatorRaw = WebauthnServer::b64uDecode((string)Http::str('authenticatorData', '')); $signature = WebauthnServer::b64uDecode((string)Http::str('signature', '')); if ($credentialId === '' || $clientDataJson === '' || $authenticatorRaw === '' || $signature === '') { Http::fail(400, 'incomplete', 'Die Antwort des Browsers ist unvollstaendig.'); } $credential = $repo->find($userId, $credentialId); if ($credential === null) { // Der Schluessel gehoert nicht zu diesem Konto. Das ist kein // Bedienfehler, das ist ein Fehlversuch - und zaehlt als solcher. Auth::recordSecondFactorFailure($db, 'unknown_credential'); Http::fail(403, 'unknown_credential', 'Dieser Hardwarekey ist fuer das Konto nicht hinterlegt.'); } // allowCredentials ist eine Vorgabe an den Browser, keine Zusage des // Clients. Wer die Anfrage von Hand stellt, kann jede Kennung // einsetzen - also wird die Regel hier ein zweites Mal geprueft, an // der Stelle, an der sie wirklich entscheidet. if ($policy->appliesToLogin() && (int)($credential['attested'] ?? 0) !== 1) { Auth::recordSecondFactorFailure($db, 'not_attested'); Http::fail( 403, 'not_attested', 'Dieser Schluessel hat sich bei der Aufnahme nicht als Hardware ausgewiesen ' . 'und ist unter der geltenden Regel nicht zugelassen.' ); } try { $result = WebauthnServer::verifyAssertion( $clientDataJson, $authenticatorRaw, $signature, $challenge, (string)$credential['public_key'], (int)$credential['alg'], (int)$credential['sign_count'] ); } catch (\Throwable $e) { Auth::recordSecondFactorFailure($db, 'assertion_rejected'); Logger::warning('Hardwarekey abgelehnt', [ 'user_id' => $userId, 'error' => $e->getMessage(), ]); Http::fail(403, 'assertion_rejected', $e->getMessage()); } $repo->touch((int)$credential['id'], $result['sign_count']); if (!Auth::completeSecondFactor($db, 'hardware_key')) { Http::fail(403, 'login_failed', 'Die Anmeldung konnte nicht abgeschlossen werden.'); } Http::ok(['redirect' => '/index.php']); break; } // ----------------------------------------------------------------- // Registrieren - nur aus einer bestehenden Anmeldung heraus // ----------------------------------------------------------------- case 'register_options': { if (!Auth::isLoggedIn()) { Http::fail(401, 'unauthorized', 'Anmeldung erforderlich.'); } Csrf::requireValid(); // Einen zweiten Faktor aufzunehmen ist die wirksamste Art, sich in // einem Konto einzunisten: Der eigene Schluessel gilt danach // dauerhaft und faellt niemandem auf. Deshalb genuegt hier eine // lebende Sitzung nicht - es braucht eine Authentifizierung aus den // letzten Minuten. Der Browser bekommt einen eigenen Code dafuer, // damit die Oberflaeche zum Sperrbildschirm schicken kann, statt // "verboten" zu melden. if (!Auth::reauthFresh()) { Http::fail( 403, 'reauth_required', 'Diese Aenderung betrifft die Anmeldung selbst. Bitte bestaetigen Sie sie ' . 'zuerst mit Ihrem Passwort.', null, ['reauth_url' => '/login.php?step=unlock&weiter=' . rawurlencode('/index.php#sub-system-users')] ); } $userId = Auth::userId(); $challenge = WebauthnServer::challenge(); Auth::storeChallenge($challenge); Http::ok([ 'publicKey' => WebauthnServer::registrationOptions( $challenge, $userId, Auth::username(), $repo->rawIdsForUser($userId), $policy->conveyance() ), // Die Oberflaeche sagt vorher, was gefordert ist. Ohne diesen // Hinweis erfuehre man die Regel erst nach dem Beruehren des // Schluessels - und wuesste nicht, ob der Schluessel oder die // Regel das Problem war. 'policy' => $policy->level(), ]); break; } case 'register_verify': { if (!Auth::isLoggedIn()) { Http::fail(401, 'unauthorized', 'Anmeldung erforderlich.'); } Csrf::requireValid(); // Einen zweiten Faktor aufzunehmen ist die wirksamste Art, sich in // einem Konto einzunisten: Der eigene Schluessel gilt danach // dauerhaft und faellt niemandem auf. Deshalb genuegt hier eine // lebende Sitzung nicht - es braucht eine Authentifizierung aus den // letzten Minuten. Der Browser bekommt einen eigenen Code dafuer, // damit die Oberflaeche zum Sperrbildschirm schicken kann, statt // "verboten" zu melden. if (!Auth::reauthFresh()) { Http::fail( 403, 'reauth_required', 'Diese Aenderung betrifft die Anmeldung selbst. Bitte bestaetigen Sie sie ' . 'zuerst mit Ihrem Passwort.', null, ['reauth_url' => '/login.php?step=unlock&weiter=' . rawurlencode('/index.php#sub-system-users')] ); } $userId = Auth::userId(); $challenge = Auth::takeChallenge(); if ($challenge === '') { Http::fail(400, 'challenge_expired', 'Die Anfrage ist abgelaufen. Bitte erneut versuchen.'); } $clientDataJson = WebauthnServer::b64uDecode((string)Http::str('clientDataJSON', '')); $attestationObject = WebauthnServer::b64uDecode((string)Http::str('attestationObject', '')); if ($clientDataJson === '' || $attestationObject === '') { Http::fail(400, 'incomplete', 'Die Antwort des Browsers ist unvollstaendig.'); } $label = Http::str('label') ?? 'Hardwarekey'; $label = mb_substr(trim($label), 0, 64); if ($label === '') { $label = 'Hardwarekey'; } $transports = Http::input('transports'); $transports = is_array($transports) ? mb_substr(implode(',', array_map('strval', $transports)), 0, 128) : null; try { $registration = WebauthnServer::verifyRegistration( $clientDataJson, $attestationObject, $challenge, $policy->roots() ); } catch (\Throwable $e) { Logger::warning('Registrierung eines Hardwarekeys abgelehnt', [ 'user_id' => $userId, 'error' => $e->getMessage(), ]); Http::fail(400, 'registration_rejected', $e->getMessage()); } if ($repo->find($userId, $registration['credential_id']) !== null) { Http::fail(409, 'already_registered', 'Dieser Hardwarekey ist bereits hinterlegt.'); } // Erst jetzt entscheidet die Regel. Die Zeremonie oben war fehlerfrei // - der Schluessel ist echt und gehoert zu diesem Konto. Abgelehnt // wird er, weil er nicht das ist, was hier gefordert ist. Diese // Reihenfolge ist Absicht: Sie trennt "kaputt" von "nicht zugelassen", // und nur so laesst sich die zweite Meldung ueberhaupt verstehen. $verdict = $policy->acceptsRegistration($registration['attestation']); if (!$verdict['ok']) { SecurityLogger::log('credential_rejected', Http::clientIp(), [ 'username' => Auth::username(), 'format' => (string)($registration['attestation']['format'] ?? '?'), 'reason' => (string)$verdict['reason'], ]); Http::fail(403, 'policy_rejected', (string)$verdict['reason'], null, [ 'format' => $registration['attestation']['format'] ?? null, 'attested' => false, ]); } try { $repo->add( $userId, $registration['credential_id'], $registration['public_key'], $registration['alg'], $registration['sign_count'], $registration['aaguid'], $label, $transports, $registration['attestation'] ); } catch (\PDOException $e) { // Eindeutiger Schluessel verletzt: derselbe Key an einem anderen // Konto. Die Kennung ist geraeteweit eindeutig, deshalb faellt // das hier und nicht in der Suche oben auf. if ((string)$e->getCode() === '23000') { Http::fail(409, 'already_registered', 'Dieser Hardwarekey ist bereits einem Konto zugeordnet.'); } throw $e; } // Ein neuer zweiter Faktor am eigenen Konto gehoert ins // Sicherheitsprotokoll - es ist die Aenderung, die im Ernstfall die // Frage beantwortet, seit wann ein fremder Schluessel mitliest. SecurityLogger::log('credential_registered', Http::clientIp(), [ 'username' => Auth::username(), 'label' => $label, 'attested' => $registration['attestation']['attested'] ?? false, 'root' => $registration['attestation']['root'] ?? null, ]); Http::ok([ 'label' => $label, 'total' => $repo->countForUser($userId), 'attested' => $registration['attestation']['attested'] ?? false, 'format' => $registration['attestation']['format'] ?? null, 'root' => $registration['attestation']['root'] ?? null, 'aaguid' => Attestation::formatAaguid($registration['aaguid']), 'message' => 'Hardwarekey registriert.', ]); break; } default: Http::fail(404, 'unknown_action', sprintf('Unbekannte Aktion "%s".', $action), null, [ 'available' => ['login_options', 'login_verify', 'register_options', 'register_verify'], ]); }