diff --git a/CHANGELOG.md b/CHANGELOG.md index d76cf465996..6fdeacda906 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Release Notes for Craft CMS 5 +## Unreleased + +- Fixed a bug where passkeys created before updating to Craft 5.10 could no longer be used to log in. ([#19530](https://github.com/craftcms/cms/issues/19530), [#19536](https://github.com/craftcms/cms/pull/19536)) + ## 5.11.0 - 2026-09-01 > [!WARNING] diff --git a/src/auth/passkeys/CredentialRepository.php b/src/auth/passkeys/CredentialRepository.php index 17251a0b9a5..5313d93c34c 100644 --- a/src/auth/passkeys/CredentialRepository.php +++ b/src/auth/passkeys/CredentialRepository.php @@ -8,13 +8,12 @@ namespace craft\auth\passkeys; use Craft; +use craft\elements\User; use craft\helpers\DateTimeHelper; use craft\helpers\Db; -use craft\helpers\Json; use craft\records\WebAuthn; use ParagonIE\ConstantTime\Base64UrlSafe; use Webauthn\CredentialRecord; -use Webauthn\Exception\InvalidDataException; use Webauthn\PublicKeyCredentialUserEntity; use Webauthn\Util\Base64; @@ -29,7 +28,7 @@ class CredentialRepository /** * Finds a webauthn record in the database for given id and returns the CredentialRecord for its credential value. */ - public function findOneByCredentialId(string $publicKeyCredentialId, bool $checkOldUserHandle = false): ?CredentialRecord + public function findOneByCredentialId(string $publicKeyCredentialId): ?CredentialRecord { $record = $this->_findByCredentialId($publicKeyCredentialId); @@ -42,19 +41,13 @@ public function findOneByCredentialId(string $publicKeyCredentialId, bool $check 'json', ); - // if the record was created using webauthn v4 then the credential was run through Json::encode() before storing in the DB, - // without the extra base64 encoding pass that webauthn 5's CredentialRecordDenormalizer::normalize() applies; - // deserialising such a value therefore leaves the userHandle one decode pass short of where it should be, - // so if we failed to log the user in based on the handle mismatch exception, we'll try again, decoding the - // stored (old, singly-encoded) handle ourselves to get it to the same (raw) form the assertion response is in - if ($checkOldUserHandle) { - $credential = Json::decodeIfJson($record->credential); - try { - $credentialRecord->userHandle = Base64::decode($credential['userHandle']); - } catch (InvalidDataException) { - // not base64-encoded after all; fall back to using it as-is - $credentialRecord->userHandle = $credential['userHandle']; - } + // if the userHandle is already a fully decoded user UID it means it was created with webauthn v4; + // in that case, we should be able to find user by it + $found = User::find()->uid($credentialRecord->userHandle)->exists(); + + // and if that's the case, we want to base64 encode it again, so that we're comparing correct values + if ($found) { + $credentialRecord->userHandle = Base64UrlSafe::encodeUnpadded($credentialRecord->userHandle); } return $credentialRecord; diff --git a/src/elements/User.php b/src/elements/User.php index e1adb6c424b..b6af7509449 100644 --- a/src/elements/User.php +++ b/src/elements/User.php @@ -56,7 +56,6 @@ use DateInterval; use DateTime; use DateTimeZone; -use Webauthn\Exception\InvalidUserHandleException; use Webauthn\PublicKeyCredentialRequestOptions; use yii\base\Exception; use yii\base\InvalidArgumentException; @@ -1455,26 +1454,15 @@ public function authenticateWithPasskey( // Validate the security key try { $keyValid = $authService->verifyPasskey($this, $requestOptions, $response); - } catch (InvalidUserHandleException) { - // the user handle may have been stored in the old (pre-webauthn-5) format; try again, accounting for that - try { - $keyValid = $authService->verifyPasskey($this, $requestOptions, $response, true); - } catch (InvalidUserHandleException) { - $keyValid = false; - } } catch (InvalidArgumentException) { $keyValid = false; } - $updatedCredentialRecord = Session::remove($authService->passkeyCredSourceParam); - if (!$keyValid) { $this->handleInvalidLoginParam(); return false; } - $authService->webauthnServer()->getCredentialRepository()->saveCredentialSource($updatedCredentialRecord); - $this->authError = $this->_getAuthError(); return !isset($this->authError); } diff --git a/src/services/Auth.php b/src/services/Auth.php index 753f610f324..11d13c18f6c 100644 --- a/src/services/Auth.php +++ b/src/services/Auth.php @@ -625,7 +625,6 @@ public function verifyPasskey( $credentialRecord = $this->webauthnServer()->getCredentialRepository()->findOneByCredentialId( $publicKeyCredential->rawId, - $checkOldUserHandle, ); if ($credentialRecord === null) { @@ -633,24 +632,16 @@ public function verifyPasskey( return false; } - // if we're re-checking against the old (pre-webauthn-5) user handle format, $credentialRecord->userHandle - // was just set to the decoded (raw) user handle by CredentialRepository::findOneByCredentialId(), so the - // user handle we compare it with here needs to be decoded as well, rather than the (encoded) $userEntity->id - $userHandle = $checkOldUserHandle ? $user->uid : $userEntity->id; - try { $updatedCredentialRecord = $this->webauthnServer()->getAuthenticatorAssertionResponseValidator()->check( $credentialRecord, $authenticatorAssertionResponse, $publicKeyCredentialRequestOptions, Craft::$app->getRequest()->getHostName(), - $userHandle, + $userEntity->id, ); - // we can't save the updated credential record to db here as in User::authenticateWithPasskey() - // we might need to call this method (Auth::verifyPasskey()) again, with checkOldUserHandle set to true; - // so, we're going to store it in the session and then save from the User::authenticateWithPasskey() method - SessionHelper::set($this->passkeyCredSourceParam, $updatedCredentialRecord); + $this->webauthnServer()->getCredentialRepository()->saveCredentialSource($updatedCredentialRecord); } catch (InvalidUserHandleException $e) { throw $e; } catch (Throwable $e) {