From 2a2b3aa53b5b822902694cb234811033c89856df Mon Sep 17 00:00:00 2001 From: Yanick Witschi Date: Fri, 23 Mar 2018 14:37:50 +0100 Subject: [PATCH 1/6] Decorate default Symfony session listener for Symfony 3.4+ to restore user context functionality --- .../FOSHttpCacheExtension.php | 10 +++ src/EventListener/SessionListener.php | 80 +++++++++++++++++++ src/Resources/config/user_context.xml | 5 ++ .../FOSHttpCacheExtensionTest.php | 30 +++++++ .../EventListener/SessionListenerTest.php | 80 +++++++++++++++++++ 5 files changed, 205 insertions(+) create mode 100644 src/EventListener/SessionListener.php create mode 100755 tests/Unit/EventListener/SessionListenerTest.php diff --git a/src/DependencyInjection/FOSHttpCacheExtension.php b/src/DependencyInjection/FOSHttpCacheExtension.php index 20a28ff3..935e89b1 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, $config['user_hash_header']) + ->setArgument(2, $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..ff67cade --- /dev/null +++ b/src/EventListener/SessionListener.php @@ -0,0 +1,80 @@ + + * + * 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 + * FOSCache. 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; + + public function __construct(BaseSessionListener $inner, string $userHashHeader, array $userIdentifierHeaders) + { + $this->inner = $inner; + $this->userHashHeader = strtolower($userHashHeader); + $this->userIdentifierHeaders = array_map('strtolower', $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..59d5c3c5 100644 --- a/src/Resources/config/user_context.xml +++ b/src/Resources/config/user_context.xml @@ -40,6 +40,11 @@ + + + + + diff --git a/tests/Unit/DependencyInjection/FOSHttpCacheExtensionTest.php b/tests/Unit/DependencyInjection/FOSHttpCacheExtensionTest.php index d2e19eb4..c9b9abe6 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..b6ffcc06 --- /dev/null +++ b/tests/Unit/EventListener/SessionListenerTest.php @@ -0,0 +1,80 @@ + + * + * 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; + +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) + { + $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); + } +} From 5f31bf0f54a6ec2f2ad2e2d148d6d091e96b7657 Mon Sep 17 00:00:00 2001 From: Yanick Witschi Date: Fri, 23 Mar 2018 14:51:24 +0100 Subject: [PATCH 2/6] Lowercase headers in extension already --- src/DependencyInjection/FOSHttpCacheExtension.php | 4 ++-- src/EventListener/SessionListener.php | 11 ++++++++--- .../DependencyInjection/FOSHttpCacheExtensionTest.php | 4 ++-- tests/Unit/EventListener/SessionListenerTest.php | 4 ++-- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/DependencyInjection/FOSHttpCacheExtension.php b/src/DependencyInjection/FOSHttpCacheExtension.php index 935e89b1..c0759012 100644 --- a/src/DependencyInjection/FOSHttpCacheExtension.php +++ b/src/DependencyInjection/FOSHttpCacheExtension.php @@ -317,8 +317,8 @@ private function loadUserContext(ContainerBuilder $container, XmlFileLoader $loa // 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, $config['user_hash_header']) - ->setArgument(2, $config['user_identifier_headers']); + ->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'); } diff --git a/src/EventListener/SessionListener.php b/src/EventListener/SessionListener.php index ff67cade..30413e3d 100644 --- a/src/EventListener/SessionListener.php +++ b/src/EventListener/SessionListener.php @@ -21,7 +21,7 @@ * * The default Symfony session listener automatically makes responses private * in case the session was started. This kills the user context feature of - * FOSCache. We disable the default behaviour only if the user context header + * 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. * @@ -44,11 +44,16 @@ final class SessionListener implements EventSubscriberInterface */ 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 = strtolower($userHashHeader); - $this->userIdentifierHeaders = array_map('strtolower', $userIdentifierHeaders); + $this->userHashHeader = $userHashHeader; + $this->userIdentifierHeaders = $userIdentifierHeaders; } public function onKernelRequest(GetResponseEvent $event) diff --git a/tests/Unit/DependencyInjection/FOSHttpCacheExtensionTest.php b/tests/Unit/DependencyInjection/FOSHttpCacheExtensionTest.php index c9b9abe6..b977eef5 100644 --- a/tests/Unit/DependencyInjection/FOSHttpCacheExtensionTest.php +++ b/tests/Unit/DependencyInjection/FOSHttpCacheExtensionTest.php @@ -505,8 +505,8 @@ public function testSessionListenerIsDecoratedIfNeeded() $definition = $container->getDefinition('fos_http_cache.user_context.session_listener'); - $this->assertSame('X-Bar', $definition->getArgument(1)); - $this->assertSame(['X-Foo'], $definition->getArgument(2)); + $this->assertSame('x-bar', $definition->getArgument(1)); + $this->assertSame(['x-foo'], $definition->getArgument(2)); } } diff --git a/tests/Unit/EventListener/SessionListenerTest.php b/tests/Unit/EventListener/SessionListenerTest.php index b6ffcc06..8a55778a 100755 --- a/tests/Unit/EventListener/SessionListenerTest.php +++ b/tests/Unit/EventListener/SessionListenerTest.php @@ -68,12 +68,12 @@ public function onKernelResponseProvider() '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], + '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']) + private function getListener(BaseSessionListener $inner, $userHashHeader = 'x-user-context-hash', $userIdentifierHeaders = ['cookie', 'authorization']) { return new SessionListener($inner, $userHashHeader, $userIdentifierHeaders); } From 52607166ab188cdf516cd626649d02ba63dbc8ed Mon Sep 17 00:00:00 2001 From: Yanick Witschi Date: Fri, 23 Mar 2018 14:56:19 +0100 Subject: [PATCH 3/6] Skip test for Symfony < 3.4 --- tests/Unit/EventListener/SessionListenerTest.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/Unit/EventListener/SessionListenerTest.php b/tests/Unit/EventListener/SessionListenerTest.php index 8a55778a..b5ff998d 100755 --- a/tests/Unit/EventListener/SessionListenerTest.php +++ b/tests/Unit/EventListener/SessionListenerTest.php @@ -19,6 +19,7 @@ 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 { @@ -42,6 +43,10 @@ public function testOnKernelRequestRemainsUntouched() */ 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(), From 1e2a05db21550b479e0b576dfeb5fc969095ccb4 Mon Sep 17 00:00:00 2001 From: Yanick Witschi Date: Fri, 23 Mar 2018 14:57:41 +0100 Subject: [PATCH 4/6] CS --- src/EventListener/SessionListener.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/EventListener/SessionListener.php b/src/EventListener/SessionListener.php index 30413e3d..a356f721 100644 --- a/src/EventListener/SessionListener.php +++ b/src/EventListener/SessionListener.php @@ -46,7 +46,7 @@ final class SessionListener implements EventSubscriberInterface /** * @param BaseSessionListener $inner - * @param string $userHashHeader Must be lower-cased + * @param string $userHashHeader Must be lower-cased * @param array $userIdentifierHeaders Must be lower-cased */ public function __construct(BaseSessionListener $inner, string $userHashHeader, array $userIdentifierHeaders) From 12123deea2a06f827b09c3ae171b25a5427b8689 Mon Sep 17 00:00:00 2001 From: Yanick Witschi Date: Fri, 23 Mar 2018 15:10:30 +0100 Subject: [PATCH 5/6] CS --- src/Resources/config/user_context.xml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Resources/config/user_context.xml b/src/Resources/config/user_context.xml index 59d5c3c5..d2a987af 100644 --- a/src/Resources/config/user_context.xml +++ b/src/Resources/config/user_context.xml @@ -42,7 +42,8 @@ - + + From 064c6167874c277638aa5ab356b9db6ad49c2959 Mon Sep 17 00:00:00 2001 From: Yanick Witschi Date: Fri, 23 Mar 2018 15:13:19 +0100 Subject: [PATCH 6/6] Added a changelog entry --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) 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