rawIdsForUser($userId); if ($ids === []) { 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.'); } 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(); $userId = Auth::userId(); $challenge = WebauthnServer::challenge(); Auth::storeChallenge($challenge); Http::ok([ 'publicKey' => WebauthnServer::registrationOptions( $challenge, $userId, Auth::username(), $repo->rawIdsForUser($userId) ), ]); break; } case 'register_verify': { if (!Auth::isLoggedIn()) { Http::fail(401, 'unauthorized', 'Anmeldung erforderlich.'); } Csrf::requireValid(); $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); } 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.'); } try { $repo->add( $userId, $registration['credential_id'], $registration['public_key'], $registration['alg'], $registration['sign_count'], $registration['aaguid'], $label, $transports ); } 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, ]); Http::ok([ 'label' => $label, 'total' => $repo->countForUser($userId), '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'], ]); }