diff --git a/composer.json b/composer.json index 241debc5a..4b4959d77 100644 --- a/composer.json +++ b/composer.json @@ -119,6 +119,7 @@ "symfony/filesystem": "^6.4|^7.0", "symfony/finder": "^6.4|^7.0", "symfony/monolog-bundle": "^3.8", + "symfony/twig-bundle": "^6.4|^7.0", "symfony/var-dumper": "^6.4|^7.0", "symfony/yaml": "^6.4|^7.0", "symplify/easy-coding-standard": "^12.0", diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index ae2ef3c7c..b3417d27b 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -418,7 +418,7 @@ parameters: path: src/symfony/src/DependencyInjection/WebauthnExtension.php - - message: '#^Method Webauthn\\Bundle\\Doctrine\\Type\\AttestedCredentialDataType\:\:convertToDatabaseValue\(\) should return string\|null but returns mixed\.$#' + message: '#^Method Webauthn\\Bundle\\Doctrine\\Type\\AttestedCredentialDataType\:\:convertToDatabaseValue\(\) should return string\|null but returns T of mixed\.$#' identifier: return.type count: 1 path: src/symfony/src/Doctrine/Type/AttestedCredentialDataType.php @@ -436,7 +436,7 @@ parameters: path: src/symfony/src/Doctrine/Type/AttestedCredentialDataType.php - - message: '#^Method Webauthn\\Bundle\\Doctrine\\Type\\PublicKeyCredentialDescriptorType\:\:convertToDatabaseValue\(\) should return string\|null but returns mixed\.$#' + message: '#^Method Webauthn\\Bundle\\Doctrine\\Type\\PublicKeyCredentialDescriptorType\:\:convertToDatabaseValue\(\) should return string\|null but returns T of mixed\.$#' identifier: return.type count: 1 path: src/symfony/src/Doctrine/Type/PublicKeyCredentialDescriptorType.php @@ -454,7 +454,7 @@ parameters: path: src/symfony/src/Doctrine/Type/PublicKeyCredentialDescriptorType.php - - message: '#^Method Webauthn\\Bundle\\Doctrine\\Type\\TrustPathDataType\:\:convertToDatabaseValue\(\) should return string\|null but returns mixed\.$#' + message: '#^Method Webauthn\\Bundle\\Doctrine\\Type\\TrustPathDataType\:\:convertToDatabaseValue\(\) should return string\|null but returns T of mixed\.$#' identifier: return.type count: 1 path: src/symfony/src/Doctrine/Type/TrustPathDataType.php @@ -561,6 +561,168 @@ parameters: count: 1 path: src/symfony/src/Security/Authentication/Token/WebauthnToken.php + - + message: '#^Access to an undefined property Webauthn\\AuthenticatorResponse\:\:\$attestationObject\.$#' + identifier: property.notFound + count: 1 + path: src/symfony/src/Security/Authentication/WebauthnAuthenticator.php + + - + message: '#^Cannot access property \$authData on mixed\.$#' + identifier: property.nonObject + count: 1 + path: src/symfony/src/Security/Authentication/WebauthnAuthenticator.php + + - + message: '#^Cannot access property \$extensions on mixed\.$#' + identifier: property.nonObject + count: 1 + path: src/symfony/src/Security/Authentication/WebauthnAuthenticator.php + + - + message: '#^Cannot access property \$signCount on mixed\.$#' + identifier: property.nonObject + count: 1 + path: src/symfony/src/Security/Authentication/WebauthnAuthenticator.php + + - + message: '#^Cannot call method getReservedForFutureUse1\(\) on mixed\.$#' + identifier: method.nonObject + count: 1 + path: src/symfony/src/Security/Authentication/WebauthnAuthenticator.php + + - + message: '#^Cannot call method getReservedForFutureUse2\(\) on mixed\.$#' + identifier: method.nonObject + count: 1 + path: src/symfony/src/Security/Authentication/WebauthnAuthenticator.php + + - + message: '#^Cannot call method isBackedUp\(\) on mixed\.$#' + identifier: method.nonObject + count: 1 + path: src/symfony/src/Security/Authentication/WebauthnAuthenticator.php + + - + message: '#^Cannot call method isBackupEligible\(\) on mixed\.$#' + identifier: method.nonObject + count: 1 + path: src/symfony/src/Security/Authentication/WebauthnAuthenticator.php + + - + message: '#^Cannot call method isUserPresent\(\) on mixed\.$#' + identifier: method.nonObject + count: 1 + path: src/symfony/src/Security/Authentication/WebauthnAuthenticator.php + + - + message: '#^Cannot call method isUserVerified\(\) on mixed\.$#' + identifier: method.nonObject + count: 1 + path: src/symfony/src/Security/Authentication/WebauthnAuthenticator.php + + - + message: '#^Parameter \#12 \$isBackupEligible of class Webauthn\\Bundle\\Security\\Authentication\\Token\\WebauthnToken constructor expects bool, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/symfony/src/Security/Authentication/WebauthnAuthenticator.php + + - + message: '#^Parameter \#13 \$isBackedUp of class Webauthn\\Bundle\\Security\\Authentication\\Token\\WebauthnToken constructor expects bool, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/symfony/src/Security/Authentication/WebauthnAuthenticator.php + + - + message: '#^Parameter \#4 \$isUserPresent of class Webauthn\\Bundle\\Security\\Authentication\\Token\\WebauthnToken constructor expects bool, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/symfony/src/Security/Authentication/WebauthnAuthenticator.php + + - + message: '#^Parameter \#5 \$isUserVerified of class Webauthn\\Bundle\\Security\\Authentication\\Token\\WebauthnToken constructor expects bool, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/symfony/src/Security/Authentication/WebauthnAuthenticator.php + + - + message: '#^Parameter \#6 \$reservedForFutureUse1 of class Webauthn\\Bundle\\Security\\Authentication\\Token\\WebauthnToken constructor expects int, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/symfony/src/Security/Authentication/WebauthnAuthenticator.php + + - + message: '#^Parameter \#7 \$reservedForFutureUse2 of class Webauthn\\Bundle\\Security\\Authentication\\Token\\WebauthnToken constructor expects int, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/symfony/src/Security/Authentication/WebauthnAuthenticator.php + + - + message: '#^Parameter \#8 \$signCount of class Webauthn\\Bundle\\Security\\Authentication\\Token\\WebauthnToken constructor expects int, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/symfony/src/Security/Authentication/WebauthnAuthenticator.php + + - + message: '#^Parameter \#9 \$extensions of class Webauthn\\Bundle\\Security\\Authentication\\Token\\WebauthnToken constructor expects Webauthn\\AuthenticationExtensions\\AuthenticationExtensions\|null, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/symfony/src/Security/Authentication/WebauthnAuthenticator.php + + - + message: '#^Class Webauthn\\Bundle\\Security\\Authentication\\WebauthnBadge has an uninitialized property \$authenticatorResponse\. Give it default value or assign it in the constructor\.$#' + identifier: property.uninitialized + count: 1 + path: src/symfony/src/Security/Authentication/WebauthnBadge.php + + - + message: '#^Class Webauthn\\Bundle\\Security\\Authentication\\WebauthnBadge has an uninitialized property \$publicKeyCredentialOptions\. Give it default value or assign it in the constructor\.$#' + identifier: property.uninitialized + count: 1 + path: src/symfony/src/Security/Authentication/WebauthnBadge.php + + - + message: '#^Class Webauthn\\Bundle\\Security\\Authentication\\WebauthnBadge has an uninitialized property \$publicKeyCredentialSource\. Give it default value or assign it in the constructor\.$#' + identifier: property.uninitialized + count: 1 + path: src/symfony/src/Security/Authentication/WebauthnBadge.php + + - + message: '#^Class Webauthn\\Bundle\\Security\\Authentication\\WebauthnBadge has an uninitialized property \$publicKeyCredentialUserEntity\. Give it default value or assign it in the constructor\.$#' + identifier: property.uninitialized + count: 1 + path: src/symfony/src/Security/Authentication/WebauthnBadge.php + + - + message: '#^Class Webauthn\\Bundle\\Security\\Authentication\\WebauthnBadge has an uninitialized property \$user\. Give it default value or assign it in the constructor\.$#' + identifier: property.uninitialized + count: 1 + path: src/symfony/src/Security/Authentication/WebauthnBadge.php + + - + message: '#^Method Webauthn\\Bundle\\Security\\Authentication\\WebauthnBadge\:\:__construct\(\) has parameter \$attributes with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/symfony/src/Security/Authentication/WebauthnBadge.php + + - + message: '#^Property Webauthn\\Bundle\\Security\\Authentication\\WebauthnBadge\:\:\$user \(Symfony\\Component\\Security\\Core\\User\\UserInterface\) does not accept mixed\.$#' + identifier: assign.propertyType + count: 1 + path: src/symfony/src/Security/Authentication/WebauthnBadge.php + + - + message: '#^Method Webauthn\\Bundle\\Security\\Authentication\\WebauthnBadgeListener\:\:__construct\(\) has parameter \$userProvider with generic interface Symfony\\Component\\Security\\Core\\User\\UserProviderInterface but does not specify its types\: TUser$#' + identifier: missingType.generics + count: 1 + path: src/symfony/src/Security/Authentication/WebauthnBadgeListener.php + + - + message: '#^Webauthn\\Bundle\\Security\\Authentication\\WebauthnPassport\:\:__construct\(\) does not call parent constructor from Symfony\\Component\\Security\\Http\\Authenticator\\Passport\\Passport\.$#' + identifier: constructor.missingParentCall + count: 1 + path: src/symfony/src/Security/Authentication/WebauthnPassport.php + - message: '#^Method Webauthn\\Bundle\\Security\\Http\\Authenticator\\WebauthnAuthenticator\:\:__construct\(\) has parameter \$userProvider with generic interface Symfony\\Component\\Security\\Core\\User\\UserProviderInterface but does not specify its types\: TUser$#' identifier: missingType.generics @@ -1341,12 +1503,6 @@ parameters: count: 3 path: src/webauthn/src/AuthenticationExtensions/AuthenticationExtensions.php - - - message: '#^Cannot unset @readonly Webauthn\\AuthenticationExtensions\\AuthenticationExtensions\:\:\$extensions property\.$#' - identifier: unset.readOnlyPropertyByPhpDoc - count: 1 - path: src/webauthn/src/AuthenticationExtensions/AuthenticationExtensions.php - - message: '#^Class Webauthn\\AuthenticationExtensions\\AuthenticationExtensions implements generic interface ArrayAccess but does not specify its types\: TKey, TValue$#' identifier: missingType.generics diff --git a/rector.php b/rector.php index c9469f0c9..be0d62da1 100644 --- a/rector.php +++ b/rector.php @@ -5,6 +5,7 @@ use Rector\Config\RectorConfig; use Rector\DeadCode\Rector\ClassMethod\RemoveUnusedPrivateMethodParameterRector; use Rector\Doctrine\Set\DoctrineSetList; +use Rector\Php84\Rector\Param\ExplicitNullableParamTypeRector; use Rector\PHPUnit\CodeQuality\Rector\Class_\PreferPHPUnitThisCallRector; use Rector\PHPUnit\Set\PHPUnitSetList; use Rector\Set\ValueObject\SetList; @@ -34,6 +35,7 @@ ], PreferPHPUnitThisCallRector::class, ]); + $config->rule(ExplicitNullableParamTypeRector::class); $config->phpVersion(PhpVersion::PHP_82); $config::configure()->withComposerBased(twig: true, doctrine: true, phpunit: true); $config::configure()->withPhpSets(); diff --git a/src/stimulus/.github/close-pull-request.yml b/src/stimulus/.github/close-pull-request.yml new file mode 100644 index 000000000..ba9500ee4 --- /dev/null +++ b/src/stimulus/.github/close-pull-request.yml @@ -0,0 +1,20 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: | + Thanks for your Pull Request! We love contributions. + + However, you should instead open your PR on the main repository: + https://github.com/web-auth/webauthn-framework + + This repository is what we call a "subtree split": a read-only subset of that main repository. + We're looking forward to your PR there! diff --git a/src/symfony/.github/close-pull-request.yml b/src/symfony/.github/close-pull-request.yml new file mode 100644 index 000000000..ba9500ee4 --- /dev/null +++ b/src/symfony/.github/close-pull-request.yml @@ -0,0 +1,20 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: | + Thanks for your Pull Request! We love contributions. + + However, you should instead open your PR on the main repository: + https://github.com/web-auth/webauthn-framework + + This repository is what we call a "subtree split": a read-only subset of that main repository. + We're looking forward to your PR there! diff --git a/src/symfony/src/Repository/DoctrineCredentialSourceRepository.php b/src/symfony/src/Repository/DoctrineCredentialSourceRepository.php index 0017c95bc..d83786d53 100644 --- a/src/symfony/src/Repository/DoctrineCredentialSourceRepository.php +++ b/src/symfony/src/Repository/DoctrineCredentialSourceRepository.php @@ -14,8 +14,6 @@ /** * @template T of PublicKeyCredentialSource * @template-extends ServiceEntityRepository - * - * @deprecated since 5.2.0, to be removed in 6.0.0. Please create your own doctrine-based repository. */ class DoctrineCredentialSourceRepository extends ServiceEntityRepository implements PublicKeyCredentialSourceRepositoryInterface, CanSaveCredentialSource { diff --git a/src/symfony/src/Resources/config/doctrine-mapping/PublicKeyCredentialSource.orm.xml b/src/symfony/src/Resources/config/doctrine-mapping/PublicKeyCredentialSource.orm.xml index 90cb8e581..f3329c092 100644 --- a/src/symfony/src/Resources/config/doctrine-mapping/PublicKeyCredentialSource.orm.xml +++ b/src/symfony/src/Resources/config/doctrine-mapping/PublicKeyCredentialSource.orm.xml @@ -5,7 +5,7 @@ xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping https://raw.github.com/doctrine/doctrine2/master/doctrine-mapping.xsd" > - + diff --git a/src/symfony/src/Resources/config/security.php b/src/symfony/src/Resources/config/security.php index 5f5f1ec02..577a365ab 100644 --- a/src/symfony/src/Resources/config/security.php +++ b/src/symfony/src/Resources/config/security.php @@ -8,6 +8,7 @@ use Webauthn\Bundle\DependencyInjection\Factory\Security\WebauthnFactory; use Webauthn\Bundle\Repository\PublicKeyCredentialSourceRepositoryInterface; use Webauthn\Bundle\Repository\PublicKeyCredentialUserEntityRepositoryInterface; +use Webauthn\Bundle\Security\Authentication\WebauthnBadgeListener; use Webauthn\Bundle\Security\Authorization\Voter\IsUserPresentVoter; use Webauthn\Bundle\Security\Authorization\Voter\IsUserVerifiedVoter; use Webauthn\Bundle\Security\Guesser\CurrentUserEntityGuesser; @@ -51,9 +52,7 @@ service(PublicKeyCredentialUserEntityRepositoryInterface::class), service(SerializerInterface::class), abstract_arg('Authenticator Assertion Response Validator'), - abstract_arg( - 'Authenticator Attestation Response Validator' - ), //service(AuthenticatorAttestationResponseValidator::class) + abstract_arg('Authenticator Attestation Response Validator'), ]); $service ->set(WebauthnFactory::FIREWALL_CONFIG_DEFINITION_ID, WebauthnFirewallConfig::class) @@ -62,4 +61,5 @@ $service->set(CurrentUserEntityGuesser::class); $service->set(RequestBodyUserEntityGuesser::class); + $service->set(WebauthnBadgeListener::class); }; diff --git a/src/symfony/src/Security/Authentication/WebauthnAuthenticator.php b/src/symfony/src/Security/Authentication/WebauthnAuthenticator.php new file mode 100644 index 000000000..f12a17399 --- /dev/null +++ b/src/symfony/src/Security/Authentication/WebauthnAuthenticator.php @@ -0,0 +1,51 @@ +getBadge(WebauthnBadge::class); + assert($webauthnBadge instanceof WebauthnBadge, 'Invalid badge'); + if ($webauthnBadge->getAuthenticatorResponse() instanceof AuthenticatorAssertionResponse) { + $authData = $webauthnBadge->getAuthenticatorResponse() + ->authenticatorData; + } else { + $authData = $webauthnBadge->getAuthenticatorResponse() + ->attestationObject + ->authData; + } + + $token = new WebauthnToken( + $webauthnBadge->getPublicKeyCredentialUserEntity(), + $webauthnBadge->getPublicKeyCredentialOptions(), + $webauthnBadge->getPublicKeyCredentialSource() + ->getPublicKeyCredentialDescriptor(), + $authData->isUserPresent(), + $authData->isUserVerified(), + $authData->getReservedForFutureUse1(), + $authData->getReservedForFutureUse2(), + $authData->signCount, + $authData->extensions, + $firewallName, + $webauthnBadge->getUser() + ->getRoles(), + $authData->isBackupEligible(), + $authData->isBackedUp(), + ); + $token->setUser($webauthnBadge->getUser()); + + return $token; + } +} diff --git a/src/symfony/src/Security/Authentication/WebauthnBadge.php b/src/symfony/src/Security/Authentication/WebauthnBadge.php new file mode 100644 index 000000000..acbe803c4 --- /dev/null +++ b/src/symfony/src/Security/Authentication/WebauthnBadge.php @@ -0,0 +1,127 @@ +userLoader = $userLoader; + } + + public function isResolved(): bool + { + return $this->isResolved; + } + + public function getAuthenticatorResponse(): AuthenticatorResponse + { + if (! $this->isResolved) { + throw new LogicException('The badge is not resolved.'); + } + return $this->authenticatorResponse; + } + + public function getPublicKeyCredentialOptions(): PublicKeyCredentialOptions + { + if (! $this->isResolved) { + throw new LogicException('The badge is not resolved.'); + } + return $this->publicKeyCredentialOptions; + } + + public function getPublicKeyCredentialUserEntity(): PublicKeyCredentialUserEntity + { + if (! $this->isResolved) { + throw new LogicException('The badge is not resolved.'); + } + return $this->publicKeyCredentialUserEntity; + } + + public function getPublicKeyCredentialSource(): PublicKeyCredentialSource + { + if (! $this->isResolved) { + throw new LogicException('The badge is not resolved.'); + } + return $this->publicKeyCredentialSource; + } + + public function getUser(): UserInterface + { + if (! $this->isResolved) { + throw new LogicException('The badge is not resolved.'); + } + return $this->user; + } + + public function markResolved( + AuthenticatorResponse $authenticatorResponse, + PublicKeyCredentialOptions $publicKeyCredentialOptions, + PublicKeyCredentialUserEntity $publicKeyCredentialUserEntity, + PublicKeyCredentialSource $publicKeyCredentialSource, + ): void { + if ($this->userLoader === null) { + throw new LogicException(sprintf( + 'No user loader is configured, did you forget to register the "%s" listener?', + WebauthnBadgeListener::class + )); + } + $this->authenticatorResponse = $authenticatorResponse; + $this->publicKeyCredentialOptions = $publicKeyCredentialOptions; + $this->publicKeyCredentialUserEntity = $publicKeyCredentialUserEntity; + $this->publicKeyCredentialSource = $publicKeyCredentialSource; + $user = ($this->userLoader)($publicKeyCredentialUserEntity->name, $this->attributes); + if ($user === null) { + $exception = new UserNotFoundException(); + $exception->setUserIdentifier($publicKeyCredentialSource->userHandle); + + throw $exception; + } + $this->user = $user; + $this->isResolved = true; + } + + public function setUserLoader(callable $userLoader): void + { + $this->userLoader = $userLoader; + } + + public function getUserLoader(): ?callable + { + return $this->userLoader; + } +} diff --git a/src/symfony/src/Security/Authentication/WebauthnBadgeListener.php b/src/symfony/src/Security/Authentication/WebauthnBadgeListener.php new file mode 100644 index 000000000..01ca8ae86 --- /dev/null +++ b/src/symfony/src/Security/Authentication/WebauthnBadgeListener.php @@ -0,0 +1,169 @@ +getPassport(); + if (! $passport->hasBadge(WebauthnBadge::class)) { + return; + } + + /** @var WebauthnBadge $badge */ + $badge = $passport->getBadge(WebauthnBadge::class); + if ($badge->isResolved()) { + return; + } + if ($badge->getUserLoader() === null) { + $badge->setUserLoader($this->userProvider->loadUserByIdentifier(...)); + } + + try { + $publicKeyCredential = $this->publicKeyCredentialLoader->deserialize( + $badge->response, + PublicKeyCredential::class, + JsonEncoder::FORMAT + ); + $response = $publicKeyCredential->response; + $data = $this->optionsStorage->get($response->clientDataJSON->challenge); + $publicKeyCredentialRequestOptions = $data->getPublicKeyCredentialOptions(); + $userEntity = $data->getPublicKeyCredentialUserEntity(); + + switch (true) { + case $publicKeyCredentialRequestOptions instanceof PublicKeyCredentialRequestOptions && $response instanceof AuthenticatorAssertionResponse: + $this->processRequest( + $badge, + $publicKeyCredentialRequestOptions, + $userEntity, + $publicKeyCredential->rawId, + $response, + ); + break; + case $badge->allowRegistration && $publicKeyCredentialRequestOptions instanceof PublicKeyCredentialCreationOptions && $response instanceof AuthenticatorAttestationResponse: + $this->processCreation($badge, $publicKeyCredentialRequestOptions, $userEntity, $response); + break; + default: + return; + } + } catch (Throwable) { + return; + } + } + + private function processRequest( + WebauthnBadge $badge, + PublicKeyCredentialRequestOptions $publicKeyCredentialRequestOptions, + ?PublicKeyCredentialUserEntity $userEntity, + string $publicKeyCredentialId, + AuthenticatorAssertionResponse $response, + ): void { + $publicKeyCredentialSource = $this->publicKeyCredentialSourceRepository->findOneByCredentialId( + $publicKeyCredentialId + ); + if ($publicKeyCredentialSource === null) { + throw InvalidDataException::create($publicKeyCredentialSource, 'The credential ID is invalid.'); + } + $publicKeyCredentialSource = $this->assertionResponseValidator->check( + $publicKeyCredentialSource, + $response, + $publicKeyCredentialRequestOptions, + $badge->host, + $userEntity?->id + ); + $userEntity = $this->credentialUserEntityRepository->findOneByUserHandle( + $publicKeyCredentialSource->userHandle + ); + if (! $userEntity instanceof PublicKeyCredentialUserEntity) { + throw InvalidDataException::create($userEntity, 'Invalid user entity'); + } + if ($this->publicKeyCredentialSourceRepository instanceof CanSaveCredentialSource) { + $this->publicKeyCredentialSourceRepository->saveCredentialSource($publicKeyCredentialSource); + } + + $badge->markResolved( + $response, + $publicKeyCredentialRequestOptions, + $userEntity, + $publicKeyCredentialSource, + ); + } + + private function processCreation( + WebauthnBadge $badge, + PublicKeyCredentialCreationOptions $publicKeyCredentialCreationOptions, + ?PublicKeyCredentialUserEntity $userEntity, + AuthenticatorAttestationResponse $response, + ): void { + if (! $this->credentialUserEntityRepository instanceof CanRegisterUserEntity) { + throw UnsupportedFeatureException::create('The user entity repository does not support registration.'); + } + if (! $this->publicKeyCredentialSourceRepository instanceof CanSaveCredentialSource) { + throw UnsupportedFeatureException::create( + 'The credential source repository does not support registration.' + ); + } + if (! $userEntity instanceof PublicKeyCredentialUserEntity) { + return; + } + if ($this->credentialUserEntityRepository->findOneByUsername($userEntity->name) !== null) { + throw InvalidDataException::create($userEntity, 'The username already exists'); + } + $publicKeyCredentialSource = $this->attestationResponseValidator->check( + $response, + $publicKeyCredentialCreationOptions, + $badge->host, + ); + if ($this->publicKeyCredentialSourceRepository->findOneByCredentialId( + $publicKeyCredentialSource->publicKeyCredentialId + ) !== null) { + throw InvalidDataException::create($publicKeyCredentialSource, 'The credentials already exists'); + } + $this->credentialUserEntityRepository->saveUserEntity($userEntity); + $this->publicKeyCredentialSourceRepository->saveCredentialSource($publicKeyCredentialSource); + + $badge->markResolved( + $response, + $publicKeyCredentialCreationOptions, + $userEntity, + $publicKeyCredentialSource, + ); + } +} diff --git a/src/symfony/src/Security/Authentication/WebauthnPassport.php b/src/symfony/src/Security/Authentication/WebauthnPassport.php new file mode 100644 index 000000000..74447a9c3 --- /dev/null +++ b/src/symfony/src/Security/Authentication/WebauthnPassport.php @@ -0,0 +1,36 @@ +addBadge($webauthnBadge); + $this->addBadge(new PreAuthenticatedUserBadge()); + foreach ($badges as $badge) { + $this->addBadge($badge); + } + } + + public function getUser(): UserInterface + { + $webauthnBadge = $this->getBadge(WebauthnBadge::class); + if ($webauthnBadge === null || ! $webauthnBadge instanceof WebauthnBadge) { + throw new LogicException('No WebauthnBadge found in the passport.'); + } + + return $webauthnBadge->getUser(); + } +} diff --git a/src/webauthn/.github/close-pull-request.yml b/src/webauthn/.github/close-pull-request.yml new file mode 100644 index 000000000..ba9500ee4 --- /dev/null +++ b/src/webauthn/.github/close-pull-request.yml @@ -0,0 +1,20 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: | + Thanks for your Pull Request! We love contributions. + + However, you should instead open your PR on the main repository: + https://github.com/web-auth/webauthn-framework + + This repository is what we call a "subtree split": a read-only subset of that main repository. + We're looking forward to your PR there! diff --git a/tests/symfony/config/config.yml b/tests/symfony/config/config.yml index 9c0ebaad0..d05790827 100644 --- a/tests/symfony/config/config.yml +++ b/tests/symfony/config/config.yml @@ -27,9 +27,9 @@ framework: uid: default_uuid_version: 7 time_based_uuid_version: 7 - assets: - base_urls: - - '%kernel.project_dir%/tests/symfony/public' +twig: + file_name_pattern: '*.twig' + default_path: '%kernel.project_dir%/tests/symfony/templates' services: _defaults: @@ -40,6 +40,9 @@ services: Webauthn\Tests\Bundle\Functional\MockClientCallback: ~ + Webauthn\Tests\Bundle\Functional\WebauthnAuthenticator: + autowire: true + Webauthn\Tests\Bundle\Functional\PublicKeyCredentialUserEntityRepository: autowire: true @@ -70,21 +73,9 @@ services: - '%kernel.project_dir%/tests/metadataStatements' - '@serializer' - # fido_alliance_official: - # class: Webauthn\MetadataService\Service\FidoAllianceCompliantMetadataService - # tags: - # - 'webauthn.mds_service' - # arguments: - # - '@Psr\Http\Client\ClientInterface' - # - 'https://fidoalliance.co.nz/blob.jwt' - # - [ ] - # - '@Webauthn\MetadataService\CertificateChain\PhpCertificateChainValidator' - # - 'https://localhost/FidoAllianceRootR3.crt' - Webauthn\MetadataService\Service\ChainedMetadataServices: arguments: - '@mds_single_file_1' - # - '@fido_alliance_official' ### MDS ### Webauthn\Tests\Bundle\Functional\FailureHandler: ~ @@ -140,25 +131,13 @@ webauthn: hide_existing_credentials: true options_path: '/devices/add/options' result_path: '/devices/add' - #host: null - #profile: 'default' user_entity_guesser: 'Webauthn\Bundle\Security\Guesser\CurrentUserEntityGuesser' - #options_storage: 'Webauthn\Tests\Bundle\Functional\CustomSessionStorage' - #success_handler: - #failure_handler: - #option_handler: secured_rp_ids: - 'localhost' request: test: options_path: '/devices/test/options' result_path: '/devices/test' - #host: null - #profile: 'default' - #options_storage: 'Webauthn\Tests\Bundle\Functional\CustomSessionStorage' - #success_handler: - #failure_handler: - #option_handler: secured_rp_ids: - 'localhost' creation_profiles: @@ -173,25 +152,13 @@ webauthn: user_verification: !php/const Webauthn\AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_PREFERRED resident_key: !php/const Webauthn\AuthenticatorSelectionCriteria::RESIDENT_KEY_REQUIREMENT_REQUIRED extensions: [ ] - # public_key_credential_parameters: - # - !php/const Cose\Algorithms::COSE_ALGORITHM_EdDSA #Order is important. Preferred algorithms go first - # - !php/const Cose\Algorithms::COSE_ALGORITHM_ES256 - # - !php/const Cose\Algorithms::COSE_ALGORITHM_ES256K - # - !php/const Cose\Algorithms::COSE_ALGORITHM_ES384 - # - !php/const Cose\Algorithms::COSE_ALGORITHM_ES512 - # - !php/const Cose\Algorithms::COSE_ALGORITHM_RS256 - # - !php/const Cose\Algorithms::COSE_ALGORITHM_RS384 - # - !php/const Cose\Algorithms::COSE_ALGORITHM_RS512 - # - !php/const Cose\Algorithms::COSE_ALGORITHM_PS256 - # - !php/const Cose\Algorithms::COSE_ALGORITHM_PS384 - # - !php/const Cose\Algorithms::COSE_ALGORITHM_PS512 attestation_conveyance: !php/const Webauthn\PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE request_profiles: default: rp_id: 'localhost' challenge_length: 32 user_verification: !php/const Webauthn\AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_PREFERRED - metadata: ## Optional + metadata: enabled: true mds_repository: 'Webauthn\Tests\Bundle\Functional\MetadataStatementRepository' status_report_repository: 'Webauthn\Tests\Bundle\Functional\MetadataStatementRepository' @@ -200,39 +167,32 @@ security: providers: default: id: 'Webauthn\Tests\Bundle\Functional\UserProvider' - firewalls: main: + custom_authenticator: 'Webauthn\Tests\Bundle\Functional\WebauthnAuthenticator' webauthn: - # user_provider: null - #options_storage: 'Webauthn\Tests\Bundle\Functional\CustomSessionStorage' failure_handler: 'Webauthn\Tests\Bundle\Functional\FailureHandler' success_handler: 'Webauthn\Tests\Bundle\Functional\SuccessHandler' registration: enabled: true - # profile: default routes: - # host: null options_path: '/api/register/options' result_path: '/api/register' - # options_handler: DefaultCreationOptionsHandler::class authentication: enabled: true - # profile: default routes: - # host: null options_path: '/api/login/options' result_path: '/api/login' - # options_handler: DefaultRequestOptionsHandler::class logout: path: /logout target: / access_control: - - { path: ^/devices/add, roles: ROLE_USER, requires_channel: https } - - { path: ^/logout, roles: PUBLIC_ACCESS , requires_channel: https } - - { path: ^/api/login, roles: PUBLIC_ACCESS , requires_channel: https } - - { path: ^/api/register, roles: PUBLIC_ACCESS , requires_channel: https } - - { path: ^/admin, roles: 'ROLE_ADMIN', requires_channel: https } - - { path: ^/page, roles: 'ROLE_USER', requires_channel: https } - - { path: ^/, roles: PUBLIC_ACCESS , requires_channel: https } + - { path: '^/devices/add', roles: 'ROLE_USER', requires_channel: 'https' } + - { path: '^/logout', roles: 'PUBLIC_ACCESS' , requires_channel: 'https' } + - { path: '^/api/login', roles: 'PUBLIC_ACCESS' , requires_channel: 'https' } + - { path: '^/api/register', roles: 'PUBLIC_ACCESS' , requires_channel: 'https' } + - { path: '^/admin', roles: 'ROLE_ADMIN', requires_channel: 'https' } + - { path: '^/page', roles: 'ROLE_USER', requires_channel: 'https' } + - { path: '^/login', roles: 'PUBLIC_ACCESS', requires_channel: 'https' } + - { path: '^/', roles: 'PUBLIC_ACCESS' , requires_channel: 'https' } diff --git a/tests/symfony/config/routing.php b/tests/symfony/config/routing.php index 1f7c749a6..a4b8983ce 100644 --- a/tests/symfony/config/routing.php +++ b/tests/symfony/config/routing.php @@ -20,6 +20,11 @@ ->controller([HomeController::class, 'home']) ->methods(['GET']); + // Login + $routes->add('app_login', '/login') + ->controller([HomeController::class, 'login']) + ->methods(['GET', 'POST']); + // Admin $routes->add('app_admin', '/admin') ->controller([AdminController::class, 'admin']) diff --git a/tests/symfony/functional/AdminController.php b/tests/symfony/functional/AdminController.php index a577d9473..ed5801d3e 100644 --- a/tests/symfony/functional/AdminController.php +++ b/tests/symfony/functional/AdminController.php @@ -11,7 +11,7 @@ final readonly class AdminController { public function __construct( - private TokenStorageInterface $tokenStorage + private TokenStorageInterface $tokenStorage, ) { } diff --git a/tests/symfony/functional/AppKernel.php b/tests/symfony/functional/AppKernel.php index b0ab6a2ae..da4f8faf5 100644 --- a/tests/symfony/functional/AppKernel.php +++ b/tests/symfony/functional/AppKernel.php @@ -8,6 +8,7 @@ use Symfony\Bundle\FrameworkBundle\FrameworkBundle; use Symfony\Bundle\MonologBundle\MonologBundle; use Symfony\Bundle\SecurityBundle\SecurityBundle; +use Symfony\Bundle\TwigBundle\TwigBundle; use Symfony\Component\Config\Loader\LoaderInterface; use Symfony\Component\HttpKernel\Kernel; use Webauthn\Bundle\WebauthnBundle; @@ -15,8 +16,9 @@ final class AppKernel extends Kernel { - public function __construct(string $environment) - { + public function __construct( + string $environment, + ) { parent::__construct($environment, true); } @@ -27,6 +29,7 @@ public function registerBundles(): iterable new DoctrineBundle(), new SecurityBundle(), new MonologBundle(), + new TwigBundle(), new WebauthnBundle(), new WebauthnStimulusBundle(), diff --git a/tests/symfony/functional/Assertion/AssertionTest.php b/tests/symfony/functional/Assertion/AssertionTest.php index 89ee83cdb..c97c98d41 100644 --- a/tests/symfony/functional/Assertion/AssertionTest.php +++ b/tests/symfony/functional/Assertion/AssertionTest.php @@ -6,7 +6,6 @@ use ParagonIE\ConstantTime\Base64UrlSafe; use PHPUnit\Framework\Attributes\Test; -use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Symfony\Component\Serializer\SerializerInterface; use Webauthn\AuthenticationExtensions\AuthenticationExtensions; use Webauthn\AuthenticatorAssertionResponse; @@ -16,12 +15,13 @@ use Webauthn\PublicKeyCredential; use Webauthn\PublicKeyCredentialDescriptor; use Webauthn\PublicKeyCredentialRequestOptions; +use Webauthn\Tests\Bundle\Functional\WebauthnTestCase; use Webauthn\Tests\MockedRequestTrait; /** * @internal */ -final class AssertionTest extends WebTestCase +final class AssertionTest extends WebauthnTestCase { use MockedRequestTrait; diff --git a/tests/symfony/functional/Attestation/AdditionalAuthenticatorTest.php b/tests/symfony/functional/Attestation/AdditionalAuthenticatorTest.php index 76b12ab5a..4907532f2 100644 --- a/tests/symfony/functional/Attestation/AdditionalAuthenticatorTest.php +++ b/tests/symfony/functional/Attestation/AdditionalAuthenticatorTest.php @@ -9,7 +9,6 @@ use PHPUnit\Framework\Attributes\Depends; use PHPUnit\Framework\Attributes\Test; use Symfony\Bundle\FrameworkBundle\KernelBrowser; -use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Symfony\Component\BrowserKit\Cookie; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Serializer\SerializerInterface; @@ -24,6 +23,7 @@ use Webauthn\Tests\Bundle\Functional\CustomSessionStorage; use Webauthn\Tests\Bundle\Functional\PublicKeyCredentialSourceRepository; use Webauthn\Tests\Bundle\Functional\User; +use Webauthn\Tests\Bundle\Functional\WebauthnTestCase; use function assert; use function base64_decode; use function count; @@ -32,7 +32,7 @@ /** * @internal */ -final class AdditionalAuthenticatorTest extends WebTestCase +final class AdditionalAuthenticatorTest extends WebauthnTestCase { #[Test] public function anExistingUserCanAskForOptionsUsingTheDedicatedController(): void diff --git a/tests/symfony/functional/Firewall/AllowedOriginsTest.php b/tests/symfony/functional/Firewall/AllowedOriginsTest.php index 6619fe008..96e0055a5 100644 --- a/tests/symfony/functional/Firewall/AllowedOriginsTest.php +++ b/tests/symfony/functional/Firewall/AllowedOriginsTest.php @@ -5,13 +5,13 @@ namespace Webauthn\Tests\Bundle\Functional\Firewall; use PHPUnit\Framework\Attributes\Test; -use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Symfony\Component\HttpFoundation\Request; +use Webauthn\Tests\Bundle\Functional\WebauthnTestCase; /** * @internal */ -final class AllowedOriginsTest extends WebTestCase +final class AllowedOriginsTest extends WebauthnTestCase { #[Test] public function allowedOriginsAreAvailable(): void diff --git a/tests/symfony/functional/Firewall/LegacySecuredAreaTest.php b/tests/symfony/functional/Firewall/LegacySecuredAreaTest.php new file mode 100644 index 000000000..4b774deff --- /dev/null +++ b/tests/symfony/functional/Firewall/LegacySecuredAreaTest.php @@ -0,0 +1,131 @@ + 'on', + ]); + $client->request(Request::METHOD_GET, '/admin', [], [], [ + 'HTTPS' => 'on', + ]); + + static::assertResponseRedirects('/login'); + } + + #[Test] + public function aClientCanSubmitUsernameToGetWebauthnOptions(): void + { + $body = [ + 'username' => 'admin', + ]; + $client = static::createClient([], [ + 'HTTPS' => 'on', + ]); + $client->request(Request::METHOD_POST, '/api/login/options', [], [], [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_HOST' => 'test.com', + 'HTTPS' => 'on', + ], json_encode($body, JSON_THROW_ON_ERROR)); + + static::assertResponseIsSuccessful(); + $json = json_decode($client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR); + static::assertArrayHasKey('challenge', $json); + static::assertArrayHasKey('rpId', $json); + static::assertArrayHasKey('userVerification', $json); + static::assertArrayHasKey('allowCredentials', $json); + static::assertArrayNotHasKey('timeout', $json); + } + + #[Test] + public function aUserCannotBeBeAuthenticatedInAbsenceOfOptions(): void + { + $assertion = '{"id":"eHouz_Zi7-BmByHjJ_tx9h4a1WZsK4IzUmgGjkhyOodPGAyUqUp_B9yUkflXY3yHWsNtsrgCXQ3HjAIFUeZB-w","type":"public-key","rawId":"eHouz/Zi7+BmByHjJ/tx9h4a1WZsK4IzUmgGjkhyOodPGAyUqUp/B9yUkflXY3yHWsNtsrgCXQ3HjAIFUeZB+w==","response":{"authenticatorData":"SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MBAAAAew","clientDataJSON":"eyJjaGFsbGVuZ2UiOiJHMEpiTExuZGVmM2EwSXkzUzJzU1FBOHVPNFNPX3plNkZaTUF1UEk2LXhJIiwiY2xpZW50RXh0ZW5zaW9ucyI6e30sImhhc2hBbGdvcml0aG0iOiJTSEEtMjU2Iiwib3JpZ2luIjoiaHR0cHM6Ly9sb2NhbGhvc3Q6ODQ0MyIsInR5cGUiOiJ3ZWJhdXRobi5nZXQifQ","signature":"MEUCIEY/vcNkbo/LdMTfLa24ZYLlMMVMRd8zXguHBvqud9AJAiEAwCwpZpvcMaqCrwv85w/8RGiZzE+gOM61ffxmgEDeyhM=","userHandle":null}}'; + + $client = static::createClient([], [ + 'HTTPS' => 'on', + ]); + $client->request(Request::METHOD_POST, '/api/login', [], [], [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_HOST' => 'test.com', + ], $assertion); + + self::assertResponseStatusCodeSame(Response::HTTP_UNAUTHORIZED); + static::assertSame( + '{"status":"error","errorMessage":"No public key credential options available for this session."}', + $client->getResponse() + ->getContent() + ); + } + + #[Test] + public function aUserCanBeAuthenticatedAndAccessToTheProtectedResource(): void + { + $client = static::createClient([], [ + 'HTTPS' => 'on', + ]); + $publicKeyCredentialRequestOptions = PublicKeyCredentialRequestOptions::create( + base64_decode('G0JbLLndef3a0Iy3S2sSQA8uO4SO/ze6FZMAuPI6+xI=', true) + ); + $publicKeyCredentialRequestOptions->timeout = 60000; + $publicKeyCredentialRequestOptions->rpId = 'localhost'; + $publicKeyCredentialRequestOptions->userVerification = PublicKeyCredentialRequestOptions::USER_VERIFICATION_REQUIREMENT_PREFERRED; + $publicKeyCredentialRequestOptions->allowCredentials = [ + PublicKeyCredentialDescriptor::create( + PublicKeyCredentialDescriptor::CREDENTIAL_TYPE_PUBLIC_KEY, + Base64UrlSafe::decode( + 'eHouz_Zi7-BmByHjJ_tx9h4a1WZsK4IzUmgGjkhyOodPGAyUqUp_B9yUkflXY3yHWsNtsrgCXQ3HjAIFUeZB-w' + ) + ), + ]; + + $storage = static::getContainer()->get(CustomSessionStorage::class); + $storage->store(Item::create( + $publicKeyCredentialRequestOptions, + PublicKeyCredentialUserEntity::create('admin', 'foo', 'Foo BAR (-_-)') + )); + + $assertion = '{"id":"eHouz_Zi7-BmByHjJ_tx9h4a1WZsK4IzUmgGjkhyOodPGAyUqUp_B9yUkflXY3yHWsNtsrgCXQ3HjAIFUeZB-w","type":"public-key","rawId":"eHouz/Zi7+BmByHjJ/tx9h4a1WZsK4IzUmgGjkhyOodPGAyUqUp/B9yUkflXY3yHWsNtsrgCXQ3HjAIFUeZB+w==","response":{"authenticatorData":"SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MBAAAAew","clientDataJSON":"eyJjaGFsbGVuZ2UiOiJHMEpiTExuZGVmM2EwSXkzUzJzU1FBOHVPNFNPX3plNkZaTUF1UEk2LXhJIiwiY2xpZW50RXh0ZW5zaW9ucyI6e30sImhhc2hBbGdvcml0aG0iOiJTSEEtMjU2Iiwib3JpZ2luIjoiaHR0cHM6Ly9sb2NhbGhvc3Q6ODQ0MyIsInR5cGUiOiJ3ZWJhdXRobi5nZXQifQ","signature":"MEUCIEY/vcNkbo/LdMTfLa24ZYLlMMVMRd8zXguHBvqud9AJAiEAwCwpZpvcMaqCrwv85w/8RGiZzE+gOM61ffxmgEDeyhM=","userHandle":null}}'; + + $client->request(Request::METHOD_POST, '/api/login', [], [], [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_HOST' => 'localhost', + ], $assertion); + + static::assertResponseIsSuccessful(); + static::assertSame( + '{"status":"ok","errorMessage":"","userIdentifier":"admin"}', + $client->getResponse() + ->getContent() + ); + static::assertTrue($client->getRequest()->getSession()->has('_security_main')); + + $client->request(Request::METHOD_GET, '/admin'); + + static::assertSame('["Hello admin"]', $client->getResponse()->getContent()); + static::assertResponseIsSuccessful(); + } +} diff --git a/tests/symfony/functional/Firewall/NonSecuredAreaTest.php b/tests/symfony/functional/Firewall/NonSecuredAreaTest.php index 70e477b25..5b2440f4c 100644 --- a/tests/symfony/functional/Firewall/NonSecuredAreaTest.php +++ b/tests/symfony/functional/Firewall/NonSecuredAreaTest.php @@ -5,13 +5,13 @@ namespace Webauthn\Tests\Bundle\Functional\Firewall; use PHPUnit\Framework\Attributes\Test; -use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Symfony\Component\HttpFoundation\Request; +use Webauthn\Tests\Bundle\Functional\WebauthnTestCase; /** * @internal */ -final class NonSecuredAreaTest extends WebTestCase +final class NonSecuredAreaTest extends WebauthnTestCase { #[Test] public function aClientWantsToAccessOnNonSecuredResource(): void diff --git a/tests/symfony/functional/Firewall/RegistrationAreaTest.php b/tests/symfony/functional/Firewall/RegistrationAreaTest.php index acc630b25..0196d26a7 100644 --- a/tests/symfony/functional/Firewall/RegistrationAreaTest.php +++ b/tests/symfony/functional/Firewall/RegistrationAreaTest.php @@ -6,7 +6,6 @@ use Cose\Algorithms; use PHPUnit\Framework\Attributes\Test; -use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Symfony\Component\HttpFoundation\Request; use Webauthn\Bundle\Security\Storage\Item; use Webauthn\PublicKeyCredentialCreationOptions; @@ -17,6 +16,7 @@ use Webauthn\Tests\Bundle\Functional\PublicKeyCredentialSourceRepository; use Webauthn\Tests\Bundle\Functional\PublicKeyCredentialUserEntityRepository; use Webauthn\Tests\Bundle\Functional\User; +use Webauthn\Tests\Bundle\Functional\WebauthnTestCase; use function base64_decode; use function json_decode; use function json_encode; @@ -25,7 +25,7 @@ /** * @internal */ -final class RegistrationAreaTest extends WebTestCase +final class RegistrationAreaTest extends WebauthnTestCase { #[Test] public function aRequestWithoutUsernameCanBeProcessed(): void diff --git a/tests/symfony/functional/Firewall/SecuredAreaTest.php b/tests/symfony/functional/Firewall/SecuredAreaTest.php index 1e2a5ccf5..88fb5dec5 100644 --- a/tests/symfony/functional/Firewall/SecuredAreaTest.php +++ b/tests/symfony/functional/Firewall/SecuredAreaTest.php @@ -4,89 +4,73 @@ namespace Webauthn\Tests\Bundle\Functional\Firewall; -use Ergebnis\PHPUnit\SlowTestDetector\Attribute\MaximumDuration; use ParagonIE\ConstantTime\Base64UrlSafe; use PHPUnit\Framework\Attributes\Test; -use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Serializer\SerializerInterface; +use Webauthn\Bundle\Repository\PublicKeyCredentialSourceRepositoryInterface; use Webauthn\Bundle\Security\Storage\Item; +use Webauthn\PublicKeyCredentialCreationOptions; use Webauthn\PublicKeyCredentialDescriptor; use Webauthn\PublicKeyCredentialRequestOptions; use Webauthn\PublicKeyCredentialUserEntity; use Webauthn\Tests\Bundle\Functional\CustomSessionStorage; -use const JSON_THROW_ON_ERROR; +use Webauthn\Tests\Bundle\Functional\PublicKeyCredentialUserEntityRepository; +use Webauthn\Tests\Bundle\Functional\WebauthnTestCase; /** * @internal */ -final class SecuredAreaTest extends WebTestCase +final class SecuredAreaTest extends WebauthnTestCase { #[Test] - #[MaximumDuration(600)] - public function aClientCannotAccessToTheResourceIfUserIsNotAuthenticated(): void + public function aClientIsRedirectedIfUserIsNotAuthenticated(): void { + //Given $client = static::createClient([], [ 'HTTPS' => 'on', ]); - $client->request(Request::METHOD_GET, '/admin', [], [], [ - 'HTTPS' => 'on', - ]); - - static::assertResponseStatusCodeSame(Response::HTTP_UNAUTHORIZED); - } - #[Test] - public function aClientCanSubmitUsernameToGetWebauthnOptions(): void - { - $body = [ - 'username' => 'admin', - ]; - $client = static::createClient([], [ - 'HTTPS' => 'on', - ]); - $client->request(Request::METHOD_POST, '/api/login/options', [], [], [ - 'CONTENT_TYPE' => 'application/json', - 'HTTP_HOST' => 'test.com', - 'HTTPS' => 'on', - ], json_encode($body, JSON_THROW_ON_ERROR)); + //When + $client->request(Request::METHOD_GET, '/admin'); - static::assertResponseIsSuccessful(); - $json = json_decode($client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR); - static::assertArrayHasKey('challenge', $json); - static::assertArrayHasKey('rpId', $json); - static::assertArrayHasKey('userVerification', $json); - static::assertArrayHasKey('allowCredentials', $json); - static::assertArrayNotHasKey('timeout', $json); + //Then + static::assertResponseRedirects('/login'); } #[Test] - public function aUserCannotBeBeAuthenticatedInAbsenceOfOptions(): void + public function aUserCannotBeAuthenticatedInAbsenceOfOptions(): void { + //Given $assertion = '{"id":"eHouz_Zi7-BmByHjJ_tx9h4a1WZsK4IzUmgGjkhyOodPGAyUqUp_B9yUkflXY3yHWsNtsrgCXQ3HjAIFUeZB-w","type":"public-key","rawId":"eHouz/Zi7+BmByHjJ/tx9h4a1WZsK4IzUmgGjkhyOodPGAyUqUp/B9yUkflXY3yHWsNtsrgCXQ3HjAIFUeZB+w==","response":{"authenticatorData":"SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MBAAAAew","clientDataJSON":"eyJjaGFsbGVuZ2UiOiJHMEpiTExuZGVmM2EwSXkzUzJzU1FBOHVPNFNPX3plNkZaTUF1UEk2LXhJIiwiY2xpZW50RXh0ZW5zaW9ucyI6e30sImhhc2hBbGdvcml0aG0iOiJTSEEtMjU2Iiwib3JpZ2luIjoiaHR0cHM6Ly9sb2NhbGhvc3Q6ODQ0MyIsInR5cGUiOiJ3ZWJhdXRobi5nZXQifQ","signature":"MEUCIEY/vcNkbo/LdMTfLa24ZYLlMMVMRd8zXguHBvqud9AJAiEAwCwpZpvcMaqCrwv85w/8RGiZzE+gOM61ffxmgEDeyhM=","userHandle":null}}'; $client = static::createClient([], [ 'HTTPS' => 'on', ]); - $client->request(Request::METHOD_POST, '/api/login', [], [], [ - 'CONTENT_TYPE' => 'application/json', - 'HTTP_HOST' => 'test.com', - ], $assertion); - - self::assertResponseStatusCodeSame(Response::HTTP_UNAUTHORIZED); - static::assertSame( - '{"status":"error","errorMessage":"No public key credential options available for this session."}', - $client->getResponse() - ->getContent() - ); + $client->disableReboot(); + $crawler = $client->request(Request::METHOD_GET, '/login'); + + //When + $form = $crawler->selectButton('login') + ->form(); + $client->submit($form, [ + '_assertion' => $assertion, + ]); + + //Then + static::assertResponseRedirects('/login'); + //@todo: verify the reason is: No public key credential options available for this session. } #[Test] public function aUserCanBeAuthenticatedAndAccessToTheProtectedResource(): void { + //Given $client = static::createClient([], [ 'HTTPS' => 'on', ]); + $client->disableReboot(); + $publicKeyCredentialRequestOptions = PublicKeyCredentialRequestOptions::create( base64_decode('G0JbLLndef3a0Iy3S2sSQA8uO4SO/ze6FZMAuPI6+xI=', true) ); @@ -110,22 +94,114 @@ public function aUserCanBeAuthenticatedAndAccessToTheProtectedResource(): void $assertion = '{"id":"eHouz_Zi7-BmByHjJ_tx9h4a1WZsK4IzUmgGjkhyOodPGAyUqUp_B9yUkflXY3yHWsNtsrgCXQ3HjAIFUeZB-w","type":"public-key","rawId":"eHouz/Zi7+BmByHjJ/tx9h4a1WZsK4IzUmgGjkhyOodPGAyUqUp/B9yUkflXY3yHWsNtsrgCXQ3HjAIFUeZB+w==","response":{"authenticatorData":"SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MBAAAAew","clientDataJSON":"eyJjaGFsbGVuZ2UiOiJHMEpiTExuZGVmM2EwSXkzUzJzU1FBOHVPNFNPX3plNkZaTUF1UEk2LXhJIiwiY2xpZW50RXh0ZW5zaW9ucyI6e30sImhhc2hBbGdvcml0aG0iOiJTSEEtMjU2Iiwib3JpZ2luIjoiaHR0cHM6Ly9sb2NhbGhvc3Q6ODQ0MyIsInR5cGUiOiJ3ZWJhdXRobi5nZXQifQ","signature":"MEUCIEY/vcNkbo/LdMTfLa24ZYLlMMVMRd8zXguHBvqud9AJAiEAwCwpZpvcMaqCrwv85w/8RGiZzE+gOM61ffxmgEDeyhM=","userHandle":null}}'; - $client->request(Request::METHOD_POST, '/api/login', [], [], [ - 'CONTENT_TYPE' => 'application/json', - 'HTTP_HOST' => 'localhost', - ], $assertion); + $crawler = $client->request(Request::METHOD_GET, '/login'); + + //When + $form = $crawler->selectButton('login') + ->form(); + $client->submit($form, [ + '_assertion' => $assertion, + ]); + //Then static::assertResponseIsSuccessful(); - static::assertSame( - '{"status":"ok","errorMessage":"","userIdentifier":"admin"}', - $client->getResponse() - ->getContent() - ); + static::assertSame('{"success":true}', $client->getResponse()->getContent()); static::assertTrue($client->getRequest()->getSession()->has('_security_main')); + //And then $client->request(Request::METHOD_GET, '/admin'); - static::assertSame('["Hello admin"]', $client->getResponse()->getContent()); static::assertResponseIsSuccessful(); } + + #[Test] + public function aUserCannotBeRegisteredAsTheUserAlreadyExists(): void + { + //Given + $client = static::createClient([], [ + 'HTTPS' => 'on', + ]); + $client->disableReboot(); + /** @var SerializerInterface $serializer */ + $serializer = static::getContainer()->get(SerializerInterface::class); + + $options = '{"status":"ok","errorMessage":"","rp":{"name":"Webauthn Demo","id":"webauthn.spomky-labs.com"},"pubKeyCredParams":[{"type":"public-key","alg":-8},{"type":"public-key","alg":-7},{"type":"public-key","alg":-43},{"type":"public-key","alg":-35},{"type":"public-key","alg":-36},{"type":"public-key","alg":-257},{"type":"public-key","alg":-258},{"type":"public-key","alg":-259},{"type":"public-key","alg":-37},{"type":"public-key","alg":-38},{"type":"public-key","alg":-39}],"challenge":"EhNVt3T8V12FJvSAc50nhKnZ-MEc-kf84xepDcGyN1g","attestation":"direct","user":{"name":"XY5nn3p_6olTLjoB2Jbb","id":"OTI5ZmJhMmYtMjM2MS00YmM2LWE5MTctYmI3NmFhMTRjN2Y5","displayName":"Bennie Moneypenny"},"authenticatorSelection":{"userVerification":"preferred"},"timeout":60000}'; + $publicKeyCredentialCreationOptions = $serializer->deserialize( + $options, + PublicKeyCredentialCreationOptions::class, + 'json' + ); + + $storage = static::getContainer()->get(CustomSessionStorage::class); + $storage->store(Item::create( + $publicKeyCredentialCreationOptions, + PublicKeyCredentialUserEntity::create('admin', 'foo', 'Foo BAR (-_-)') + )); + + $assertion = '{"id":"WT7a99M1zA3XUBBvEwXqPzP0C3zNoS_SpmMpv2sG2YM","rawId":"WT7a99M1zA3XUBBvEwXqPzP0C3zNoS/SpmMpv2sG2YM","response":{"attestationObject":"o2NmbXRmcGFja2VkZ2F0dFN0bXSiY2FsZydjc2lnWECRl1RciDxSF7hkhJbqVJeryUIFrX7r6QQMQq8bIP4wYRA6f96iOO4wiOo34l65kZ5v1erxSmIaH56VySUSMusEaGF1dGhEYXRhWIGWBOqCgk6YpK2hS0Ri0Nc6jsRpEw2pGxkwdFkin3SjWUEAAAAykd_q15WeRHWtJpsNSCvgiQAgWT7a99M1zA3XUBBvEwXqPzP0C3zNoS_SpmMpv2sG2YOkAQEDJyAGIVgg4smTlXUJnAP_RqNWNv2Eqkh8I7ZDS0IuSgotbPygd9k","clientDataJSON":"eyJvcmlnaW4iOiJodHRwczovL3dlYmF1dGhuLnNwb21reS1sYWJzLmNvbSIsImNoYWxsZW5nZSI6IkVoTlZ0M1Q4VjEyRkp2U0FjNTBuaEtuWi1NRWMta2Y4NHhlcERjR3lOMWciLCJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIn0"},"type":"public-key"}'; + + $crawler = $client->request(Request::METHOD_GET, '/login'); + + //When + $form = $crawler->selectButton('login') + ->form(); + $client->submit($form, [ + '_assertion' => $assertion, + ]); + + //Then + static::assertResponseRedirects('/login'); + } + + #[Test] + public function aUserCanBeRegistered(): void + { + //Given + $client = static::createClient([], [ + 'HTTPS' => 'on', + ]); + $client->disableReboot(); + $userEntityRepository = static::getContainer()->get(PublicKeyCredentialUserEntityRepository::class); + $userEntityRepository->ensureUserDoesNotExist('john'); + $credentialRepository = static::getContainer()->get(PublicKeyCredentialSourceRepositoryInterface::class); + $credentialRepository->ensureCredentialNotExist( + base64_decode('WT7a99M1zA3XUBBvEwXqPzP0C3zNoS/SpmMpv2sG2YM=', true) + ); + + /** @var SerializerInterface $serializer */ + $serializer = static::getContainer()->get(SerializerInterface::class); + + $options = '{"status":"ok","errorMessage":"","rp":{"name":"Webauthn Demo","id":"webauthn.spomky-labs.com"},"pubKeyCredParams":[{"type":"public-key","alg":-8},{"type":"public-key","alg":-7},{"type":"public-key","alg":-43},{"type":"public-key","alg":-35},{"type":"public-key","alg":-36},{"type":"public-key","alg":-257},{"type":"public-key","alg":-258},{"type":"public-key","alg":-259},{"type":"public-key","alg":-37},{"type":"public-key","alg":-38},{"type":"public-key","alg":-39}],"challenge":"EhNVt3T8V12FJvSAc50nhKnZ-MEc-kf84xepDcGyN1g","attestation":"direct","user":{"name":"XY5nn3p_6olTLjoB2Jbb","id":"OTI5ZmJhMmYtMjM2MS00YmM2LWE5MTctYmI3NmFhMTRjN2Y5","displayName":"Bennie Moneypenny"},"authenticatorSelection":{"userVerification":"preferred"},"timeout":60000}'; + $publicKeyCredentialCreationOptions = $serializer->deserialize( + $options, + PublicKeyCredentialCreationOptions::class, + 'json' + ); + + $storage = static::getContainer()->get(CustomSessionStorage::class); + $storage->store(Item::create( + $publicKeyCredentialCreationOptions, + PublicKeyCredentialUserEntity::create('john', 'doe', 'Foo BAR (-_-)') + )); + + $assertion = '{"id":"WT7a99M1zA3XUBBvEwXqPzP0C3zNoS_SpmMpv2sG2YM","rawId":"WT7a99M1zA3XUBBvEwXqPzP0C3zNoS/SpmMpv2sG2YM","response":{"attestationObject":"o2NmbXRmcGFja2VkZ2F0dFN0bXSiY2FsZydjc2lnWECRl1RciDxSF7hkhJbqVJeryUIFrX7r6QQMQq8bIP4wYRA6f96iOO4wiOo34l65kZ5v1erxSmIaH56VySUSMusEaGF1dGhEYXRhWIGWBOqCgk6YpK2hS0Ri0Nc6jsRpEw2pGxkwdFkin3SjWUEAAAAykd_q15WeRHWtJpsNSCvgiQAgWT7a99M1zA3XUBBvEwXqPzP0C3zNoS_SpmMpv2sG2YOkAQEDJyAGIVgg4smTlXUJnAP_RqNWNv2Eqkh8I7ZDS0IuSgotbPygd9k","clientDataJSON":"eyJvcmlnaW4iOiJodHRwczovL3dlYmF1dGhuLnNwb21reS1sYWJzLmNvbSIsImNoYWxsZW5nZSI6IkVoTlZ0M1Q4VjEyRkp2U0FjNTBuaEtuWi1NRWMta2Y4NHhlcERjR3lOMWciLCJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIn0"},"type":"public-key"}'; + + $crawler = $client->request(Request::METHOD_GET, '/login'); + + //When + $form = $crawler->selectButton('login') + ->form(); + $client->submit($form, [ + '_assertion' => $assertion, + ]); + + //Then + static::assertResponseIsSuccessful(); + static::assertSame('{"success":true}', $client->getResponse()->getContent()); + static::assertTrue($client->getRequest()->getSession()->has('_security_main')); + + //And then + $client->request(Request::METHOD_GET, '/admin'); + static::assertResponseStatusCodeSame(403); + } } diff --git a/tests/symfony/functional/HomeController.php b/tests/symfony/functional/HomeController.php index 766ff2dfd..300b47bc2 100644 --- a/tests/symfony/functional/HomeController.php +++ b/tests/symfony/functional/HomeController.php @@ -5,11 +5,24 @@ namespace Webauthn\Tests\Bundle\Functional; use Symfony\Component\HttpFoundation\Response; +use Twig\Environment; -final class HomeController +final readonly class HomeController { + public function __construct( + private Environment $twig, + ) { + } + public function home(): Response { return new Response('Home'); } + + public function login(): Response + { + $page = $this->twig->render('login.html.twig'); + + return new Response($page); + } } diff --git a/tests/symfony/functional/PublicKeyCredentialSourceRepository.php b/tests/symfony/functional/PublicKeyCredentialSourceRepository.php index d3b9270b4..e25e76f51 100644 --- a/tests/symfony/functional/PublicKeyCredentialSourceRepository.php +++ b/tests/symfony/functional/PublicKeyCredentialSourceRepository.php @@ -58,6 +58,11 @@ public function __construct( $this->saveCredentialSource($publicKeyCredentialSource2); } + public function ensureCredentialNotExist(string $publicKeyCredentialId): void + { + $this->cacheItemPool->deleteItem('pks-' . Base64UrlSafe::encodeUnpadded($publicKeyCredentialId)); + } + public function findOneByCredentialId(string $publicKeyCredentialId): ?PublicKeyCredentialSource { $item = $this->cacheItemPool->getItem('pks-' . Base64UrlSafe::encodeUnpadded($publicKeyCredentialId)); diff --git a/tests/symfony/functional/PublicKeyCredentialUserEntityRepository.php b/tests/symfony/functional/PublicKeyCredentialUserEntityRepository.php index 6f46eb7e6..4b9636b54 100644 --- a/tests/symfony/functional/PublicKeyCredentialUserEntityRepository.php +++ b/tests/symfony/functional/PublicKeyCredentialUserEntityRepository.php @@ -27,6 +27,11 @@ public function __construct( )); } + public function ensureUserDoesNotExist(string $username): void + { + $this->cacheItemPool->deleteItem('user-name' . Base64UrlSafe::encodeUnpadded($username)); + } + public function findOneByUsername(string $username): ?PublicKeyCredentialUserEntity { $item = $this->cacheItemPool->getItem('user-name' . Base64UrlSafe::encodeUnpadded($username)); diff --git a/tests/symfony/functional/SecurityController.php b/tests/symfony/functional/SecurityController.php index 84eb4ebe0..d31892d21 100644 --- a/tests/symfony/functional/SecurityController.php +++ b/tests/symfony/functional/SecurityController.php @@ -6,7 +6,7 @@ use Symfony\Component\HttpFoundation\Response; -final class SecurityController +final readonly class SecurityController { /** * Intercepted by the security listener. diff --git a/tests/symfony/functional/WebauthnAuthenticator.php b/tests/symfony/functional/WebauthnAuthenticator.php new file mode 100644 index 000000000..f7473cd02 --- /dev/null +++ b/tests/symfony/functional/WebauthnAuthenticator.php @@ -0,0 +1,42 @@ +getHost(), $request->request->get('_assertion', ''), allowRegistration: true) + ); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + return new JsonResponse([ + 'success' => true, + ]); + } + + protected function getLoginUrl(Request $request): string + { + return $this->urlGenerator->generate('app_login'); + } +} diff --git a/tests/symfony/functional/WebauthnTestCase.php b/tests/symfony/functional/WebauthnTestCase.php new file mode 100644 index 000000000..0514dc0bd --- /dev/null +++ b/tests/symfony/functional/WebauthnTestCase.php @@ -0,0 +1,11 @@ + + + + + + + {% block title %}{% endblock %} + + + {% block body %}{% endblock %} + + diff --git a/tests/symfony/templates/login.html.twig b/tests/symfony/templates/login.html.twig new file mode 100644 index 000000000..8ca9dcb1a --- /dev/null +++ b/tests/symfony/templates/login.html.twig @@ -0,0 +1,12 @@ +{% extends 'base.html.twig' %} + +{% block body %} + {% if error is defined %} +
{{ error.messageKey|trans(error.messageData, 'security') }}
+ {% endif %} + +
+ + +
+{% endblock %}