From 0241f62bbea991c5283c06a27383e0e5900fd9b7 Mon Sep 17 00:00:00 2001 From: Florent Morselli Date: Wed, 17 Apr 2019 15:30:23 +0200 Subject: [PATCH] Authentication handlers (#51) Handlers and request options storage added --- phpstan.neon | 1 + .../src/Resources/config/security.php | 31 +++++- .../EntryPoint/WebauthnEntryPoint.php | 21 +++-- .../Factory/WebauthnSecurityFactory.php | 22 ++++- .../Security/Firewall/WebauthnListener.php | 94 +++++++++++-------- .../Handler/DefaultFailureHandler.php | 33 +++++++ .../Handler/DefaultRequestOptionsHandler.php | 27 ++++++ .../Handler/DefaultSuccessHandler.php | 32 +++++++ .../Handler/RequestOptionsHandler.php | 23 +++++ .../Storage/RequestOptionsStorage.php | 23 +++++ .../src/Security/Storage/SessionStorage.php | 59 ++++++++++++ .../src/Security/Storage/StoredData.php | 46 +++++++++ .../tests/config/config.yml | 6 ++ .../tests/functional/CustomSessionStorage.php | 61 ++++++++++++ .../tests/functional/FailureHandler.php | 34 +++++++ ...licKeyCredentialFakeUserEntityProvider.php | 4 +- .../tests/functional/SecuredAreaTest.php | 14 +-- .../tests/functional/SuccessHandler.php | 33 +++++++ 18 files changed, 503 insertions(+), 61 deletions(-) create mode 100644 src/symfony-json-security/src/Security/Handler/DefaultFailureHandler.php create mode 100644 src/symfony-json-security/src/Security/Handler/DefaultRequestOptionsHandler.php create mode 100644 src/symfony-json-security/src/Security/Handler/DefaultSuccessHandler.php create mode 100644 src/symfony-json-security/src/Security/Handler/RequestOptionsHandler.php create mode 100644 src/symfony-json-security/src/Security/Storage/RequestOptionsStorage.php create mode 100644 src/symfony-json-security/src/Security/Storage/SessionStorage.php create mode 100644 src/symfony-json-security/src/Security/Storage/StoredData.php create mode 100644 src/symfony-json-security/tests/functional/CustomSessionStorage.php create mode 100644 src/symfony-json-security/tests/functional/FailureHandler.php create mode 100644 src/symfony-json-security/tests/functional/SuccessHandler.php diff --git a/phpstan.neon b/phpstan.neon index bbf9a25c..a7f08fec 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -17,6 +17,7 @@ parameters: - '#Fetching class constant class of deprecated class Webauthn\\CredentialRepository\.#' - '#Interface Webauthn\\PublicKeyCredentialSourceRepository extends deprecated interface Webauthn\\CredentialRepository\.#' - '#Parameter (.*) of class Webauthn\\PublicKeyCredentialSource constructor expects string, string|null given\.#' + - '#Instanceof between Symfony\\Component\\HttpFoundation\\Response and Symfony\\Component\\HttpFoundation\\Response will always evaluate to true\.#' includes: - vendor/phpstan/phpstan-strict-rules/rules.neon - vendor/phpstan/phpstan-phpunit/extension.neon diff --git a/src/symfony-json-security/src/Resources/config/security.php b/src/symfony-json-security/src/Resources/config/security.php index 54adcc0b..8569ce94 100644 --- a/src/symfony-json-security/src/Resources/config/security.php +++ b/src/symfony-json-security/src/Resources/config/security.php @@ -28,6 +28,10 @@ use Webauthn\JsonSecurityBundle\Security\Authentication\Provider\WebauthnProvider; use Webauthn\JsonSecurityBundle\Security\EntryPoint\WebauthnEntryPoint; use Webauthn\JsonSecurityBundle\Security\Firewall\WebauthnListener; +use Webauthn\JsonSecurityBundle\Security\Handler\DefaultFailureHandler; +use Webauthn\JsonSecurityBundle\Security\Handler\DefaultRequestOptionsHandler; +use Webauthn\JsonSecurityBundle\Security\Handler\DefaultSuccessHandler; +use Webauthn\JsonSecurityBundle\Security\Storage\SessionStorage; use Webauthn\JsonSecurityBundle\Security\Voter\IsUserPresentVoter; use Webauthn\JsonSecurityBundle\Security\Voter\IsUserVerifiedVoter; use Webauthn\PublicKeyCredentialLoader; @@ -57,8 +61,12 @@ ref(SessionAuthenticationStrategyInterface::class), ref(HttpUtils::class), ref('webauthn_json_security.fake_user_entity_provider')->nullOnInvalid(), - '', - [], + '', // Provider key + [], // Options + null, // Authentication success handler + null, // Authentication failure handler + null, // Request Options handler + null, // Request Options Storage ref(LoggerInterface::class)->nullOnInvalid(), ref(EventDispatcherInterface::class)->nullOnInvalid(), ]) @@ -68,6 +76,9 @@ $container->services()->set(WebauthnEntryPoint::class) ->abstract() ->private() + ->args([ + null, // Authentication failure handler + ]) ; $container->services()->set(IsUserPresentVoter::class) @@ -79,4 +90,20 @@ ->private() ->tag('security.voter') ; + + $container->services()->set(DefaultSuccessHandler::class) + ->private() + ; + + $container->services()->set(DefaultFailureHandler::class) + ->private() + ; + + $container->services()->set(SessionStorage::class) + ->private() + ; + + $container->services()->set(DefaultRequestOptionsHandler::class) + ->private() + ; }; diff --git a/src/symfony-json-security/src/Security/EntryPoint/WebauthnEntryPoint.php b/src/symfony-json-security/src/Security/EntryPoint/WebauthnEntryPoint.php index f1556086..6cb01315 100644 --- a/src/symfony-json-security/src/Security/EntryPoint/WebauthnEntryPoint.php +++ b/src/symfony-json-security/src/Security/EntryPoint/WebauthnEntryPoint.php @@ -13,24 +13,31 @@ namespace Webauthn\JsonSecurityBundle\Security\EntryPoint; -use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Security\Core\Exception\AuthenticationException; +use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface; use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface; final class WebauthnEntryPoint implements AuthenticationEntryPointInterface { + /** + * @var AuthenticationFailureHandlerInterface + */ + private $failureHandler; + + public function __construct(AuthenticationFailureHandlerInterface $failureHandler) + { + $this->failureHandler = $failureHandler; + } + /** * {@inheritdoc} */ - public function start(Request $request, AuthenticationException $authException = null) + public function start(Request $request, AuthenticationException $authException = null): Response { - $data = [ - 'status' => 'error', - 'errorMessage' => 'Authentication Required', - ]; + $exception = $authException ?? new AuthenticationException('Authentication Required'); - return new JsonResponse($data, Response::HTTP_UNAUTHORIZED); + return $this->failureHandler->onAuthenticationFailure($request, $exception); } } diff --git a/src/symfony-json-security/src/Security/Factory/WebauthnSecurityFactory.php b/src/symfony-json-security/src/Security/Factory/WebauthnSecurityFactory.php index 81326c1f..ca791988 100644 --- a/src/symfony-json-security/src/Security/Factory/WebauthnSecurityFactory.php +++ b/src/symfony-json-security/src/Security/Factory/WebauthnSecurityFactory.php @@ -21,6 +21,10 @@ use Symfony\Component\DependencyInjection\Reference; use Webauthn\JsonSecurityBundle\Security\Authentication\Provider\WebauthnProvider; use Webauthn\JsonSecurityBundle\Security\EntryPoint\WebauthnEntryPoint; +use Webauthn\JsonSecurityBundle\Security\Handler\DefaultFailureHandler; +use Webauthn\JsonSecurityBundle\Security\Handler\DefaultRequestOptionsHandler; +use Webauthn\JsonSecurityBundle\Security\Handler\DefaultSuccessHandler; +use Webauthn\JsonSecurityBundle\Security\Storage\SessionStorage; class WebauthnSecurityFactory implements SecurityFactoryInterface { @@ -36,7 +40,7 @@ class WebauthnSecurityFactory implements SecurityFactoryInterface public function create(ContainerBuilder $container, $id, $config, $userProviderId, $defaultEntryPointId): array { $authProviderId = $this->createAuthProvider($container, $id, $config, $userProviderId); - $entryPointId = $this->createEntryPoint($container, $id); + $entryPointId = $this->createEntryPoint($container, $id, $config); $listenerId = $this->createListener($container, $id, $config); return [$authProviderId, $listenerId, $entryPointId]; @@ -70,8 +74,11 @@ public function addConfiguration(NodeDefinition $node): void ->scalarNode('profile')->isRequired()->end() ->scalarNode('options_path')->defaultValue('/login/options')->end() ->scalarNode('login_path')->defaultValue('/login')->end() - ->scalarNode('session_parameter')->defaultValue('WEBAUTHN_PUBLIC_KEY_REQUEST_OPTIONS')->end() ->scalarNode('user_provider')->defaultNull()->end() + ->scalarNode('request_options_storage')->defaultValue(SessionStorage::class)->end() + ->scalarNode('request_options_handler')->defaultValue(DefaultRequestOptionsHandler::class)->end() + ->scalarNode('success_handler')->defaultValue(DefaultSuccessHandler::class)->end() + ->scalarNode('failure_handler')->defaultValue(DefaultFailureHandler::class)->end() ->end() ; } @@ -93,6 +100,10 @@ private function createListener(ContainerBuilder $container, string $id, array $ $listener = new ChildDefinition($listenerId); $listener->replaceArgument(13, $id); $listener->replaceArgument(14, $config); + $listener->replaceArgument(15, new Reference($config['success_handler'])); + $listener->replaceArgument(16, new Reference($config['failure_handler'])); + $listener->replaceArgument(17, new Reference($config['request_options_handler'])); + $listener->replaceArgument(18, new Reference($config['request_options_storage'])); $listenerId .= '.'.$id; $container->setDefinition($listenerId, $listener); @@ -100,10 +111,13 @@ private function createListener(ContainerBuilder $container, string $id, array $ return $listenerId; } - private function createEntryPoint(ContainerBuilder $container, string $id): string + private function createEntryPoint(ContainerBuilder $container, string $id, array $config): string { $entryPointId = 'webauthn.security.json.authentication.entrypoint.'.$id; - $container->setDefinition($entryPointId, new ChildDefinition(WebauthnEntryPoint::class)); + $entryPoint = new ChildDefinition(WebauthnEntryPoint::class); + $entryPoint->replaceArgument(0, new Reference($config['failure_handler'])); + + $container->setDefinition($entryPointId, $entryPoint); return $entryPointId; } diff --git a/src/symfony-json-security/src/Security/Firewall/WebauthnListener.php b/src/symfony-json-security/src/Security/Firewall/WebauthnListener.php index cb8b7e4b..b8cda79c 100644 --- a/src/symfony-json-security/src/Security/Firewall/WebauthnListener.php +++ b/src/symfony-json-security/src/Security/Firewall/WebauthnListener.php @@ -21,7 +21,6 @@ use function Safe\sprintf; use Symfony\Bridge\PsrHttpMessage\HttpMessageFactoryInterface; use Symfony\Component\EventDispatcher\EventDispatcherInterface; -use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Event\GetResponseEvent; @@ -30,6 +29,8 @@ use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Exception\AuthenticationException; +use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface; +use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface; use Symfony\Component\Security\Http\Event\InteractiveLoginEvent; use Symfony\Component\Security\Http\Firewall\ListenerInterface; use Symfony\Component\Security\Http\HttpUtils; @@ -45,9 +46,11 @@ use Webauthn\JsonSecurityBundle\Model\PublicKeyCredentialFakeUserEntity; use Webauthn\JsonSecurityBundle\Provider\FakePublicKeyCredentialUserEntityProvider; use Webauthn\JsonSecurityBundle\Security\Authentication\Token\WebauthnToken; +use Webauthn\JsonSecurityBundle\Security\Handler\RequestOptionsHandler; +use Webauthn\JsonSecurityBundle\Security\Storage\RequestOptionsStorage; +use Webauthn\JsonSecurityBundle\Security\Storage\StoredData; use Webauthn\PublicKeyCredentialDescriptor; use Webauthn\PublicKeyCredentialLoader; -use Webauthn\PublicKeyCredentialRequestOptions; use Webauthn\PublicKeyCredentialSource; use Webauthn\PublicKeyCredentialSourceRepository; use Webauthn\PublicKeyCredentialUserEntity; @@ -135,7 +138,26 @@ class WebauthnListener implements ListenerInterface */ private $fakePublicKeyCredentialUserEntityProvider; - public function __construct(HttpMessageFactoryInterface $httpMessageFactory, SerializerInterface $serializer, ValidatorInterface $validator, PublicKeyCredentialRequestOptionsFactory $publicKeyCredentialRequestOptionsFactory, PublicKeyCredentialSourceRepository $publicKeyCredentialSourceRepository, PublicKeyCredentialUserEntityRepository $userEntityRepository, PublicKeyCredentialLoader $publicKeyCredentialLoader, AuthenticatorAssertionResponseValidator $authenticatorAssertionResponseValidator, TokenStorageInterface $tokenStorage, AuthenticationManagerInterface $authenticationManager, SessionAuthenticationStrategyInterface $sessionStrategy, HttpUtils $httpUtils, ?FakePublicKeyCredentialUserEntityProvider $fakePublicKeyCredentialSourceRepository, string $providerKey, array $options = [], LoggerInterface $logger = null, EventDispatcherInterface $dispatcher = null) + /** + * @var AuthenticationSuccessHandlerInterface + */ + private $authenticationSuccessHandler; + + /** + * @var AuthenticationFailureHandlerInterface + */ + private $authenticationFailureHandler; + + /** + * @var RequestOptionsStorage + */ + private $requestOptionsStorage; + /** + * @var RequestOptionsHandler + */ + private $requestOptionsHandler; + + public function __construct(HttpMessageFactoryInterface $httpMessageFactory, SerializerInterface $serializer, ValidatorInterface $validator, PublicKeyCredentialRequestOptionsFactory $publicKeyCredentialRequestOptionsFactory, PublicKeyCredentialSourceRepository $publicKeyCredentialSourceRepository, PublicKeyCredentialUserEntityRepository $userEntityRepository, PublicKeyCredentialLoader $publicKeyCredentialLoader, AuthenticatorAssertionResponseValidator $authenticatorAssertionResponseValidator, TokenStorageInterface $tokenStorage, AuthenticationManagerInterface $authenticationManager, SessionAuthenticationStrategyInterface $sessionStrategy, HttpUtils $httpUtils, ?FakePublicKeyCredentialUserEntityProvider $fakePublicKeyCredentialSourceRepository, string $providerKey, array $options, AuthenticationSuccessHandlerInterface $authenticationSuccessHandler, AuthenticationFailureHandlerInterface $authenticationFailureHandler, RequestOptionsHandler $requestOptionsHandler, RequestOptionsStorage $requestOptionsStorage, LoggerInterface $logger = null, EventDispatcherInterface $dispatcher = null) { Assertion::notEmpty($providerKey, '$providerKey must not be empty.'); @@ -157,6 +179,10 @@ public function __construct(HttpMessageFactoryInterface $httpMessageFactory, Ser $this->publicKeyCredentialRequestOptionsFactory = $publicKeyCredentialRequestOptionsFactory; $this->validator = $validator; $this->fakePublicKeyCredentialUserEntityProvider = $fakePublicKeyCredentialSourceRepository; + $this->authenticationSuccessHandler = $authenticationSuccessHandler; + $this->authenticationFailureHandler = $authenticationFailureHandler; + $this->requestOptionsStorage = $requestOptionsStorage; + $this->requestOptionsHandler = $requestOptionsHandler; } /** @@ -189,8 +215,8 @@ public function handle(GetResponseEvent $event): void private function onOptionsPath(GetResponseEvent $event): void { + $request = $event->getRequest(); try { - $request = $event->getRequest(); $content = $request->getContent(); Assertion::string($content, 'Invalid data'); $creationOptionsRequest = $this->getServerPublicKeyCredentialRequestOptionsRequest($content); @@ -208,10 +234,10 @@ private function onOptionsPath(GetResponseEvent $event): void $this->options['profile'], $allowedCredentials ); - $request->getSession()->set($this->options['session_parameter'], ['options' => $publicKeyCredentialRequestOptions, 'userEntity' => $userEntity]); - $response = new JsonResponse($publicKeyCredentialRequestOptions->jsonSerialize()); + $this->requestOptionsStorage->store($request, new StoredData($publicKeyCredentialRequestOptions, $userEntity)); + $response = $this->requestOptionsHandler->onRequestOptions($publicKeyCredentialRequestOptions, $userEntity); } catch (\Exception $e) { - $response = $this->onAssertionFailure(new AuthenticationException($e->getMessage(), 0, $e)); + $response = $this->onAssertionFailure($request, new AuthenticationException($e->getMessage(), 0, $e)); } $event->setResponse($response); @@ -245,15 +271,15 @@ private function onLoginPath(GetResponseEvent $event): void $this->sessionStrategy->onAuthentication($request, $authenticatedToken); $response = $this->onAssertionSuccess($request, $authenticatedToken); } catch (AuthenticationException $e) { - $response = $this->onAssertionFailure($e); + $response = $this->onAssertionFailure($request, $e); } catch (\Exception $e) { - $response = $this->onAssertionFailure(new AuthenticationException($e->getMessage(), 0, $e)); + $response = $this->onAssertionFailure($request, new AuthenticationException($e->getMessage(), 0, $e)); } $event->setResponse($response); } - private function onAssertionFailure(AuthenticationException $failed): Response + private function onAssertionFailure(Request $request, AuthenticationException $failed): Response { if (null !== $this->logger) { $this->logger->info('Webauthn authentication request failed.', ['exception' => $failed]); @@ -264,12 +290,13 @@ private function onAssertionFailure(AuthenticationException $failed): Response $this->tokenStorage->setToken(null); } - $data = [ - 'status' => 'error', - 'errorMessage' => $failed->getMessage(), - ]; + $response = $this->authenticationFailureHandler->onAuthenticationFailure($request, $failed); - return new JsonResponse($data, Response::HTTP_UNAUTHORIZED); + if (!$response instanceof Response) { + throw new RuntimeException('Authentication Failure Handler did not return a Response.'); + } + + return $response; } /** @@ -291,31 +318,20 @@ private function onAssertionSuccess(Request $request, TokenInterface $token): Re $this->dispatcher->dispatch(SecurityEvents::INTERACTIVE_LOGIN, $loginEvent); } - $data = [ - 'status' => 'ok', - 'errorMessage' => '', - ]; + $response = $this->authenticationSuccessHandler->onAuthenticationSuccess($request, $token); + + if (!$response instanceof Response) { + throw new RuntimeException('Authentication Success Handler did not return a Response.'); + } - return new JsonResponse($data, Response::HTTP_OK); + return $response; } private function processWithAssertion(Request $request): WebauthnToken { - $sessionValue = $request->getSession()->remove($this->options['session_parameter']); - if (!\is_array($sessionValue) || !\array_key_exists('options', $sessionValue) || !\array_key_exists('userEntity', $sessionValue)) { - throw new BadRequestHttpException('No public key credential request options available for this session.'); - } - - $publicKeyCredentialRequestOptions = $sessionValue['options']; - $userEntity = $sessionValue['userEntity']; + $storedData = $this->requestOptionsStorage->get($request); - if (!$publicKeyCredentialRequestOptions instanceof PublicKeyCredentialRequestOptions) { - throw new BadRequestHttpException('No public key credential request options available for this session.'); - } - if (!$userEntity instanceof PublicKeyCredentialUserEntity) { - throw new BadRequestHttpException('No user entity available for this session.'); - } - if ($userEntity instanceof PublicKeyCredentialFakeUserEntity) { + if ($storedData->getPublicKeyCredentialUserEntity() instanceof PublicKeyCredentialFakeUserEntity) { throw new BadRequestHttpException('Invalid assertion'); } @@ -337,16 +353,16 @@ private function processWithAssertion(Request $request): WebauthnToken $this->authenticatorAssertionResponseValidator->check( $publicKeyCredential->getRawId(), $response, - $publicKeyCredentialRequestOptions, + $storedData->getPublicKeyCredentialRequestOptions(), $psr7Request, - $userEntity->getId() + $storedData->getPublicKeyCredentialUserEntity()->getId() ); } catch (\Throwable $throwable) { if (null !== $this->logger) { $this->logger->error(sprintf( 'Invalid assertion: %s. Request was: %s. Reason is: %s (%s:%d)', $assertion, - json_encode($publicKeyCredentialRequestOptions), + json_encode($storedData->getPublicKeyCredentialRequestOptions()), $throwable->getMessage(), $throwable->getFile(), $throwable->getLine() @@ -356,8 +372,8 @@ private function processWithAssertion(Request $request): WebauthnToken } $token = new WebauthnToken( - $userEntity->getName(), - $publicKeyCredentialRequestOptions, + $storedData->getPublicKeyCredentialUserEntity()->getName(), + $storedData->getPublicKeyCredentialRequestOptions(), $publicKeyCredentialSource->getPublicKeyCredentialDescriptor(), $response->getAuthenticatorData()->isUserPresent(), $response->getAuthenticatorData()->isUserVerified(), diff --git a/src/symfony-json-security/src/Security/Handler/DefaultFailureHandler.php b/src/symfony-json-security/src/Security/Handler/DefaultFailureHandler.php new file mode 100644 index 00000000..933ee271 --- /dev/null +++ b/src/symfony-json-security/src/Security/Handler/DefaultFailureHandler.php @@ -0,0 +1,33 @@ + 'error', + 'errorMessage' => $exception->getMessage(), + ]; + + return new JsonResponse($data, Response::HTTP_UNAUTHORIZED); + } +} diff --git a/src/symfony-json-security/src/Security/Handler/DefaultRequestOptionsHandler.php b/src/symfony-json-security/src/Security/Handler/DefaultRequestOptionsHandler.php new file mode 100644 index 00000000..2729bd04 --- /dev/null +++ b/src/symfony-json-security/src/Security/Handler/DefaultRequestOptionsHandler.php @@ -0,0 +1,27 @@ + 'ok', + 'errorMessage' => '', + ]; + + return new JsonResponse($data, JsonResponse::HTTP_OK); + } +} diff --git a/src/symfony-json-security/src/Security/Handler/RequestOptionsHandler.php b/src/symfony-json-security/src/Security/Handler/RequestOptionsHandler.php new file mode 100644 index 00000000..3a07e0ca --- /dev/null +++ b/src/symfony-json-security/src/Security/Handler/RequestOptionsHandler.php @@ -0,0 +1,23 @@ +getSession(); + Assertion::notNull($session, 'This authentication method requires a session.'); + + $session->set(self::SESSION_PARAMETER, ['options' => $data->getPublicKeyCredentialRequestOptions(), 'userEntity' => $data->getPublicKeyCredentialUserEntity()]); + } + + public function get(Request $request): StoredData + { + $session = $request->getSession(); + Assertion::notNull($session, 'This authentication method requires a session.'); + + $sessionValue = $session->remove(self::SESSION_PARAMETER); + if (!\is_array($sessionValue) || !\array_key_exists('options', $sessionValue) || !\array_key_exists('userEntity', $sessionValue)) { + throw new BadRequestHttpException('No public key credential request options available for this session.'); + } + + $publicKeyCredentialRequestOptions = $sessionValue['options']; + $userEntity = $sessionValue['userEntity']; + + if (!$publicKeyCredentialRequestOptions instanceof PublicKeyCredentialRequestOptions) { + throw new BadRequestHttpException('No public key credential request options available for this session.'); + } + if (!$userEntity instanceof PublicKeyCredentialUserEntity) { + throw new BadRequestHttpException('No user entity available for this session.'); + } + + return new StoredData($publicKeyCredentialRequestOptions, $userEntity); + } +} diff --git a/src/symfony-json-security/src/Security/Storage/StoredData.php b/src/symfony-json-security/src/Security/Storage/StoredData.php new file mode 100644 index 00000000..5a2d4ce6 --- /dev/null +++ b/src/symfony-json-security/src/Security/Storage/StoredData.php @@ -0,0 +1,46 @@ +publicKeyCredentialRequestOptions = $publicKeyCredentialRequestOptions; + $this->publicKeyCredentialUserEntity = $publicKeyCredentialUserEntity; + } + + public function getPublicKeyCredentialRequestOptions(): PublicKeyCredentialRequestOptions + { + return $this->publicKeyCredentialRequestOptions; + } + + public function getPublicKeyCredentialUserEntity(): PublicKeyCredentialUserEntity + { + return $this->publicKeyCredentialUserEntity; + } +} diff --git a/src/symfony-json-security/tests/config/config.yml b/src/symfony-json-security/tests/config/config.yml index 17bed62c..3f210fdd 100644 --- a/src/symfony-json-security/tests/config/config.yml +++ b/src/symfony-json-security/tests/config/config.yml @@ -50,6 +50,9 @@ services: autowire: true Webauthn\JsonSecurityBundle\Tests\Functional\PublicKeyCredentialFakeUserEntityProvider: autowire: true + Webauthn\JsonSecurityBundle\Tests\Functional\FailureHandler: ~ + Webauthn\JsonSecurityBundle\Tests\Functional\SuccessHandler: ~ + Webauthn\JsonSecurityBundle\Tests\Functional\CustomSessionStorage: ~ httplug: profiling: false @@ -110,6 +113,9 @@ security: anonymous: ~ webauthn_json: profile: 'default' + request_options_storage: 'Webauthn\JsonSecurityBundle\Tests\Functional\CustomSessionStorage' + success_handler: 'Webauthn\JsonSecurityBundle\Tests\Functional\SuccessHandler' + failure_handler: 'Webauthn\JsonSecurityBundle\Tests\Functional\FailureHandler' logout: path: /logout target: / diff --git a/src/symfony-json-security/tests/functional/CustomSessionStorage.php b/src/symfony-json-security/tests/functional/CustomSessionStorage.php new file mode 100644 index 00000000..87c40b4b --- /dev/null +++ b/src/symfony-json-security/tests/functional/CustomSessionStorage.php @@ -0,0 +1,61 @@ +getSession(); + Assertion::notNull($session, 'This authentication method requires a session.'); + + $session->set(self::SESSION_PARAMETER, ['options' => $data->getPublicKeyCredentialRequestOptions(), 'userEntity' => $data->getPublicKeyCredentialUserEntity()]); + } + + public function get(Request $request): StoredData + { + $session = $request->getSession(); + Assertion::notNull($session, 'This authentication method requires a session.'); + + $sessionValue = $session->remove(self::SESSION_PARAMETER); + if (!\is_array($sessionValue) || !\array_key_exists('options', $sessionValue) || !\array_key_exists('userEntity', $sessionValue)) { + throw new BadRequestHttpException('No public key credential request options available for this session.'); + } + + $publicKeyCredentialRequestOptions = $sessionValue['options']; + $userEntity = $sessionValue['userEntity']; + + if (!$publicKeyCredentialRequestOptions instanceof PublicKeyCredentialRequestOptions) { + throw new BadRequestHttpException('No public key credential request options available for this session.'); + } + if (!$userEntity instanceof PublicKeyCredentialUserEntity) { + throw new BadRequestHttpException('No user entity available for this session.'); + } + + return new StoredData($publicKeyCredentialRequestOptions, $userEntity); + } +} diff --git a/src/symfony-json-security/tests/functional/FailureHandler.php b/src/symfony-json-security/tests/functional/FailureHandler.php new file mode 100644 index 00000000..bfb89fbe --- /dev/null +++ b/src/symfony-json-security/tests/functional/FailureHandler.php @@ -0,0 +1,34 @@ + 'error', + 'errorMessage' => $exception->getMessage(), + 'errorCode' => $exception->getCode(), + ]; + + return new JsonResponse($data, Response::HTTP_UNAUTHORIZED); + } +} diff --git a/src/symfony-json-security/tests/functional/PublicKeyCredentialFakeUserEntityProvider.php b/src/symfony-json-security/tests/functional/PublicKeyCredentialFakeUserEntityProvider.php index bdb3901b..1f00698f 100644 --- a/src/symfony-json-security/tests/functional/PublicKeyCredentialFakeUserEntityProvider.php +++ b/src/symfony-json-security/tests/functional/PublicKeyCredentialFakeUserEntityProvider.php @@ -50,7 +50,7 @@ public function generateFakeUserEntityFor(string $username): PublicKeyCredential { $nbCredentials = random_int(1, 6); $credentials = []; - for($i = 0; $i < $nbCredentials; ++$i) { + for ($i = 0; $i < $nbCredentials; ++$i) { $credentials[] = new PublicKeyCredentialDescriptor( PublicKeyCredentialDescriptor::CREDENTIAL_TYPE_PUBLIC_KEY, random_bytes(32) @@ -65,4 +65,4 @@ public function generateFakeUserEntityFor(string $username): PublicKeyCredential $credentials ); } -} \ No newline at end of file +} diff --git a/src/symfony-json-security/tests/functional/SecuredAreaTest.php b/src/symfony-json-security/tests/functional/SecuredAreaTest.php index 0f72fb2c..81649651 100644 --- a/src/symfony-json-security/tests/functional/SecuredAreaTest.php +++ b/src/symfony-json-security/tests/functional/SecuredAreaTest.php @@ -38,7 +38,7 @@ public function aClientCannotAccessToTheResourceIfUserIsNotAuthenticated(): void $client->request('GET', '/admin', [], [], ['HTTPS' => 'on']); static::assertEquals(Response::HTTP_UNAUTHORIZED, $client->getResponse()->getStatusCode()); - static::assertEquals('{"status":"error","errorMessage":"Authentication Required"}', $client->getResponse()->getContent()); + static::assertEquals('{"status":"error","errorMessage":"Full authentication is required to access this resource.","errorCode":0}', $client->getResponse()->getContent()); } /** @@ -63,7 +63,7 @@ public function aClientCanSubmitUsernameToGetWebauthnOptions(): void static::assertArrayHasKey('set-cookie', $client->getResponse()->headers->all()); $session = $client->getContainer()->get('session'); - static::assertTrue($session->has('WEBAUTHN_PUBLIC_KEY_REQUEST_OPTIONS')); + static::assertTrue($session->has('FOO_BAR_SESSION_PARAMETER')); } /** @@ -89,13 +89,13 @@ public function aClientCanSubmitUsernameToGetWebauthnOptionsEvenIfTheUsernameIsN static::assertArrayHasKey('set-cookie', $client->getResponse()->headers->all()); $session = $client->getContainer()->get('session'); - static::assertTrue($session->has('WEBAUTHN_PUBLIC_KEY_REQUEST_OPTIONS')); + static::assertTrue($session->has('FOO_BAR_SESSION_PARAMETER')); $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('POST', '/login', [], [], ['CONTENT_TYPE' => 'application/json', 'HTTP_HOST' => 'test.com', 'HTTPS' => 'on'], $assertion); static::assertEquals(Response::HTTP_UNAUTHORIZED, $client->getResponse()->getStatusCode()); - static::assertEquals('{"status":"error","errorMessage":"Invalid assertion"}', $client->getResponse()->getContent()); + static::assertEquals('{"status":"error","errorMessage":"Invalid assertion","errorCode":0}', $client->getResponse()->getContent()); static::assertFalse($session->has('_security_main')); static::assertFalse($client->getResponse()->headers->has('set-cookie')); @@ -118,7 +118,7 @@ public function aUserCannotBeBeAuthenticatedInAbsenceOfOptions(): void $client->request('POST', '/login', [], [], ['CONTENT_TYPE' => 'application/json', 'HTTP_HOST' => 'test.com', 'HTTPS' => 'on'], $assertion); static::assertEquals(Response::HTTP_UNAUTHORIZED, $client->getResponse()->getStatusCode()); - static::assertEquals('{"status":"error","errorMessage":"No public key credential request options available for this session."}', $client->getResponse()->getContent()); + static::assertEquals('{"status":"error","errorMessage":"No public key credential request options available for this session.","errorCode":0}', $client->getResponse()->getContent()); } /** @@ -142,7 +142,7 @@ public function aUserCanBeAuthenticatedAndAccessToTheProtectedResource(): void $client = static::createClient(); $session = $client->getContainer()->get('session'); - $session->set('WEBAUTHN_PUBLIC_KEY_REQUEST_OPTIONS', [ + $session->set('FOO_BAR_SESSION_PARAMETER', [ 'options' => $publicKeyCredentialRequestOptions, 'userEntity' => new PublicKeyCredentialUserEntity('admin', 'foo', 'Foo BAR (-_-)'), ]); @@ -153,7 +153,7 @@ public function aUserCanBeAuthenticatedAndAccessToTheProtectedResource(): void $client->request('POST', '/login', [], [], ['CONTENT_TYPE' => 'application/json', 'HTTP_HOST' => 'test.com', 'HTTPS' => 'on'], $assertion); static::assertEquals(Response::HTTP_OK, $client->getResponse()->getStatusCode()); - static::assertEquals('{"status":"ok","errorMessage":""}', $client->getResponse()->getContent()); + static::assertEquals('{"status":"ok","errorMessage":"","username":"admin"}', $client->getResponse()->getContent()); static::assertTrue($session->has('_security_main')); static::assertTrue($client->getResponse()->headers->has('set-cookie')); diff --git a/src/symfony-json-security/tests/functional/SuccessHandler.php b/src/symfony-json-security/tests/functional/SuccessHandler.php new file mode 100644 index 00000000..e3644aa7 --- /dev/null +++ b/src/symfony-json-security/tests/functional/SuccessHandler.php @@ -0,0 +1,33 @@ + 'ok', + 'errorMessage' => '', + 'username' => $token->getUsername(), + ]; + + return new JsonResponse($data, JsonResponse::HTTP_OK); + } +}