From cbcf51b5b32d6eaceb16c07f104b459ab4ccf0d1 Mon Sep 17 00:00:00 2001 From: silverbackdan Date: Wed, 26 Oct 2022 13:15:46 +0100 Subject: [PATCH] Add configuration for mercure cookie samesite policy --- src/DependencyInjection/Configuration.php | 26 ++++++ .../SilverbackApiComponentsExtension.php | 5 ++ .../Mercure/AddMercureTokenListener.php | 89 ++++++++++++------- src/Resources/config/services.php | 4 +- 4 files changed, 92 insertions(+), 32 deletions(-) diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 00e2fb5e..adbd9083 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -16,6 +16,7 @@ use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; +use Symfony\Component\HttpFoundation\Cookie; /** * @author Daniel West @@ -33,6 +34,7 @@ public function getConfigTreeBuilder(): TreeBuilder ->scalarNode('metadata_key')->defaultValue('_metadata')->end() ->end(); + $this->addMercureNode($rootNode); $this->addRouteSecurityNode($rootNode); $this->addRoutableSecurityNode($rootNode); $this->addRefreshTokenNode($rootNode); @@ -43,6 +45,30 @@ public function getConfigTreeBuilder(): TreeBuilder return $treeBuilder; } + private function addMercureNode(ArrayNodeDefinition $rootNode): void + { + $rootNode + ->children() + ->arrayNode('mercure') + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('hub_name')->defaultNull()->end() + ->arrayNode('cookie') + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('samesite')->defaultValue(Cookie::SAMESITE_STRICT) + ->validate() + ->ifNotInArray([Cookie::SAMESITE_STRICT, Cookie::SAMESITE_LAX, Cookie::SAMESITE_NONE]) + ->thenInvalid('Invalid Mercure cookie samesite value %s') + ->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ->end(); + } + private function addRouteSecurityNode(ArrayNodeDefinition $rootNode): void { $rootNode diff --git a/src/DependencyInjection/SilverbackApiComponentsExtension.php b/src/DependencyInjection/SilverbackApiComponentsExtension.php index 516f4b99..7643c1fc 100644 --- a/src/DependencyInjection/SilverbackApiComponentsExtension.php +++ b/src/DependencyInjection/SilverbackApiComponentsExtension.php @@ -21,6 +21,7 @@ use Silverback\ApiComponentsBundle\Doctrine\Extension\ORM\TablePrefixExtension; use Silverback\ApiComponentsBundle\Event\FormSuccessEvent; use Silverback\ApiComponentsBundle\EventListener\Form\FormSuccessEventListenerInterface; +use Silverback\ApiComponentsBundle\EventListener\Mercure\AddMercureTokenListener; use Silverback\ApiComponentsBundle\Exception\ApiPlatformAuthenticationException; use Silverback\ApiComponentsBundle\Exception\UnparseableRequestHeaderException; use Silverback\ApiComponentsBundle\Exception\UserDisabledException; @@ -151,6 +152,10 @@ public function load(array $configs, ContainerBuilder $container): void $definition = $container->getDefinition(RoutableVoter::class); $definition->setArgument('$securityStr', $config['routable_security']); + + $definition = $container->getDefinition(AddMercureTokenListener::class); + $definition->setArgument('$cookieSameSite', $config['mercure']['cookie']['samesite']); + $definition->setArgument('$hubName', $config['mercure']['hub_name']); } private function setEmailVerificationArguments(ContainerBuilder $container, array $emailVerificationConfig, int $passwordRepeatTtl): void diff --git a/src/EventListener/Mercure/AddMercureTokenListener.php b/src/EventListener/Mercure/AddMercureTokenListener.php index 28316335..a6df0d92 100644 --- a/src/EventListener/Mercure/AddMercureTokenListener.php +++ b/src/EventListener/Mercure/AddMercureTokenListener.php @@ -22,14 +22,23 @@ use Silverback\ApiComponentsBundle\Helper\Publishable\PublishableStatusChecker; use Symfony\Component\HttpFoundation\Cookie; use Symfony\Component\HttpKernel\Event\ResponseEvent; -use Symfony\Component\Mercure\Jwt\TokenFactoryInterface; +use Symfony\Component\Mercure\Authorization; use Symfony\Component\Routing\RequestContext; + class AddMercureTokenListener { use CorsTrait; - public function __construct(private TokenFactoryInterface $tokenFactory, private ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, private ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private PublishableStatusChecker $publishableStatusChecker, private RequestContext $requestContext) + public function __construct( + private readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, + private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, + private readonly PublishableStatusChecker $publishableStatusChecker, + private readonly RequestContext $requestContext, + private readonly Authorization $mercureAuthorization, + private readonly string $cookieSameSite = Cookie::SAMESITE_STRICT, + private readonly ?string $hubName = null + ) { } @@ -47,47 +56,65 @@ public function onKernelResponse(ResponseEvent $event): void $subscribeIris = []; $response = $event->getResponse(); - foreach ($this->resourceNameCollectionFactory->create() as $resourceClass) { - $resourceMetadataCollection = $this->resourceMetadataCollectionFactory->create($resourceClass); - - try { - $operation = $resourceMetadataCollection->getOperation(forceCollection: false, httpOperation: true); - } catch (OperationNotFoundException $e) { - continue; + if ($resourceIris = $this->getSubscribeIrisForResource($resourceClass)) { + $subscribeIris[] = $resourceIris; } + } + $subscribeIris = array_merge([], ...$subscribeIris); - if (!$operation instanceof HttpOperation) { - continue; - } + // Todo: await merge of https://github.com/symfony/mercure/pull/93 to remove ability to publish any updates and set to null + // May also be able to await a mercure bundle update to set the cookie samesite in mercure configs + $cookie = $this->mercureAuthorization->createCookie($request, $request, $subscribeIris, [], $this->hubName); + $cookie->withSameSite($this->cookieSameSite); + $response->headers->setCookie($cookie); + } - $mercure = $operation->getMercure(); + private function getSubscribeIrisForResource(string $resourceClass): ?array + { + $operation = $this->getMercureResourceOperation($resourceClass); + if (!$operation) { + return null; + } - if (!$mercure) { - continue; - } + $refl = new \ReflectionClass($operation->getClass()); + $isPublishable = \count($refl->getAttributes(Publishable::class)); + + $uriTemplate = $this->buildAbsoluteUriTemplate() . $operation->getRoutePrefix() . $operation->getUriTemplate(); + $subscribeIris = [$uriTemplate]; - $refl = new \ReflectionClass($operation->getClass()); - $isPublishable = \count($refl->getAttributes(Publishable::class)); + if (!$isPublishable) { + return $subscribeIris; + } - $uriTemplate = $this->buildAbsoluteUriTemplate() . $operation->getRoutePrefix() . $operation->getUriTemplate(); + // Note that `?draft=1` is also hard coded into the PublishableIriConverter, probably make this configurable somewhere + if ($this->publishableStatusChecker->isGranted($operation->getClass())) { + $subscribeIris[] = $uriTemplate . '?draft=1'; + } - if (!$isPublishable) { - $subscribeIris[] = $uriTemplate; - continue; - } + return $subscribeIris; + } - // Note that `?draft=1` is also hard coded into the PublishableIriConverter, probably make this configurable somewhere - if ($this->publishableStatusChecker->isGranted($operation->getClass())) { - $subscribeIris[] = $uriTemplate . '?draft=1'; - $subscribeIris[] = $uriTemplate; - continue; - } + private function getMercureResourceOperation(string $resourceClass): ?HttpOperation + { + $resourceMetadataCollection = $this->resourceMetadataCollectionFactory->create($resourceClass); + + try { + $operation = $resourceMetadataCollection->getOperation(forceCollection: false, httpOperation: true); + } catch (OperationNotFoundException $e) { + return null; + } - $subscribeIris[] = $uriTemplate; + if (!$operation instanceof HttpOperation) { + return null; } - $response->headers->setCookie(Cookie::create('mercureAuthorization', $this->tokenFactory->create($subscribeIris, []))); + $mercure = $operation->getMercure(); + + if (!$mercure) { + return null; + } + return $operation; } /** diff --git a/src/Resources/config/services.php b/src/Resources/config/services.php index 62bb0b96..6f85f456 100644 --- a/src/Resources/config/services.php +++ b/src/Resources/config/services.php @@ -168,6 +168,7 @@ use Symfony\Component\HttpKernel\KernelEvents; use Symfony\Component\Mailer\Event\MessageEvent; use Symfony\Component\Mailer\MailerInterface; +use Symfony\Component\Mercure\Authorization; use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\Routing\RouterInterface; @@ -619,11 +620,12 @@ ->set(AddMercureTokenListener::class) ->args( [ - new Reference('mercure.hub.default.jwt.factory'), new Reference(ResourceNameCollectionFactoryInterface::class), new Reference(ResourceMetadataCollectionFactoryInterface::class), new Reference(PublishableStatusChecker::class), new Reference('router.request_context'), + new Reference(Authorization::class), + '', // injected with dependency injection ] ) ->tag('kernel.event_listener', ['event' => ResponseEvent::class, 'method' => 'onKernelResponse']);