diff --git a/CHANGELOG.md b/CHANGELOG.md index 98429e63..60d92bf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,9 @@ access to other services.) it should have but a newly created one. You should remove the deprecated service `fos_http_cache.user_context.logout_handler` from the logout.handlers section of your firewall configuration. + +* User context compatibility which was broken due to Symfony making responses + private if the session is started as of Symfony 3.4+. ### Deprecated diff --git a/src/DependencyInjection/FOSHttpCacheExtension.php b/src/DependencyInjection/FOSHttpCacheExtension.php index 20a28ff3..c0759012 100644 --- a/src/DependencyInjection/FOSHttpCacheExtension.php +++ b/src/DependencyInjection/FOSHttpCacheExtension.php @@ -24,6 +24,7 @@ use Symfony\Component\DependencyInjection\Loader\XmlFileLoader; use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\HttpKernel\DependencyInjection\Extension; +use Symfony\Component\HttpKernel\Kernel; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; /** @@ -312,6 +313,15 @@ private function loadUserContext(ContainerBuilder $container, XmlFileLoader $loa ->addTag(HashGeneratorPass::TAG_NAME) ->setAbstract(false); } + + // Only decorate default session listener for Symfony 3.4+ + if (version_compare(Kernel::VERSION, '3.4', '>=')) { + $container->getDefinition('fos_http_cache.user_context.session_listener') + ->setArgument(1, strtolower($config['user_hash_header'])) + ->setArgument(2, array_map('strtolower', $config['user_identifier_headers'])); + } else { + $container->removeDefinition('fos_http_cache.user_context.session_listener'); + } } private function loadProxyClient(ContainerBuilder $container, XmlFileLoader $loader, array $config) diff --git a/src/EventListener/SessionListener.php b/src/EventListener/SessionListener.php new file mode 100644 index 00000000..a356f721 --- /dev/null +++ b/src/EventListener/SessionListener.php @@ -0,0 +1,85 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FOS\HttpCacheBundle\EventListener; + +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpKernel\Event\FilterResponseEvent; +use Symfony\Component\HttpKernel\Event\GetResponseEvent; +use Symfony\Component\HttpKernel\EventListener\SessionListener as BaseSessionListener; + +/** + * Decorates the default Symfony session listener. + * + * The default Symfony session listener automatically makes responses private + * in case the session was started. This kills the user context feature of + * FOSHttpCache. We disable the default behaviour only if the user context header + * is part of the Vary headers to reduce the possible impacts on other parts + * of your application. + * + * @author Yanick Witschi + */ +final class SessionListener implements EventSubscriberInterface +{ + /** + * @var BaseSessionListener + */ + private $inner; + + /** + * @var string + */ + private $userHashHeader; + + /** + * @var array + */ + private $userIdentifierHeaders; + + /** + * @param BaseSessionListener $inner + * @param string $userHashHeader Must be lower-cased + * @param array $userIdentifierHeaders Must be lower-cased + */ + public function __construct(BaseSessionListener $inner, string $userHashHeader, array $userIdentifierHeaders) + { + $this->inner = $inner; + $this->userHashHeader = $userHashHeader; + $this->userIdentifierHeaders = $userIdentifierHeaders; + } + + public function onKernelRequest(GetResponseEvent $event) + { + return $this->inner->onKernelRequest($event); + } + + public function onKernelResponse(FilterResponseEvent $event) + { + if (!$event->isMasterRequest()) { + return; + } + + $varyHeaders = array_map('strtolower', $event->getResponse()->getVary()); + $relevantHeaders = array_merge($this->userIdentifierHeaders, [$this->userHashHeader]); + + // Call default behaviour if it's an irrelevant request for the user context + if (0 === count(array_intersect($varyHeaders, $relevantHeaders))) { + $this->inner->onKernelResponse($event); + } + + // noop, see class description + } + + public static function getSubscribedEvents(): array + { + return BaseSessionListener::getSubscribedEvents(); + } +} diff --git a/src/Resources/config/user_context.xml b/src/Resources/config/user_context.xml index efb5aaf4..d2a987af 100644 --- a/src/Resources/config/user_context.xml +++ b/src/Resources/config/user_context.xml @@ -40,6 +40,12 @@ + + + + + + diff --git a/tests/Unit/DependencyInjection/FOSHttpCacheExtensionTest.php b/tests/Unit/DependencyInjection/FOSHttpCacheExtensionTest.php index d2e19eb4..b977eef5 100644 --- a/tests/Unit/DependencyInjection/FOSHttpCacheExtensionTest.php +++ b/tests/Unit/DependencyInjection/FOSHttpCacheExtensionTest.php @@ -21,6 +21,7 @@ use Symfony\Component\DependencyInjection\DefinitionDecorator; use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag; use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\HttpKernel\Kernel; use Symfony\Component\Routing\Router; class FOSHttpCacheExtensionTest extends TestCase @@ -478,6 +479,35 @@ public function testConfigWithoutUserContext() $this->assertFalse($container->has('fos_http_cache.user_context.request_matcher')); $this->assertFalse($container->has('fos_http_cache.user_context.role_provider')); $this->assertFalse($container->has('fos_http_cache.user_context.logout_handler')); + $this->assertFalse($container->has('fos_http_cache.user_context.session_listener')); + } + + public function testSessionListenerIsDecoratedIfNeeded() + { + $config = [[ + 'user_context' => [ + 'user_identifier_headers' => ['X-Foo'], + 'user_hash_header' => 'X-Bar', + 'hash_cache_ttl' => 30, + 'always_vary_on_context_hash' => true, + 'role_provider' => true, + ], + ]]; + + $container = $this->createContainer(); + $this->extension->load($config, $container); + + // The whole definition should be removed for Symfony < 3.4 + if (version_compare(Kernel::VERSION, '3.4', '<')) { + $this->assertFalse($container->hasDefinition('fos_http_cache.user_context.session_listener')); + } else { + $this->assertTrue($container->hasDefinition('fos_http_cache.user_context.session_listener')); + + $definition = $container->getDefinition('fos_http_cache.user_context.session_listener'); + + $this->assertSame('x-bar', $definition->getArgument(1)); + $this->assertSame(['x-foo'], $definition->getArgument(2)); + } } public function testConfigLoadFlashMessageListener() diff --git a/tests/Unit/EventListener/SessionListenerTest.php b/tests/Unit/EventListener/SessionListenerTest.php new file mode 100755 index 00000000..b5ff998d --- /dev/null +++ b/tests/Unit/EventListener/SessionListenerTest.php @@ -0,0 +1,85 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FOS\HttpCacheBundle\Tests\Unit\EventListener; + +use FOS\HttpCacheBundle\EventListener\SessionListener; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Event\FilterResponseEvent; +use Symfony\Component\HttpKernel\Event\GetResponseEvent; +use Symfony\Component\HttpKernel\EventListener\SessionListener as BaseSessionListener; +use Symfony\Component\HttpKernel\HttpKernelInterface; +use Symfony\Component\HttpKernel\Kernel; + +class SessionListenerTest extends TestCase +{ + public function testOnKernelRequestRemainsUntouched() + { + $event = $this->createMock(GetResponseEvent::class); + $inner = $this->createMock(BaseSessionListener::class); + + $inner + ->expects($this->once()) + ->method('onKernelRequest') + ->with($event) + ; + + $listener = $this->getListener($inner); + $listener->onKernelRequest($event); + } + + /** + * @dataProvider onKernelResponseProvider + */ + public function testOnKernelResponse(Response $response, bool $shouldCallDecoratedListener) + { + if (version_compare(Kernel::VERSION, '3.4', '<')) { + $this->markTestSkipped('Irrelevant for Symfony < 3.4'); + } + + $event = new FilterResponseEvent( + $this->createMock(HttpKernelInterface::class), + new Request(), + HttpKernelInterface::MASTER_REQUEST, + $response + ); + + $inner = $this->createMock(BaseSessionListener::class); + + $inner + ->expects($shouldCallDecoratedListener ? $this->once() : $this->never()) + ->method('onKernelResponse') + ->with($event) + ; + + $listener = $this->getListener($inner); + $listener->onKernelResponse($event); + } + + public function onKernelResponseProvider() + { + // Response, decorated listener should be called or not + return [ + 'Irrelevant response' => [new Response(), true], + 'Irrelevant response header' => [new Response('', 200, ['Content-Type' => 'Foobar']), true], + 'Context hash header is present in Vary' => [new Response('', 200, ['Vary' => 'X-User-Context-Hash']), false], + 'User identifier header is present in Vary' => [new Response('', 200, ['Vary' => 'cookie']), false], + 'Both, context hash and identifier headers are present in Vary' => [new Response('', 200, ['Vary' => 'Cookie, X-User-Context-Hash']), false], + ]; + } + + private function getListener(BaseSessionListener $inner, $userHashHeader = 'x-user-context-hash', $userIdentifierHeaders = ['cookie', 'authorization']) + { + return new SessionListener($inner, $userHashHeader, $userIdentifierHeaders); + } +}