From a080e7c583272508be993b8014439cc90dc78133 Mon Sep 17 00:00:00 2001 From: Bradley Schofield Date: Fri, 21 Jun 2024 22:42:44 +0900 Subject: [PATCH] Refactor things into it's own class --- app/controllers/api/account.php | 389 ++++++++++++++++++------ src/Appwrite/Auth/Auth.php | 1 + src/Appwrite/Auth/MFA/Type.php | 1 + src/Appwrite/Auth/MFA/Type/WebAuthn.php | 244 +++++++++++++++ 4 files changed, 534 insertions(+), 101 deletions(-) create mode 100644 src/Appwrite/Auth/MFA/Type/WebAuthn.php diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index d4f98291b5..de64e01825 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -5,6 +5,7 @@ use Appwrite\Auth\Auth; use Appwrite\Auth\MFA\Challenge; use Appwrite\Auth\MFA\Type; use Appwrite\Auth\MFA\Type\TOTP; +use Appwrite\Auth\MFA\Type\WebAuthn; use Appwrite\Auth\OAuth2\Exception as OAuth2Exception; use Appwrite\Auth\Phrase; use Appwrite\Auth\Validator\Password; @@ -417,7 +418,7 @@ App::post('/v1/account/webauthn') ->label('sdk.description', '/docs/references/account/webauthn-create.md') ->label('sdk.response.code', Response::STATUS_CODE_CREATED) ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) - ->label('sdk.response.model', Response::MODEL_USER) + ->label('sdk.response.model', Response::MODEL_WEBAUTHN_REGISTER_CHALLENGE) ->label('abuse-limit', 10) ->param('userId', '', new CustomId(), 'User ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.') ->param('name', '', new Text(128), 'User name. Max length: 128 chars.', true) @@ -471,71 +472,26 @@ App::post('/v1/account/webauthn') } } - // TODO: Check there isn't an existing challenge for this userId & email - // TODO: Add a challenge expiry time - $platforms = $project->getAttribute('platforms', []); - $platformName = ''; - $platformId = ''; - - //TODO: Use SDK headers to determine the platform - - // Fallback to any web platform that matches the domain - foreach ($platforms as $platform) { - if ($platform['type'] === 'web' && $platform['hostname'] == $request->getHostname()) { - $platformName = $platform['name']; - $platformId = $platform['hostname']; - break; - } - } - - // Console - if ($project->getId() === 'console') { - $platformName = 'Appwrite'; - $platformId = 'localhost'; // TODO: Replace with hostname from _APP_DOMAIN - } - - // If still no platform, throw. - if (empty($platformName)) { - throw new Exception(Exception::GENERAL_BAD_REQUEST, 'No platform found for this domain.'); - } - - // Generate rpEntity from current platform - $rpEntity = PublicKeyCredentialRpEntity::create( - $platformName, //Name - $platformId, //ID - null //Icon - ); - - // Generate userEntity $userId = $userId == 'unique()' ? ID::unique() : $userId; - $userEntity = PublicKeyCredentialUserEntity::create( - $name, - $userId, - $name, - null - ); - $challenge = random_bytes(16); - - $publicKeyCredentialCreationOptions = - PublicKeyCredentialCreationOptions::create( - $rpEntity, - $userEntity, - $challenge - ); + $webauthn = new WebAuthn(); + $relyingParty = $webauthn->createRelyingParty($project, $request); + $userEntity = $webauthn->createUserEntity(new Document([ + 'name' => $name, + 'email' => $email, + 'userId' => $userId, + ])); + $challenge = $webauthn->createRegisterChallenge($relyingParty, $userEntity, 60 * 5); $expire = DateTime::addSeconds(new \DateTime(), 60 * 5); - $webauthnDocument = new Document([ - '$id' => ID::unique(), - 'type' => 'user_creation', - 'rp' => json_encode($publicKeyCredentialCreationOptions->rp), - 'user' => json_encode($publicKeyCredentialCreationOptions->user), - 'challenge' => Base64UrlSafe::encodeUnpadded($challenge), - 'userId' => $userId, - 'pubKeyCredParams' => json_encode($publicKeyCredentialCreationOptions->pubKeyCredParams), - 'expire' => $expire, - ]); + $webauthnDocument = new Document( + array_merge([ + '$id' => ID::unique(), + 'type' => 'user_creation', + 'expire' => $expire, + ], $challenge->jsonSerialize()) + ); if (!empty($email)) { $webauthnDocument->setAttribute('email', $email); @@ -580,34 +536,12 @@ App::put('/v1/account/webauthn') throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Invalid challenge.'); } - // Validate Challenge Response - $publicKeyCredential = $publicKeyCredentialLoader->load($challengeResponse); - - $publicKeyCredentialCreationOptions = PublicKeyCredentialCreationOptions::create( - PublicKeyCredentialRpEntity::create( - $challengeDocument['rp']['name'], //Name - $challengeDocument['rp']['id'], //ID - null //Icon - ), - PublicKeyCredentialUserEntity::create( - $challengeDocument['user']['name'], - $challengeDocument['user']['id'], - $challengeDocument['user']['displayName'], - ), - Base64UrlSafe::decode($challengeDocument['challenge']), - ); - - $publicKeyCredentialSource = []; - + $webauthn = new WebAuthn(); + $publicKeyCredentials = null; try { - $publicKeyCredentialSource = $authenticatorAttestationResponseValidator->check( - $publicKeyCredential->response, - $publicKeyCredentialCreationOptions, - $challengeDocument['rp']['id'], // Replace with platform domain or origin - ['localhost'] // Remove when we can. - ); - } catch (\Throwable $th) { - throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Invalid challenge response.'); + $publicKeyCredentials = $webauthn->verifyRegisterChallenge($challengeDocument->getArrayCopy(), $challengeResponse); + } catch (\Exception $e) { + throw new Exception(Exception::USER_INVALID_TOKEN); } // Create a user account with webauthn enabled. @@ -642,19 +576,15 @@ App::put('/v1/account/webauthn') $createdUser = Authorization::skip(fn () => $dbForProject->createDocument('users', $user)); // Create Authenticator - $dbForProject->createDocument('credentialSources', new Document([ - 'userInternalId' => $createdUser->getInternalId(), - 'publicKeyCredentialId' => Base64UrlSafe::encodeUnpadded($publicKeyCredentialSource->publicKeyCredentialId), - 'type' => $publicKeyCredentialSource->type, - 'transports' => $publicKeyCredentialSource->transports, - 'attestationType' => $publicKeyCredentialSource->attestationType, - 'aaguid' => $publicKeyCredentialSource->aaguid->__toString(), - 'trustPath' => json_encode($publicKeyCredentialSource->trustPath), - 'credentialPublicKey' => Base64UrlSafe::encodeUnpadded($publicKeyCredentialSource->credentialPublicKey), - 'userHandle' => Base64UrlSafe::encodeUnpadded($publicKeyCredentialSource->userHandle), - 'counter' => $publicKeyCredentialSource->counter, - ])); - + $dbForProject->createDocument('credentialSources', + new Document( + array_merge( + ['userInternalId' => $createdUser->getInternalId()], + $publicKeyCredentials->jsonSerialize() + ) + ) + ); + Authorization::skip(fn () => $dbForProject->deleteDocument('webauthnChallenges', $challengeId)); } catch (Duplicate) { throw new Exception(Exception::USER_ALREADY_EXISTS); @@ -4321,6 +4251,130 @@ App::put('/v1/account/mfa/authenticators/:type') $response->dynamic($user, Response::MODEL_ACCOUNT); }); +App::post('/v1/account/mfa/authenticators/webauthn') + ->desc('Add WebAuthn Authenticator') + ->groups(['api', 'account']) + ->label('event', 'users.[userId].update.mfa') + ->label('scope', 'account') + ->label('audits.event', 'user.update') + ->label('audits.resource', 'user/{response.$id}') + ->label('audits.userId', '{response.$id}') + ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT]) + ->label('sdk.namespace', 'account') + ->label('sdk.method', 'createMfaWebauthnAuthenticator') + ->label('sdk.description', '/docs/references/account/create-mfa-webauthn-authenticator.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_WEBAUTHN_REGISTER_CHALLENGE) + ->label('sdk.offline.model', '/account') + ->label('sdk.offline.key', 'current') + ->inject('request') + ->inject('response') + ->inject('project') + ->inject('user') + ->inject('dbForProject') + ->inject('queueForEvents') + ->action(function (Request $request, Response $response, Document $project, Document $user, Database $dbForProject, Event $queueForEvents) { + // Clean up any previous challenges not completed + $authenticators = array_filter($user->getAttribute('authenticators', []), fn ($authenticator) => $authenticator['type'] === Type::WEBAUTHN); + + foreach ($authenticators as $authenticator) { + if (empty($authenticator['verified'])) { + $dbForProject->deleteDocument('authenticators', $authenticator['id']); + } + } + + $webauthn = new WebAuthn(); + $relyingParty = $webauthn->createRelyingParty($project, $request); + $userEntity = $webauthn->createUserEntity($user); + $challenge = $webauthn->createRegisterChallenge($relyingParty, $userEntity, 60 * 5); + + $authenticator = new Document([ + '$id' => ID::unique(), + 'userId' => $user->getId(), + 'userInternalId' => $user->getInternalId(), + 'type' => Type::WEBAUTHN, + 'verified' => false, + 'data' => $challenge->jsonSerialize(), + '$permissions' => [ + Permission::read(Role::user($user->getId())), + Permission::update(Role::user($user->getId())), + Permission::delete(Role::user($user->getId())), + ] + ]); + + $authenticator = $dbForProject->createDocument('authenticators', $authenticator); + $dbForProject->purgeCachedDocument('users', $user->getId()); + + $queueForEvents->setParam('userId', $user->getId()); + + $model = new Document($challenge->jsonSerialize()); + $response->dynamic($model, Response::MODEL_WEBAUTHN_REGISTER_CHALLENGE); + }); + +App::put('/v1/account/mfa/authenticators/webauthn') + ->desc('Verify WebAuthn Authenticator') + ->groups(['api', 'account']) + ->label('event', 'users.[userId].update.mfa') + ->label('scope', 'account') + ->label('audits.event', 'user.update') + ->label('audits.resource', 'user/{response.$id}') + ->label('audits.userId', '{response.$id}') + ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT]) + ->label('sdk.namespace', 'account') + ->label('sdk.method', 'updateWebauthnMfaAuthenticator') + ->label('sdk.description', '/docs/references/account/update-mfa-authenticator.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_USER) + ->label('sdk.offline.model', '/account') + ->label('sdk.offline.key', 'current') + ->param('challengeResponse', '', new Text(8192), 'Valid verification token.') + ->inject('response') + ->inject('user') + ->inject('session') + ->inject('dbForProject') + ->inject('queueForEvents') + ->action(function (string $challengeResponse, Response $response, Document $user, Document $session, Database $dbForProject, Event $queueForEvents) { + /** @var ?Document $authenticator */ + $authenticator = null; + + foreach ($user->getAttribute('authenticators', []) as $auth) { + if ($auth['type'] === Type::WEBAUTHN && empty($auth['verified'])) { + $authenticator = $auth; + } + }; + + if ($authenticator === null) { + throw new Exception(Exception::USER_AUTHENTICATOR_NOT_FOUND); + } + + $webauthn = new WebAuthn(); + $challenge = $authenticator->getAttribute('data'); + + $publicKeyCredentials = null; + try { + $publicKeyCredentials = $webauthn->verifyRegisterChallenge($challenge, $challengeResponse); + } catch (\Exception $e) { + throw new Exception(Exception::USER_INVALID_TOKEN); + } + + $authenticator->setAttribute('verified', true); + $authenticator->setAttribute('data', $publicKeyCredentials->jsonSerialize()); + $dbForProject->updateDocument('authenticators', $authenticator->getId(), $authenticator); + + $factors = $session->getAttribute('factors', []); + $factors[] = Type::WEBAUTHN; + $factors = \array_unique($factors); + + $session->setAttribute('factors', $factors); + $dbForProject->updateDocument('sessions', $session->getId(), $session); + + $queueForEvents->setParam('userId', $user->getId()); + + $response->dynamic($user, Response::MODEL_ACCOUNT); + }); + App::post('/v1/account/mfa/recovery-codes') ->desc('Create MFA Recovery Codes') ->groups(['api', 'account']) @@ -4769,6 +4823,139 @@ App::put('/v1/account/mfa/challenge') $response->dynamic($session, Response::MODEL_SESSION); }); +App::post('/v1/account/mfa/challenge/webauthn') + ->desc('Create WebAuthn MFA Challenge') + ->groups(['api', 'account', 'mfa']) + ->label('scope', 'account') + ->label('event', 'users.[userId].challenges.[challengeId].create') + ->label('audits.event', 'challenge.create') + ->label('audits.resource', 'user/{response.userId}') + ->label('audits.userId', '{response.userId}') + ->label('sdk.auth', []) + ->label('sdk.namespace', 'account') + ->label('sdk.method', 'createWebauthnMfaChallenge') + ->label('sdk.description', '/docs/references/account/create-webauthn-mfa-challenge.md') + ->label('sdk.response.code', Response::STATUS_CODE_CREATED) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_WEBAUTHN_LOGIN_CHALLENGE) + ->label('abuse-limit', 10) + ->label('abuse-key', 'url:{url},token:{param-token}') + ->inject('response') + ->inject('dbForProject') + ->inject('user') + ->inject('locale') + ->inject('project') + ->inject('request') + ->inject('queueForEvents') + ->action(function (Response $response, Database $dbForProject, Document $user, Locale $locale, Document $project, Request $request, Event $queueForEvents) { + $expire = DateTime::addSeconds(new \DateTime(), Auth::TOKEN_EXPIRATION_WEBAUTHN); + + $webauthn = new WebAuthn(); + + $allowedCredentials = $webauthn->getAllowedCredentials($user); + + if (empty($allowedCredentials)) { + //todo: add exception + } + + $relyingParty = $webauthn->createRelyingParty($project, $request); + $webAuthnChallenge = $webauthn->createLoginChallenge($relyingParty, $allowedCredentials, Auth::TOKEN_EXPIRATION_WEBAUTHN); + + // Store challenge + $challenge = new Document([ + 'userId' => $user->getId(), + 'userInternalId' => $user->getInternalId(), + 'type' => Type::WEBAUTHN, + 'code' => Base64UrlSafe::encodeUnpadded($webAuthnChallenge->challenge), + 'expire' => $expire, + '$permissions' => [ + Permission::read(Role::user($user->getId())), + Permission::update(Role::user($user->getId())), + Permission::delete(Role::user($user->getId())), + ], + ]); + + $challenge = $dbForProject->createDocument('challenges', $challenge); + + $queueForEvents + ->setParam('userId', $user->getId()) + ->setParam('challengeId', $challenge->getId()); + + // Send challenge + $response->dynamic(new Document(array_merge( + [ + '$id' => $challenge->getId(), + ], + $webAuthnChallenge->jsonSerialize() + )), Response::MODEL_WEBAUTHN_LOGIN_CHALLENGE); + }); + +App::put('/v1/account/mfa/webauthn/challenge') + ->desc('Create WebAuthn MFA Challenge (confirmation)') + ->groups(['api', 'account', 'mfa']) + ->label('scope', 'account') + ->label('event', 'users.[userId].sessions.[sessionId].create') + ->label('audits.event', 'challenges.update') + ->label('audits.resource', 'user/{response.userId}') + ->label('audits.userId', '{response.userId}') + ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT]) + ->label('sdk.namespace', 'account') + ->label('sdk.method', 'updateWebauthnMfaChallenge') + ->label('sdk.description', '/docs/references/account/update-webauthn-mfa-challenge.md') + ->label('sdk.response.code', Response::STATUS_CODE_NOCONTENT) + ->label('sdk.response.model', Response::MODEL_SESSION) + ->label('abuse-limit', 10) + ->label('abuse-key', 'userId:{param-userId}') + ->param('challengeId', '', new Text(256), 'ID of the challenge.') + ->param('challengeResponse', '', new Text(8192), 'Valid verification token.') + ->inject('project') + ->inject('response') + ->inject('user') + ->inject('session') + ->inject('dbForProject') + ->inject('queueForEvents') + ->action(function (string $challengeId, string $challengeResponse, Document $project, Response $response, Document $user, Document $session, Database $dbForProject, Event $queueForEvents) { + $challenge = $dbForProject->getDocument('challenges', $challengeId); + + if ($challenge->isEmpty()) { + throw new Exception(Exception::USER_INVALID_TOKEN); + } + + $authenticators = array_filter($user->getAttribute('authenticators', []), function ($auth) { + return $auth['type'] === Type::WEBAUTHN && !empty($auth['verified']); + }); + + + $webauthn = new WebAuthn(); + + // Find authenticator used + $authenticator = null; + foreach ($authenticators as $auth) { + $data = $auth['data']; + if ($data['credentialId'] === $challengeResponse['id']) { + $authenticator = $auth; + break; + } + } + + if ($authenticator === null) { + throw new Exception(Exception::USER_AUTHENTICATOR_NOT_FOUND); + } + + // Check challenge + $publicKeyCredential = null; + try { + $publicKeyCredential = $webauthn->verifyLoginChallenge($challenge->getArrayCopy(), $challengeResponse, $authenticators); + } catch (\Exception $e) { + throw new Exception(Exception::USER_INVALID_TOKEN); + } + + // Update authenticator as counter has changed + + + // Update Session + }); + App::post('/v1/account/targets/push') ->desc('Create push target') ->groups(['api', 'account']) diff --git a/src/Appwrite/Auth/Auth.php b/src/Appwrite/Auth/Auth.php index 6bf321bec5..4569bea0fa 100644 --- a/src/Appwrite/Auth/Auth.php +++ b/src/Appwrite/Auth/Auth.php @@ -76,6 +76,7 @@ class Auth public const TOKEN_EXPIRATION_RECOVERY = 3600; /* 1 hour */ public const TOKEN_EXPIRATION_CONFIRM = 3600 * 1; /* 1 hour */ public const TOKEN_EXPIRATION_OTP = 60 * 15; /* 15 minutes */ + public const TOKEN_EXPIRATION_WEBAUTHN = 60 * 5; /* 5 minutes */ public const TOKEN_EXPIRATION_GENERIC = 60 * 15; /* 15 minutes */ /** diff --git a/src/Appwrite/Auth/MFA/Type.php b/src/Appwrite/Auth/MFA/Type.php index 3516ec3780..9b451df26a 100644 --- a/src/Appwrite/Auth/MFA/Type.php +++ b/src/Appwrite/Auth/MFA/Type.php @@ -13,6 +13,7 @@ abstract class Type public const EMAIL = 'email'; public const PHONE = 'phone'; public const RECOVERY_CODE = 'recoveryCode'; + public const WEBAUTHN = 'webauthn'; public function setLabel(string $label): self { diff --git a/src/Appwrite/Auth/MFA/Type/WebAuthn.php b/src/Appwrite/Auth/MFA/Type/WebAuthn.php new file mode 100644 index 0000000000..2259a9c031 --- /dev/null +++ b/src/Appwrite/Auth/MFA/Type/WebAuthn.php @@ -0,0 +1,244 @@ +publicKeyCredentialLoader = PublicKeyCredentialLoader::create($attestationObjectLoader); + + $this->authenticatorAttestationResponseValidator = AuthenticatorAttestationResponseValidator::create( + $attestationSupportManager + ); + + $this->authenticatiorAssertionResponseValdiator = AuthenticatorAssertionResponseValidator::create(); + } + + public static function createRelyingParty(Document $project, Request $request): PublicKeyCredentialRpEntity + { + // Calculate Relying Party ID and Name + $platforms = $project->getAttribute('platforms', []); + $platformName = ''; + $platformId = ''; + + // Detect platform and set platform name and id for Relying Party. + switch ($request->getHeader('x-sdk-name', '')) { + case 'Flutter': + $packageName = explode('/', $request->getHeader('user-agent', ''))[0] ?? ''; + + foreach ($platforms as $platform) { + if (str_starts_with($platform['type'], 'flutter') && $platform['key'] === $packageName) { + $platformName = $platform['name']; + $platformId = $platform['hostname']; + break; + } + } + break; + + case 'Apple': + $packageName = explode('/', $request->getHeader('user-agent', ''))[0] ?? ''; + + foreach ($platforms as $platform) { + if (str_starts_with($platform['type'], 'apple') && $platform['key'] === $packageName) { + $platformName = $platform['name']; + $platformId = $platform['hostname']; + break; + } + } + break; + + case 'Android': + $packageName = explode('/', $request->getHeader('user-agent', ''))[0] ?? ''; + + foreach ($platforms as $platform) { + if ($platform['type'] === 'android' && $platform['key'] === $packageName) { + $platformName = $platform['name']; + $platformId = $platform['hostname']; + break; + } + } + break; + + case 'Web': + default: + // Fallback to any web platform that matches the domain + foreach ($platforms as $platform) { + if ($platform['type'] === 'web' && $platform['hostname'] == $request->getHostname()) { + $platformName = $platform['name']; + $platformId = $platform['hostname']; + break; + } + } + break; + } + + // Console + if ($project->getId() === 'console') { + $platformName = 'Appwrite'; + $platformId = App::getEnv('_APP_DOMAIN', ''); + } + + return new PublicKeyCredentialRpEntity( + $platformName, + $platformId + ); + } + + public static function createUserEntity(Document $user): PublicKeyCredentialUserEntity + { + $name = $user->getAttribute('name') ?? $user->getAttribute('email'); + + return new PublicKeyCredentialUserEntity( + $name, + $user->getId(), + $name, + ); + } + + public static function createRegisterChallenge(PublicKeyCredentialRpEntity $rpEntity, PublicKeyCredentialUserEntity $userEntity, int $timeout): PublicKeyCredentialCreationOptions + { + return PublicKeyCredentialCreationOptions::create( + rp: $rpEntity, + user: $userEntity, + challenge: random_bytes(32), + timeout: $timeout + ); + } + + public static function createLoginChallenge(PublicKeyCredentialRpEntity $rpEntity, array $allowedCredentials, int $timeout): PublicKeyCredentialRequestOptions + { + return PublicKeyCredentialRequestOptions::create( + rpId: $rpEntity->id, + userVerification: PublicKeyCredentialRequestOptions::USER_VERIFICATION_REQUIREMENT_DEFAULT, + challenge: random_bytes(32), + timeout: $timeout, + allowCredentials: $allowedCredentials + ); + } + + /** + * Get all allowed credentials for a user + * + * @param Document $user + * @return PublicKeyCredentialSource[] + */ + public static function getAllowedCredentials(Document $user): array + { + $authenticators = self::getAuthenticatorsFromUser($user); + + $authenticators = array_filter($authenticators, function ($authenticator) { + /** @var Document $authenticator */ + return $authenticator->getAttribute('verified') === true; + }); + + if (empty($authenticators)) { + return []; + } + + return array_map(function ($authenticator) { + /** @var Document $authenticator */ + return PublicKeyCredentialSource::createFromArray($authenticator->getArrayCopy()); + }, $authenticators); + } + + /** + * @throws \Throwable + */ + public function verifyRegisterChallenge(array $challenge, string $challengeResponse): PublicKeyCredentialSource + { + $publicKeyCredential = $this->publicKeyCredentialLoader->load($challengeResponse); + + $relyingParty = PublicKeyCredentialRpEntity::create( + $challenge['rp']['name'], + $challenge['rp']['id'] + ); + + $userEntity = PublicKeyCredentialUserEntity::create( + $challenge['user']['name'], + $challenge['user']['id'], + $challenge['user']['displayName'], + ); + + $publicKeyCreationOptions = PublicKeyCredentialCreationOptions::create( + $relyingParty, + $userEntity, + Base64UrlSafe::decode($challenge['challenge']), + ); + + return $this->authenticatorAttestationResponseValidator->check( + $publicKeyCredential->response, + $publicKeyCreationOptions, + $challenge['rp']['id'], + App::isDevelopment() ? ['localhost'] : [], + ); + } + + /** + * @throws \Throwable + */ + public function verifyLoginChallenge(array $challenge, string $challengeResponse, string $hostname, PublicKeyCredentialSource $authenticatorPublicKey): PublicKeyCredentialSource + { + $publicKeyCredential = $this->publicKeyCredentialLoader->load($challengeResponse); + + if (!$publicKeyCredential->response instanceof AuthenticatorAssertionResponse) { + throw new Exception('Invalid response'); + } + + $requestOptions = PublicKeyCredentialRequestOptions::create( + rpId: $challenge['rp']['id'], + userVerification: PublicKeyCredentialRequestOptions::USER_VERIFICATION_REQUIREMENT_DEFAULT, + challenge: Base64UrlSafe::decode($challenge['challenge']), + timeout: $challenge['timeout'], + allowCredentials: $challenge['allowCredentials'] + ); + + return $this->authenticatiorAssertionResponseValdiator->check( + credentialId: $authenticatorPublicKey, + authenticatorAssertionResponse: $publicKeyCredential->response, + publicKeyCredentialRequestOptions: $requestOptions, + request: $hostname, + userHandle: $authenticatorPublicKey->userHandle + ); + } + + public static function getAuthenticatorsFromUser(Document $user): ?array + { + $authenticators = array_filter($user->getAttribute('authenticators', []), function ($authenticator) { + /** @var Document $authenticator */ + return $authenticator->getAttribute('type') === Type::WEBAUTHN; + }); + + if (empty($authenticators)) { + return null; + } + + return $authenticators; + } +}