diff --git a/composer.json b/composer.json index 129a303..a0178e8 100644 --- a/composer.json +++ b/composer.json @@ -30,7 +30,7 @@ "php": ">=8.1", "ext-json": "*", "cakephp/cakephp": "^5.0", - "cakedc/users": "^14.3", + "cakedc/users": "^14.3.5", "lcobucci/jwt": "~4.0.0", "firebase/php-jwt": "^6.3" }, @@ -43,7 +43,7 @@ "phpstan/phpstan": "^1.8", "robthree/twofactorauth": "^1.6", "vlucas/phpdotenv": "^3.3", - "web-auth/webauthn-lib": "^5.0" + "web-auth/webauthn-lib": "^4.4" }, "autoload": { "psr-4": { @@ -58,7 +58,6 @@ } }, "prefer-stable": true, - "minimum-stability": "dev", "config": { "sort-packages": true, "allow-plugins": { diff --git a/src/Webauthn/AuthenticateAdapter.php b/src/Webauthn/AuthenticateAdapter.php index f55f4c1..29caefa 100644 --- a/src/Webauthn/AuthenticateAdapter.php +++ b/src/Webauthn/AuthenticateAdapter.php @@ -13,7 +13,10 @@ namespace CakeDC\Api\Webauthn; -use Cake\Log\Log; +use Cake\Http\Exception\BadRequestException; +use Webauthn\AuthenticationExtensions\AuthenticationExtensionsClientInputs; +use Webauthn\AuthenticatorAssertionResponse; +use Webauthn\AuthenticatorAssertionResponseValidator; use Webauthn\PublicKeyCredentialRequestOptions; use Webauthn\PublicKeyCredentialSource; @@ -25,22 +28,20 @@ class AuthenticateAdapter extends BaseAdapter public function getOptions(): PublicKeyCredentialRequestOptions { $userEntity = $this->getUserEntity(); - $allowed = array_map(function (PublicKeyCredentialSource $credential) { + $allowedCredentials = array_map(function (PublicKeyCredentialSource $credential) { return $credential->getPublicKeyCredentialDescriptor(); }, $this->repository->findAllForUserEntity($userEntity)); - Log::error(print_r($allowed, true)); - $options = $this->server->generatePublicKeyCredentialRequestOptions( - PublicKeyCredentialRequestOptions::USER_VERIFICATION_REQUIREMENT_PREFERRED, - $allowed - ); + $options = (new PublicKeyCredentialRequestOptions(random_bytes(32))) + ->setRpId($this->rpEntity->getId()) + ->setUserVerification(PublicKeyCredentialRequestOptions::USER_VERIFICATION_REQUIREMENT_PREFERRED) + ->allowCredentials(...$allowedCredentials) + ->setExtensions(new AuthenticationExtensionsClientInputs()); + $storeEntity = $this->readStore(); - Log::error(print_r($storeEntity, true)); $storeEntity['store'] = []; $storeEntity = $this->patchStore($storeEntity, 'authenticateOptions', base64_encode(serialize($options))); $res = $this->store->save($storeEntity); - Log::error(print_r($storeEntity, true)); - Log::error(print_r($res, true)); return $options; } @@ -49,31 +50,44 @@ public function getOptions(): PublicKeyCredentialRequestOptions * Verify the registration response * * @return \Webauthn\PublicKeyCredentialSource + * @throws \Throwable */ public function verifyResponse(): \Webauthn\PublicKeyCredentialSource { $storeEntity = $this->readStore(); - Log::error(print_r($storeEntity, true)); $options = $this->getStore($storeEntity, 'authenticateOptions'); if ($options) { $options = unserialize(base64_decode($options)); } - Log::error(print_r($options, true)); - return $this->loadAndCheckAssertionResponse($options); + $publicKeyCredentialLoader = $this->createPublicKeyCredentialLoader(); + $publicKeyCredential = $publicKeyCredentialLoader->loadArray($this->request->getData()); + $authenticatorAssertionResponse = $publicKeyCredential->getResponse(); + if ($authenticatorAssertionResponse instanceof AuthenticatorAssertionResponse) { + $authenticatorAssertionResponseValidator = $this->createAssertionResponseValidator(); + + return $authenticatorAssertionResponseValidator->check( + $publicKeyCredential->getRawId(), + $authenticatorAssertionResponse, + $options, + $this->request, + $this->getUserEntity()->getId(), + ); + } + + throw new BadRequestException(__d('cake_d_c/api', 'Could not validate credential response for authentication')); } /** - * @param \Webauthn\PublicKeyCredentialRequestOptions $options request options - * @return \Webauthn\PublicKeyCredentialSource + * @return \Webauthn\AuthenticatorAssertionResponseValidator */ - protected function loadAndCheckAssertionResponse($options): PublicKeyCredentialSource + protected function createAssertionResponseValidator(): AuthenticatorAssertionResponseValidator { - return $this->server->loadAndCheckAssertionResponse( - json_encode($this->request->getData()), - $options, - $this->getUserEntity(), - $this->request + return new AuthenticatorAssertionResponseValidator( + $this->repository, + null, + $this->createExtensionOutputCheckerHandler(), + $this->getAlgorithmManager() ); } } diff --git a/src/Webauthn/Base64Utility.php b/src/Webauthn/Base64Utility.php new file mode 100644 index 0000000..143ee42 --- /dev/null +++ b/src/Webauthn/Base64Utility.php @@ -0,0 +1,59 @@ +get('CakeDC/Api.AuthStore'); $this->store = $store; $session = $this->readStore(); - $rpEntity = new PublicKeyCredentialRpEntity( + $this->rpEntity = new PublicKeyCredentialRpEntity( Configure::read('Api.Webauthn2fa.' . $this->getDomain() . '.appName'), // The application name Configure::read('Api.Webauthn2fa.' . $this->getDomain() . '.id') ); @@ -81,11 +107,6 @@ public function __construct(ServerRequest $request, ?UsersTable $usersTable, $us $this->user, $usersTable ); - - $this->server = new Server( - $rpEntity, - $this->repository - ); } /** @@ -107,7 +128,7 @@ protected function getUserEntity(): PublicKeyCredentialUserEntity /** * Get the user. * - * @return array|mixed|null + * @return mixed|array|null */ public function getUser() { @@ -229,4 +250,75 @@ public function getDomain($replace = true) { return RequestParser::getDomain($this->request, $replace); } + + /** + * @param \Webauthn\AttestationStatement\AttestationStatementSupportManager $attestationStatementSupportManager manager instance + * @return void + */ + public function setAttestationStatementSupportManager( + AttestationStatementSupportManager $attestationStatementSupportManager + ): void { + $this->attestationStatementSupportManager = $attestationStatementSupportManager; + } + + /** + * @return \Webauthn\AttestationStatement\AttestationStatementSupportManager + */ + protected function getAttestationStatementSupportManager(): AttestationStatementSupportManager + { + if ($this->attestationStatementSupportManager === null) { + $this->attestationStatementSupportManager = new AttestationStatementSupportManager(); + $this->attestationStatementSupportManager + ->add(new NoneAttestationStatementSupport()); + } + + return $this->attestationStatementSupportManager; + } + + /** + * @return \CakeDC\Api\Webauthn\PublicKeyCredentialLoader + */ + protected function createPublicKeyCredentialLoader(): PublicKeyCredentialLoader + { + $attestationObjectLoader = new AttestationObjectLoader( + $this->getAttestationStatementSupportManager() + ); + + return new PublicKeyCredentialLoader( + $attestationObjectLoader + ); + } + + /** + * @return \Webauthn\AuthenticationExtensions\ExtensionOutputCheckerHandler + */ + protected function createExtensionOutputCheckerHandler(): ExtensionOutputCheckerHandler + { + return new ExtensionOutputCheckerHandler(); + } + + /** + * @return \Cose\Algorithm\Manager + */ + protected function getAlgorithmManager(): Manager + { + if ($this->algorithmManager === null) { + $this->algorithmManager = Manager::create()->add( + ES256::create(), + ES256K::create(), + ES384::create(), + ES512::create(), + RS256::create(), + RS384::create(), + RS512::create(), + PS256::create(), + PS384::create(), + PS512::create(), + Ed256::create(), + Ed512::create(), + ); + } + + return $this->algorithmManager; + } } diff --git a/src/Webauthn/PublicKeyCredentialLoader.php b/src/Webauthn/PublicKeyCredentialLoader.php new file mode 100644 index 0000000..b23f327 --- /dev/null +++ b/src/Webauthn/PublicKeyCredentialLoader.php @@ -0,0 +1,37 @@ +getUserEntity(); - $options = $this->server->generatePublicKeyCredentialCreationOptions( + $challenge = random_bytes(16); + + $options = PublicKeyCredentialCreationOptions::create( + $this->rpEntity, $userEntity, - PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE, - [] + $challenge, + $this->getPubKeyCredParams() ); + $options = $options + ->setAuthenticatorSelection(new AuthenticatorSelectionCriteria()) + ->setAttestation(PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE) + ->setExtensions(new AuthenticationExtensionsClientInputs()); + $storeEntity = $this->readStore(); $storeEntity = $this->patchStore($storeEntity, 'registerOptions', base64_encode(serialize($options))); $this->store->save($storeEntity); @@ -35,12 +51,28 @@ public function getOptions(): PublicKeyCredentialCreationOptions return $options; } + /** + * @return array<\Webauthn\PublicKeyCredentialParameters> + */ + protected function getPubKeyCredParams(): array + { + $list = []; + foreach ($this->getAlgorithmManager()->all() as $algorithm) { + $list[] = PublicKeyCredentialParameters::create( + PublicKeyCredentialDescriptor::CREDENTIAL_TYPE_PUBLIC_KEY, + $algorithm::identifier() + ); + } + + return $list; + } + /** * Verify the registration response * * @return \Webauthn\PublicKeyCredentialSource */ - public function verifyResponse(): \Webauthn\PublicKeyCredentialSource + public function verifyResponse(): PublicKeyCredentialSource { $storeEntity = $this->readStore(); $options = $this->getStore($storeEntity, 'registerOptions'); @@ -48,24 +80,31 @@ public function verifyResponse(): \Webauthn\PublicKeyCredentialSource $options = unserialize(base64_decode($options)); } - $credential = $this->loadAndCheckAttestationResponse($options); - $this->repository->saveCredentialSource($credential); + $attestationStatementSupportManager = $this->getAttestationStatementSupportManager(); + $publicKeyCredentialLoader = $this->createPublicKeyCredentialLoader(); - return $credential; - } + $publicKeyCredential = $publicKeyCredentialLoader->loadArray((array)$this->request->getData()); + $authenticatorAttestationResponse = $publicKeyCredential->getResponse(); + if ($authenticatorAttestationResponse instanceof AuthenticatorAttestationResponse) { + $extensionOutputCheckerHandler = $this->createExtensionOutputCheckerHandler(); - /** - * @param \Webauthn\PublicKeyCredentialCreationOptions $options creation options - * @return \Webauthn\PublicKeyCredentialSource - */ - protected function loadAndCheckAttestationResponse($options): \Webauthn\PublicKeyCredentialSource - { - $credential = $this->server->loadAndCheckAttestationResponse( - json_encode($this->request->getData()), - $options, - $this->request - ); + $authenticatorAttestationResponseValidator = new AuthenticatorAttestationResponseValidator( + $attestationStatementSupportManager, + $this->repository, + null, //Token binding is deprecated + $extensionOutputCheckerHandler + ); + + $credential = $authenticatorAttestationResponseValidator->check( + $authenticatorAttestationResponse, + $options, + $this->request + ); + $this->repository->saveCredentialSource($credential); + + return $credential; + } - return $credential; + throw new BadRequestException(__d('cake_d_c/api', 'Could not create credential response for registration')); } } diff --git a/src/Webauthn/Repository/UserCredentialSourceRepository.php b/src/Webauthn/Repository/UserCredentialSourceRepository.php index 69d44ca..794f995 100644 --- a/src/Webauthn/Repository/UserCredentialSourceRepository.php +++ b/src/Webauthn/Repository/UserCredentialSourceRepository.php @@ -3,12 +3,12 @@ namespace CakeDC\Api\Webauthn\Repository; -use Base64Url\Base64Url; use Cake\Datasource\EntityInterface; use Cake\Http\ServerRequest; use Cake\Utility\Hash; use CakeDC\Api\Utility\RequestParser; use CakeDC\Users\Model\Table\UsersTable; +use CakeDC\Users\Webauthn\Base64Utility; use Webauthn\PublicKeyCredentialSource; use Webauthn\PublicKeyCredentialSourceRepository; use Webauthn\PublicKeyCredentialUserEntity; @@ -46,7 +46,7 @@ public function __construct(ServerRequest $request, EntityInterface $user, ?User */ public function findOneByCredentialId(string $publicKeyCredentialId): ?PublicKeyCredentialSource { - $encodedId = Base64Url::encode($publicKeyCredentialId); + $encodedId = Base64Utility::basicEncode($publicKeyCredentialId); $credentials = $this->getUserData($this->user); $credential = $credentials[$encodedId] ?? null; @@ -60,12 +60,10 @@ public function findOneByCredentialId(string $publicKeyCredentialId): ?PublicKey */ public function findAllForUserEntity(PublicKeyCredentialUserEntity $publicKeyCredentialUserEntity): array { - if ($publicKeyCredentialUserEntity->getId() != $this->user->id) { + if ($publicKeyCredentialUserEntity->getId() != $this->user->get('id')) { return []; } - \Cake\Log\Log::error(print_r($this->user, true)); $credentials = $this->getUserData($this->user); - \Cake\Log\Log::error(print_r($credentials, true)); $list = []; foreach ($credentials as $credential) { @@ -82,7 +80,7 @@ public function findAllForUserEntity(PublicKeyCredentialUserEntity $publicKeyCre public function saveCredentialSource(PublicKeyCredentialSource $publicKeyCredentialSource): void { $credentials = $this->getUserData($this->user); - $id = Base64Url::encode($publicKeyCredentialSource->getPublicKeyCredentialId()); + $id = Base64Utility::basicEncode($publicKeyCredentialSource->getPublicKeyCredentialId()); $credentials[$id] = json_decode(json_encode($publicKeyCredentialSource), true); $this->patchUserData($this->user, $credentials); $res = $this->usersTable->saveOrFail($this->user); diff --git a/tests/TestCase/Webauthn/Base64UtilityTest.php b/tests/TestCase/Webauthn/Base64UtilityTest.php new file mode 100644 index 0000000..9f11957 --- /dev/null +++ b/tests/TestCase/Webauthn/Base64UtilityTest.php @@ -0,0 +1,81 @@ +assertNotEmpty($encoded); + $this->assertStringEndsNotWith('=', $encoded); + $decoded = Base64Utility::basicDecode($encoded); + $this->assertSame($originalText, $decoded); + } + + /** + * @return \string[][] + */ + public static function dataProviderBasicEncodeDecodeText(): array + { + return [ + ['00000000-0000-0000-0000-000000000002'], + ['$2y$10$Nvu7ipP.z8tiIl75OdUvt.86vuG6iKMoHIOc7O7mboFI85hSyTEde'], + ['First Name'], + ]; + } + + /** + * Test method complyEncodedNoPadding + * + * @param string $encodedTextWithPadding + * @param string $originalText + * @dataProvider dataProviderComplyEncodedNoPadding + * @return void + */ + public function testComplyEncodedNoPadding($encodedTextWithPadding, $originalText) + { + $encoded = Base64Utility::complyEncodedNoPadding($encodedTextWithPadding); + $this->assertNotEmpty($encoded); + $this->assertStringEndsNotWith('=', $encoded); + $decoded = Base64UrlSafe::decodeNoPadding($encoded); + $this->assertSame($originalText, $decoded); + } + + /** + * @return \string[][] + */ + public static function dataProviderComplyEncodedNoPadding(): array + { + return [ + ['JDJ5JDEwJE52dTdpcFAuejh0aUlsNzVPZFV2dC44NnZ1RzZpS01vSElPYzdPN21ib0ZJODU=', '$2y$10$Nvu7ipP.z8tiIl75OdUvt.86vuG6iKMoHIOc7O7mboFI85'], + ['MDAwMDAwMDAwMDM=', '00000000003'], + //does not end with = + ['MDAwMDAwMDAtMDAwMC0wMDAwLTAwMDAtMDAwMDAwMDAwMDAz', '00000000-0000-0000-0000-000000000003'], + //end with == + ['Rmlyc3QgTmFtZQ==', 'First Name'], + ]; + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 494e55d..0ef5b88 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -137,6 +137,8 @@ function def($name, $value) Cake\Core\Configure::write('Session', [ 'defaults' => 'php', ]); +session_id('cli'); + Cake\Core\Configure::write('Security.salt', 'bc8b5b70eb0e18bac40204dc3a5b9fbc8b5b70eb0e18bac40204dc3a5b9f'); mb_internal_encoding(Configure::read('App.encoding'));