diff --git a/UPGRADE-5.1.md b/UPGRADE-5.1.md index 96c1c8b0e2d9..0e144f04a2f1 100644 --- a/UPGRADE-5.1.md +++ b/UPGRADE-5.1.md @@ -91,6 +91,9 @@ Security {% endif %} ``` + * Deprecated `LogoutSuccessHandlerInterface` and `LogoutHandlerInterface`, register a listener on the `LogoutEvent` event instead. + * Deprecated `DefaultLogoutSuccessHandler` in favor of `DefaultLogoutListener`. + Yaml ---- diff --git a/UPGRADE-6.0.md b/UPGRADE-6.0.md index 4180954165f5..647a9734265f 100644 --- a/UPGRADE-6.0.md +++ b/UPGRADE-6.0.md @@ -60,3 +60,5 @@ Security -------- * Removed `ROLE_PREVIOUS_ADMIN` role in favor of `IS_IMPERSONATOR` attribute + * Removed `LogoutSuccessHandlerInterface` and `LogoutHandlerInterface`, register a listener on the `LogoutEvent` event instead. + * Removed `DefaultLogoutSuccessHandler` in favor of `DefaultLogoutListener`. diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Compiler/RegisterCsrfTokenClearingLogoutHandlerPass.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Compiler/RegisterCsrfTokenClearingLogoutHandlerPass.php index 0d7527c26bb7..2d6960e1fe45 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Compiler/RegisterCsrfTokenClearingLogoutHandlerPass.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Compiler/RegisterCsrfTokenClearingLogoutHandlerPass.php @@ -14,6 +14,7 @@ use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\Security\Http\EventListener\CsrfTokenClearingLogoutListener; /** * @author Christian Flothmann @@ -33,10 +34,9 @@ public function process(ContainerBuilder $container) return; } - $container->register('security.logout.handler.csrf_token_clearing', 'Symfony\Component\Security\Http\Logout\CsrfTokenClearingLogoutHandler') + $container->register('security.logout.listener.csrf_token_clearing', CsrfTokenClearingLogoutListener::class) ->addArgument(new Reference('security.csrf.token_storage')) + ->addTag('kernel.event_subscriber') ->setPublic(false); - - $container->findDefinition('security.logout_listener')->addMethodCall('addHandler', [new Reference('security.logout.handler.csrf_token_clearing')]); } } diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php index 6361b0b4c36c..c2251ad1f544 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php @@ -16,6 +16,7 @@ use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; use Symfony\Component\Security\Core\Authorization\AccessDecisionManager; +use Symfony\Component\Security\Http\Event\LogoutEvent; use Symfony\Component\Security\Http\Session\SessionAuthenticationStrategy; /** @@ -205,7 +206,7 @@ private function addFirewallsSection(ArrayNodeDefinition $rootNode, array $facto ->scalarNode('csrf_token_id')->defaultValue('logout')->end() ->scalarNode('path')->defaultValue('/logout')->end() ->scalarNode('target')->defaultValue('/')->end() - ->scalarNode('success_handler')->end() + ->scalarNode('success_handler')->setDeprecated(sprintf('The "%%node%%" at path "%%path%%" is deprecated, register a listener on the "%s" event instead.', LogoutEvent::class))->end() ->booleanNode('invalidate_session')->defaultTrue()->end() ->end() ->fixXmlConfig('delete_cookie') @@ -228,7 +229,7 @@ private function addFirewallsSection(ArrayNodeDefinition $rootNode, array $facto ->fixXmlConfig('handler') ->children() ->arrayNode('handlers') - ->prototype('scalar')->end() + ->prototype('scalar')->setDeprecated(sprintf('The "%%node%%" at path "%%path%%" is deprecated, register a listener on the "%s" event instead.', LogoutEvent::class))->end() ->end() ->end() ->end() diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/RememberMeFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/RememberMeFactory.php index a17f799b6c41..06ad4134bd1e 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/RememberMeFactory.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/RememberMeFactory.php @@ -15,8 +15,10 @@ use Symfony\Component\DependencyInjection\Argument\IteratorArgument; use Symfony\Component\DependencyInjection\ChildDefinition; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\HttpFoundation\Cookie; +use Symfony\Component\Security\Http\EventListener\RememberMeLogoutListener; class RememberMeFactory implements SecurityFactoryInterface { @@ -55,13 +57,6 @@ public function create(ContainerBuilder $container, string $id, array $config, ? $rememberMeServicesId = $templateId.'.'.$id; } - if ($container->hasDefinition('security.logout_listener.'.$id)) { - $container - ->getDefinition('security.logout_listener.'.$id) - ->addMethodCall('addHandler', [new Reference($rememberMeServicesId)]) - ; - } - $rememberMeServices = $container->setDefinition($rememberMeServicesId, new ChildDefinition($templateId)); $rememberMeServices->replaceArgument(1, $config['secret']); $rememberMeServices->replaceArgument(2, $id); @@ -116,6 +111,11 @@ public function create(ContainerBuilder $container, string $id, array $config, ? $listener->replaceArgument(1, new Reference($rememberMeServicesId)); $listener->replaceArgument(5, $config['catch_exceptions']); + // remember-me logout listener + $container->setDefinition('security.logout.listener.remember_me.'.$id, new Definition(RememberMeLogoutListener::class)) + ->addArgument(new Reference($rememberMeServicesId)) + ->addTag('kernel.event_subscriber', ['dispatcher' => 'security.event_dispatcher.'.$id]); + return [$authProviderId, $listenerId, $defaultEntryPoint]; } diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php index 851f7da78690..293e88856f62 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php @@ -14,6 +14,7 @@ use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\RememberMeFactory; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\SecurityFactoryInterface; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\UserProvider\UserProviderFactoryInterface; +use Symfony\Bundle\SecurityBundle\Security\LegacyLogoutHandlerListener; use Symfony\Bundle\SecurityBundle\SecurityUserValueResolver; use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; use Symfony\Component\Config\FileLocator; @@ -26,6 +27,7 @@ use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; use Symfony\Component\DependencyInjection\Loader\XmlFileLoader; use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\HttpKernel\DependencyInjection\Extension; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; use Symfony\Component\Security\Core\Encoder\NativePasswordEncoder; @@ -307,6 +309,12 @@ private function createFirewall(ContainerBuilder $container, string $id, array $ $config->replaceArgument(5, $defaultProvider); + // Register Firewall-specific event dispatcher + $firewallEventDispatcherId = 'security.event_dispatcher.'.$id; + $container->register($firewallEventDispatcherId, EventDispatcher::class); + $container->setDefinition($firewallEventDispatcherId.'.event_bubbling_listener', new ChildDefinition('security.event_dispatcher.event_bubbling_listener')) + ->addTag('kernel.event_subscriber', ['dispatcher' => $firewallEventDispatcherId]); + // Register listeners $listeners = []; $listenerKeys = []; @@ -334,44 +342,50 @@ private function createFirewall(ContainerBuilder $container, string $id, array $ if (isset($firewall['logout'])) { $logoutListenerId = 'security.logout_listener.'.$id; $logoutListener = $container->setDefinition($logoutListenerId, new ChildDefinition('security.logout_listener')); + $logoutListener->replaceArgument(2, new Reference($firewallEventDispatcherId)); $logoutListener->replaceArgument(3, [ 'csrf_parameter' => $firewall['logout']['csrf_parameter'], 'csrf_token_id' => $firewall['logout']['csrf_token_id'], 'logout_path' => $firewall['logout']['path'], ]); - // add logout success handler + // add default logout listener if (isset($firewall['logout']['success_handler'])) { + // deprecated, to be removed in Symfony 6.0 $logoutSuccessHandlerId = $firewall['logout']['success_handler']; + $container->register('security.logout.listener.legacy_success_listener.'.$id, LegacyLogoutHandlerListener::class) + ->setArguments([new Reference($logoutSuccessHandlerId)]) + ->addTag('kernel.event_subscriber', ['dispatcher' => $firewallEventDispatcherId]); } else { - $logoutSuccessHandlerId = 'security.logout.success_handler.'.$id; - $logoutSuccessHandler = $container->setDefinition($logoutSuccessHandlerId, new ChildDefinition('security.logout.success_handler')); - $logoutSuccessHandler->replaceArgument(1, $firewall['logout']['target']); + $logoutSuccessListenerId = 'security.logout.listener.default.'.$id; + $container->setDefinition($logoutSuccessListenerId, new ChildDefinition('security.logout.listener.default')) + ->replaceArgument(1, $firewall['logout']['target']) + ->addTag('kernel.event_subscriber', ['dispatcher' => $firewallEventDispatcherId]); } - $logoutListener->replaceArgument(2, new Reference($logoutSuccessHandlerId)); // add CSRF provider if (isset($firewall['logout']['csrf_token_generator'])) { $logoutListener->addArgument(new Reference($firewall['logout']['csrf_token_generator'])); } - // add session logout handler + // add session logout listener if (true === $firewall['logout']['invalidate_session'] && false === $firewall['stateless']) { - $logoutListener->addMethodCall('addHandler', [new Reference('security.logout.handler.session')]); + $container->setDefinition('security.logout.listener.session.'.$id, new ChildDefinition('security.logout.listener.session')) + ->addTag('kernel.event_subscriber', ['dispatcher' => $firewallEventDispatcherId]); } - // add cookie logout handler + // add cookie logout listener if (\count($firewall['logout']['delete_cookies']) > 0) { - $cookieHandlerId = 'security.logout.handler.cookie_clearing.'.$id; - $cookieHandler = $container->setDefinition($cookieHandlerId, new ChildDefinition('security.logout.handler.cookie_clearing')); - $cookieHandler->addArgument($firewall['logout']['delete_cookies']); - - $logoutListener->addMethodCall('addHandler', [new Reference($cookieHandlerId)]); + $container->setDefinition('security.logout.listener.cookie_clearing.'.$id, new ChildDefinition('security.logout.listener.cookie_clearing')) + ->addArgument($firewall['logout']['delete_cookies']) + ->addTag('kernel.event_subscriber', ['dispatcher' => $firewallEventDispatcherId]); } - // add custom handlers - foreach ($firewall['logout']['handlers'] as $handlerId) { - $logoutListener->addMethodCall('addHandler', [new Reference($handlerId)]); + // add custom listeners (deprecated) + foreach ($firewall['logout']['handlers'] as $i => $handlerId) { + $container->register('security.logout.listener.legacy_handler.'.$i, LegacyLogoutHandlerListener::class) + ->addArgument(new Reference($handlerId)) + ->addTag('kernel.event_subscriber', ['dispatcher' => $firewallEventDispatcherId]); } // register with LogoutUrlGenerator diff --git a/src/Symfony/Bundle/SecurityBundle/EventListener/FirewallEventBubblingListener.php b/src/Symfony/Bundle/SecurityBundle/EventListener/FirewallEventBubblingListener.php new file mode 100644 index 000000000000..c3415ccc8c84 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/EventListener/FirewallEventBubblingListener.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\SecurityBundle\EventListener; + +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\Security\Http\Event\LogoutEvent; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; + +/** + * A listener that dispatches all security events from the firewall-specific + * dispatcher on the global event dispatcher. + * + * @author Wouter de Jong + */ +class FirewallEventBubblingListener implements EventSubscriberInterface +{ + private $eventDispatcher; + + public function __construct(EventDispatcherInterface $eventDispatcher) + { + $this->eventDispatcher = $eventDispatcher; + } + + public static function getSubscribedEvents(): array + { + return [ + LogoutEvent::class => 'bubbleEvent', + ]; + } + + public function bubbleEvent($event): void + { + $this->eventDispatcher->dispatch($event); + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/security.xml b/src/Symfony/Bundle/SecurityBundle/Resources/config/security.xml index 1f0e64b80348..28dceee7de11 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/security.xml +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/security.xml @@ -90,6 +90,10 @@ + + + + diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_listeners.xml b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_listeners.xml index 6b4b441c9835..8b14cfd9e0c5 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_listeners.xml +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_listeners.xml @@ -48,17 +48,17 @@ - + - + - + - + - / + / diff --git a/src/Symfony/Bundle/SecurityBundle/Security/LegacyLogoutHandlerListener.php b/src/Symfony/Bundle/SecurityBundle/Security/LegacyLogoutHandlerListener.php new file mode 100644 index 000000000000..cde709339e5d --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Security/LegacyLogoutHandlerListener.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\SecurityBundle\Security; + +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\Security\Http\Event\LogoutEvent; +use Symfony\Component\Security\Http\Logout\LogoutHandlerInterface; +use Symfony\Component\Security\Http\Logout\LogoutSuccessHandlerInterface; + +/** + * @author Wouter de Jong + * + * @internal + */ +class LegacyLogoutHandlerListener implements EventSubscriberInterface +{ + private $logoutHandler; + + public function __construct(object $logoutHandler) + { + if (!$logoutHandler instanceof LogoutSuccessHandlerInterface && !$logoutHandler instanceof LogoutHandlerInterface) { + throw new \InvalidArgumentException(sprintf('An instance of "%s" or "%s" must be passed to "%s", "%s" given.', LogoutHandlerInterface::class, LogoutSuccessHandlerInterface::class, __METHOD__, get_debug_type($logoutHandler))); + } + + $this->logoutHandler = $logoutHandler; + } + + public function onLogout(LogoutEvent $event): void + { + if ($this->logoutHandler instanceof LogoutSuccessHandlerInterface) { + $event->setResponse($this->logoutHandler->onLogoutSuccess($event->getRequest())); + } elseif ($this->logoutHandler instanceof LogoutHandlerInterface) { + $this->logoutHandler->logout($event->getRequest(), $event->getResponse(), $event->getToken()); + } + } + + public static function getSubscribedEvents(): array + { + return [ + LogoutEvent::class => 'onLogout', + ]; + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/composer.json b/src/Symfony/Bundle/SecurityBundle/composer.json index 0843a4659ad3..b06d8b4c3a05 100644 --- a/src/Symfony/Bundle/SecurityBundle/composer.json +++ b/src/Symfony/Bundle/SecurityBundle/composer.json @@ -20,6 +20,7 @@ "ext-xml": "*", "symfony/config": "^4.4|^5.0", "symfony/dependency-injection": "^4.4|^5.0", + "symfony/event-dispatcher": "^5.1", "symfony/http-kernel": "^5.0", "symfony/polyfill-php80": "^1.15", "symfony/security-core": "^4.4|^5.0", diff --git a/src/Symfony/Component/Security/CHANGELOG.md b/src/Symfony/Component/Security/CHANGELOG.md index 9f81f45191b7..da0d2cb8aa28 100644 --- a/src/Symfony/Component/Security/CHANGELOG.md +++ b/src/Symfony/Component/Security/CHANGELOG.md @@ -7,6 +7,8 @@ CHANGELOG * Added access decision strategy to override access decisions by voter service priority * Added `IS_ANONYMOUS`, `IS_REMEMBERED`, `IS_IMPERSONATOR` * Hash the persistent RememberMe token value in database. + * Added `LogoutEvent` to allow custom logout listeners. + * Deprecated `LogoutSuccessHandlerInterface` and `LogoutHandlerInterface` in favor of listening on the `LogoutEvent`. 5.0.0 ----- diff --git a/src/Symfony/Component/Security/Http/Event/LogoutEvent.php b/src/Symfony/Component/Security/Http/Event/LogoutEvent.php new file mode 100644 index 000000000000..3c521f1c3198 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Event/LogoutEvent.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Event; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Contracts\EventDispatcher\Event; + +/** + * @author Wouter de Jong + */ +class LogoutEvent extends Event +{ + private $request; + private $response; + private $token; + + public function __construct(Request $request, ?TokenInterface $token) + { + $this->request = $request; + $this->token = $token; + } + + public function getRequest(): Request + { + return $this->request; + } + + public function getToken(): ?TokenInterface + { + return $this->token; + } + + public function setResponse(Response $response): void + { + $this->response = $response; + } + + public function getResponse(): ?Response + { + return $this->response; + } +} diff --git a/src/Symfony/Component/Security/Http/EventListener/CookieClearingLogoutListener.php b/src/Symfony/Component/Security/Http/EventListener/CookieClearingLogoutListener.php new file mode 100644 index 000000000000..ecff5fd03078 --- /dev/null +++ b/src/Symfony/Component/Security/Http/EventListener/CookieClearingLogoutListener.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\EventListener; + +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\Security\Http\Event\LogoutEvent; + +/** + * This listener clears the passed cookies when a user logs out. + * + * @author Johannes M. Schmitt + * + * @final + */ +class CookieClearingLogoutListener implements EventSubscriberInterface +{ + private $cookies; + + /** + * @param array $cookies An array of cookies (keys are names, values contain path and domain) to unset + */ + public function __construct(array $cookies) + { + $this->cookies = $cookies; + } + + public function onLogout(LogoutEvent $event): void + { + if (!$response = $event->getResponse()) { + return; + } + + foreach ($this->cookies as $cookieName => $cookieData) { + $response->headers->clearCookie($cookieName, $cookieData['path'], $cookieData['domain']); + } + } + + public static function getSubscribedEvents(): array + { + return [ + LogoutEvent::class => ['onLogout', -255], + ]; + } +} diff --git a/src/Symfony/Component/Security/Http/EventListener/CsrfTokenClearingLogoutListener.php b/src/Symfony/Component/Security/Http/EventListener/CsrfTokenClearingLogoutListener.php new file mode 100644 index 000000000000..984041ee3c1a --- /dev/null +++ b/src/Symfony/Component/Security/Http/EventListener/CsrfTokenClearingLogoutListener.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\EventListener; + +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\Security\Csrf\TokenStorage\ClearableTokenStorageInterface; +use Symfony\Component\Security\Http\Event\LogoutEvent; + +/** + * @author Christian Flothmann + * + * @final + */ +class CsrfTokenClearingLogoutListener implements EventSubscriberInterface +{ + private $csrfTokenStorage; + + public function __construct(ClearableTokenStorageInterface $csrfTokenStorage) + { + $this->csrfTokenStorage = $csrfTokenStorage; + } + + public function onLogout(LogoutEvent $event): void + { + $this->csrfTokenStorage->clear(); + } + + public static function getSubscribedEvents(): array + { + return [ + LogoutEvent::class => 'onLogout', + ]; + } +} diff --git a/src/Symfony/Component/Security/Http/EventListener/DefaultLogoutListener.php b/src/Symfony/Component/Security/Http/EventListener/DefaultLogoutListener.php new file mode 100644 index 000000000000..8a9e0004e4be --- /dev/null +++ b/src/Symfony/Component/Security/Http/EventListener/DefaultLogoutListener.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\EventListener; + +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\Security\Http\Event\LogoutEvent; +use Symfony\Component\Security\Http\HttpUtils; + +/** + * Default logout listener will redirect users to a configured path. + * + * @author Fabien Potencier + * @author Alexander + * + * @final + */ +class DefaultLogoutListener implements EventSubscriberInterface +{ + private $httpUtils; + private $targetUrl; + + public function __construct(HttpUtils $httpUtils, string $targetUrl = '/') + { + $this->httpUtils = $httpUtils; + $this->targetUrl = $targetUrl; + } + + public function onLogout(LogoutEvent $event): void + { + if (null !== $event->getResponse()) { + return; + } + + $event->setResponse($this->httpUtils->createRedirectResponse($event->getRequest(), $this->targetUrl)); + } + + public static function getSubscribedEvents(): array + { + return [ + LogoutEvent::class => ['onLogout', 64], + ]; + } +} diff --git a/src/Symfony/Component/Security/Http/EventListener/RememberMeLogoutListener.php b/src/Symfony/Component/Security/Http/EventListener/RememberMeLogoutListener.php new file mode 100644 index 000000000000..5fbd94b1a90a --- /dev/null +++ b/src/Symfony/Component/Security/Http/EventListener/RememberMeLogoutListener.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\EventListener; + +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\Security\Core\Exception\LogicException; +use Symfony\Component\Security\Http\Event\LogoutEvent; +use Symfony\Component\Security\Http\RememberMe\AbstractRememberMeServices; + +/** + * @author Wouter de Jong + * + * @final + */ +class RememberMeLogoutListener implements EventSubscriberInterface +{ + private $rememberMeServices; + + public function __construct(AbstractRememberMeServices $rememberMeServices) + { + $this->rememberMeServices = $rememberMeServices; + } + + public function onLogout(LogoutEvent $event): void + { + if (null === $event->getResponse()) { + throw new LogicException(sprintf('No response was set for this logout action. Make sure the DefaultLogoutListener or another listener has set the response before "%s" is called.', __CLASS__)); + } + + $this->rememberMeServices->logout($event->getRequest(), $event->getResponse(), $event->getToken()); + } + + public static function getSubscribedEvents(): array + { + return [ + LogoutEvent::class => 'onLogout', + ]; + } +} diff --git a/src/Symfony/Component/Security/Http/EventListener/SessionLogoutListener.php b/src/Symfony/Component/Security/Http/EventListener/SessionLogoutListener.php new file mode 100644 index 000000000000..64be8e762978 --- /dev/null +++ b/src/Symfony/Component/Security/Http/EventListener/SessionLogoutListener.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\EventListener; + +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\Security\Http\Event\LogoutEvent; + +/** + * Handler for clearing invalidating the current session. + * + * @author Johannes M. Schmitt + * + * @final + */ +class SessionLogoutListener implements EventSubscriberInterface +{ + public function onLogout(LogoutEvent $event): void + { + $event->getRequest()->getSession()->invalidate(); + } + + public static function getSubscribedEvents(): array + { + return [ + LogoutEvent::class => 'onLogout', + ]; + } +} diff --git a/src/Symfony/Component/Security/Http/Firewall/LogoutListener.php b/src/Symfony/Component/Security/Http/Firewall/LogoutListener.php index 1194cea95f1e..d404c976c3d5 100644 --- a/src/Symfony/Component/Security/Http/Firewall/LogoutListener.php +++ b/src/Symfony/Component/Security/Http/Firewall/LogoutListener.php @@ -11,17 +11,21 @@ namespace Symfony\Component\Security\Http\Firewall; +use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Event\RequestEvent; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Symfony\Component\Security\Core\Exception\LogicException; use Symfony\Component\Security\Core\Exception\LogoutException; use Symfony\Component\Security\Csrf\CsrfToken; use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; +use Symfony\Component\Security\Http\Event\LogoutEvent; use Symfony\Component\Security\Http\HttpUtils; use Symfony\Component\Security\Http\Logout\LogoutHandlerInterface; use Symfony\Component\Security\Http\Logout\LogoutSuccessHandlerInterface; use Symfony\Component\Security\Http\ParameterBagUtils; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; /** * LogoutListener logout users. @@ -34,16 +38,30 @@ class LogoutListener extends AbstractListener { private $tokenStorage; private $options; - private $handlers; - private $successHandler; private $httpUtils; private $csrfTokenManager; + private $eventDispatcher; /** - * @param array $options An array of options to process a logout attempt + * @param EventDispatcherInterface $eventDispatcher + * @param array $options An array of options to process a logout attempt */ - public function __construct(TokenStorageInterface $tokenStorage, HttpUtils $httpUtils, LogoutSuccessHandlerInterface $successHandler, array $options = [], CsrfTokenManagerInterface $csrfTokenManager = null) + public function __construct(TokenStorageInterface $tokenStorage, HttpUtils $httpUtils, /* EventDispatcherInterface */$eventDispatcher, array $options = [], CsrfTokenManagerInterface $csrfTokenManager = null) { + if (!$eventDispatcher instanceof EventDispatcherInterface) { + trigger_deprecation('symfony/security-http', '5.1', 'Passing a logout success handler to "%s" is deprecated, pass an instance of "%s" instead.', __METHOD__, EventDispatcherInterface::class); + + if (!$eventDispatcher instanceof LogoutSuccessHandlerInterface) { + throw new \TypeError(sprintf('Argument 3 of "%s" must be instance of "%s" or "%s", "%s" given.', __METHOD__, EventDispatcherInterface::class, LogoutSuccessHandlerInterface::class, get_debug_type($eventDispatcher))); + } + + $successHandler = $eventDispatcher; + $eventDispatcher = new EventDispatcher(); + $eventDispatcher->addListener(LogoutEvent::class, function (LogoutEvent $event) use ($successHandler) { + $event->setResponse($r = $successHandler->onLogoutSuccess($event->getRequest())); + }); + } + $this->tokenStorage = $tokenStorage; $this->httpUtils = $httpUtils; $this->options = array_merge([ @@ -51,14 +69,24 @@ public function __construct(TokenStorageInterface $tokenStorage, HttpUtils $http 'csrf_token_id' => 'logout', 'logout_path' => '/logout', ], $options); - $this->successHandler = $successHandler; $this->csrfTokenManager = $csrfTokenManager; - $this->handlers = []; + $this->eventDispatcher = $eventDispatcher; } + /** + * @deprecated since version 5.1 + */ public function addHandler(LogoutHandlerInterface $handler) { - $this->handlers[] = $handler; + trigger_deprecation('symfony/security-http', '5.1', 'Calling "%s" is deprecated, register a listener on the "%s" event instead.', __METHOD__, LogoutEvent::class); + + $this->eventDispatcher->addListener(LogoutEvent::class, function (LogoutEvent $event) use ($handler) { + if (null === $event->getResponse()) { + throw new LogicException(sprintf('No response was set for this logout action. Make sure the DefaultLogoutListener or another listener has set the response before "%s" is called.', __CLASS__)); + } + + $handler->logout($event->getRequest(), $event->getResponse(), $event->getToken()); + }); } /** @@ -90,16 +118,12 @@ public function authenticate(RequestEvent $event) } } - $response = $this->successHandler->onLogoutSuccess($request); - if (!$response instanceof Response) { - throw new \RuntimeException('Logout Success Handler did not return a Response.'); - } + $logoutEvent = new LogoutEvent($request, $this->tokenStorage->getToken()); + $this->eventDispatcher->dispatch($logoutEvent); - // handle multiple logout attempts gracefully - if ($token = $this->tokenStorage->getToken()) { - foreach ($this->handlers as $handler) { - $handler->logout($request, $response, $token); - } + $response = $logoutEvent->getResponse(); + if (!$response instanceof Response) { + throw new \RuntimeException('No logout listener set the Response, make sure at least the DefaultLogoutListener is registered.'); } $this->tokenStorage->setToken(null); diff --git a/src/Symfony/Component/Security/Http/Logout/DefaultLogoutSuccessHandler.php b/src/Symfony/Component/Security/Http/Logout/DefaultLogoutSuccessHandler.php index 9f5c959cd23c..51a17e4d6135 100644 --- a/src/Symfony/Component/Security/Http/Logout/DefaultLogoutSuccessHandler.php +++ b/src/Symfony/Component/Security/Http/Logout/DefaultLogoutSuccessHandler.php @@ -12,13 +12,18 @@ namespace Symfony\Component\Security\Http\Logout; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Security\Http\EventListener\DefaultLogoutListener; use Symfony\Component\Security\Http\HttpUtils; +trigger_deprecation('symfony/security-http', '5.1', 'The "%s" class is deprecated, use "%s" instead.', DefaultLogoutSuccessHandler::class, DefaultLogoutListener::class); + /** * Default logout success handler will redirect users to a configured path. * * @author Fabien Potencier * @author Alexander + * + * @deprecated since version 5.1 */ class DefaultLogoutSuccessHandler implements LogoutSuccessHandlerInterface { diff --git a/src/Symfony/Component/Security/Http/Logout/LogoutHandlerInterface.php b/src/Symfony/Component/Security/Http/Logout/LogoutHandlerInterface.php index 92076a94ccc1..4c19b45904d7 100644 --- a/src/Symfony/Component/Security/Http/Logout/LogoutHandlerInterface.php +++ b/src/Symfony/Component/Security/Http/Logout/LogoutHandlerInterface.php @@ -19,6 +19,8 @@ * Interface that needs to be implemented by LogoutHandlers. * * @author Johannes M. Schmitt + * + * @deprecated since Symfony 5.1 */ interface LogoutHandlerInterface { diff --git a/src/Symfony/Component/Security/Http/Logout/LogoutSuccessHandlerInterface.php b/src/Symfony/Component/Security/Http/Logout/LogoutSuccessHandlerInterface.php index c320ad655f27..49606416a281 100644 --- a/src/Symfony/Component/Security/Http/Logout/LogoutSuccessHandlerInterface.php +++ b/src/Symfony/Component/Security/Http/Logout/LogoutSuccessHandlerInterface.php @@ -13,6 +13,9 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Security\Http\Event\LogoutEvent; + +trigger_deprecation('symfony/security-http', '5.1', 'The "%s" interface is deprecated, create a listener for the "%s" event instead.', LogoutSuccessHandlerInterface::class, LogoutEvent::class); /** * LogoutSuccesshandlerInterface. @@ -24,6 +27,8 @@ * LogoutHandlerInterface instead. * * @author Johannes M. Schmitt + * + * @deprecated since Symfony 5.1. */ interface LogoutSuccessHandlerInterface { diff --git a/src/Symfony/Component/Security/Http/Tests/Firewall/LogoutListenerTest.php b/src/Symfony/Component/Security/Http/Tests/Firewall/LogoutListenerTest.php index 3d51a26196a7..76a975d0baea 100644 --- a/src/Symfony/Component/Security/Http/Tests/Firewall/LogoutListenerTest.php +++ b/src/Symfony/Component/Security/Http/Tests/Firewall/LogoutListenerTest.php @@ -12,21 +12,30 @@ namespace Symfony\Component\Security\Http\Tests\Firewall; use PHPUnit\Framework\TestCase; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; +use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Event\RequestEvent; +use Symfony\Component\Security\Http\Event\LogoutEvent; use Symfony\Component\Security\Http\Firewall\LogoutListener; +use Symfony\Component\Security\Http\Logout\LogoutSuccessHandlerInterface; class LogoutListenerTest extends TestCase { + use ExpectDeprecationTrait; + public function testHandleUnmatchedPath() { - list($listener, , $httpUtils, $options) = $this->getListener(); + $dispatcher = $this->getEventDispatcher(); + list($listener, , $httpUtils, $options) = $this->getListener($dispatcher); list($event, $request) = $this->getGetResponseEvent(); - $event->expects($this->never()) - ->method('setResponse'); + $logoutEventDispatched = false; + $dispatcher->addListener(LogoutEvent::class, function (LogoutEvent $event) use (&$logoutEventDispatched) { + $logoutEventDispatched = true; + }); $httpUtils->expects($this->once()) ->method('checkRequestPath') @@ -34,14 +43,16 @@ public function testHandleUnmatchedPath() ->willReturn(false); $listener($event); + + $this->assertFalse($logoutEventDispatched, 'LogoutEvent should not have been dispatched.'); } - public function testHandleMatchedPathWithSuccessHandlerAndCsrfValidation() + public function testHandleMatchedPathWithCsrfValidation() { - $successHandler = $this->getSuccessHandler(); $tokenManager = $this->getTokenManager(); + $dispatcher = $this->getEventDispatcher(); - list($listener, $tokenStorage, $httpUtils, $options) = $this->getListener($successHandler, $tokenManager); + list($listener, $tokenStorage, $httpUtils, $options) = $this->getListener($dispatcher, $tokenManager); list($event, $request) = $this->getGetResponseEvent(); @@ -56,20 +67,15 @@ public function testHandleMatchedPathWithSuccessHandlerAndCsrfValidation() ->method('isTokenValid') ->willReturn(true); - $successHandler->expects($this->once()) - ->method('onLogoutSuccess') - ->with($request) - ->willReturn($response = new Response()); + $response = new Response(); + $dispatcher->addListener(LogoutEvent::class, function (LogoutEvent $event) use ($response) { + $event->setResponse($response); + }); $tokenStorage->expects($this->once()) ->method('getToken') ->willReturn($token = $this->getToken()); - $handler = $this->getHandler(); - $handler->expects($this->once()) - ->method('logout') - ->with($request, $response, $token); - $tokenStorage->expects($this->once()) ->method('setToken') ->with(null); @@ -78,16 +84,13 @@ public function testHandleMatchedPathWithSuccessHandlerAndCsrfValidation() ->method('setResponse') ->with($response); - $listener->addHandler($handler); - $listener($event); } - public function testHandleMatchedPathWithoutSuccessHandlerAndCsrfValidation() + public function testHandleMatchedPathWithoutCsrfValidation() { - $successHandler = $this->getSuccessHandler(); - - list($listener, $tokenStorage, $httpUtils, $options) = $this->getListener($successHandler); + $dispatcher = $this->getEventDispatcher(); + list($listener, $tokenStorage, $httpUtils, $options) = $this->getListener($dispatcher); list($event, $request) = $this->getGetResponseEvent(); @@ -96,20 +99,15 @@ public function testHandleMatchedPathWithoutSuccessHandlerAndCsrfValidation() ->with($request, $options['logout_path']) ->willReturn(true); - $successHandler->expects($this->once()) - ->method('onLogoutSuccess') - ->with($request) - ->willReturn($response = new Response()); + $response = new Response(); + $dispatcher->addListener(LogoutEvent::class, function (LogoutEvent $event) use ($response) { + $event->setResponse($response); + }); $tokenStorage->expects($this->once()) ->method('getToken') ->willReturn($token = $this->getToken()); - $handler = $this->getHandler(); - $handler->expects($this->once()) - ->method('logout') - ->with($request, $response, $token); - $tokenStorage->expects($this->once()) ->method('setToken') ->with(null); @@ -118,17 +116,14 @@ public function testHandleMatchedPathWithoutSuccessHandlerAndCsrfValidation() ->method('setResponse') ->with($response); - $listener->addHandler($handler); - $listener($event); } - public function testSuccessHandlerReturnsNonResponse() + public function testNoResponseSet() { $this->expectException('RuntimeException'); - $successHandler = $this->getSuccessHandler(); - list($listener, , $httpUtils, $options) = $this->getListener($successHandler); + list($listener, , $httpUtils, $options) = $this->getListener(); list($event, $request) = $this->getGetResponseEvent(); @@ -137,11 +132,6 @@ public function testSuccessHandlerReturnsNonResponse() ->with($request, $options['logout_path']) ->willReturn(true); - $successHandler->expects($this->once()) - ->method('onLogoutSuccess') - ->with($request) - ->willReturn(null); - $listener($event); } @@ -168,6 +158,40 @@ public function testCsrfValidationFails() $listener($event); } + /** + * @group legacy + */ + public function testLegacyLogoutHandlers() + { + $this->expectDeprecation('Since symfony/security-http 5.1: The "%s\LogoutSuccessHandlerInterface" interface is deprecated, create a listener for the "%s" event instead.'); + $this->expectDeprecation('Since symfony/security-http 5.1: Passing a logout success handler to "%s\LogoutListener::__construct" is deprecated, pass an instance of "%s" instead.'); + $this->expectDeprecation('Since symfony/security-http 5.1: Calling "%s::addHandler" is deprecated, register a listener on the "%s" event instead.'); + + $logoutSuccessHandler = $this->createMock(LogoutSuccessHandlerInterface::class); + list($listener, $tokenStorage, $httpUtils, $options) = $this->getListener($logoutSuccessHandler); + + $token = $this->getToken(); + $tokenStorage->expects($this->any())->method('getToken')->willReturn($token); + + list($event, $request) = $this->getGetResponseEvent(); + + $httpUtils->expects($this->once()) + ->method('checkRequestPath') + ->with($request, $options['logout_path']) + ->willReturn(true); + + $response = new Response(); + $logoutSuccessHandler->expects($this->any())->method('onLogoutSuccess')->willReturn($response); + + $handler = $this->createMock('Symfony\Component\Security\Http\Logout\LogoutHandlerInterface'); + $handler->expects($this->once())->method('logout')->with($request, $response, $token); + $listener->addHandler($handler); + + $event->expects($this->once())->method('setResponse')->with($this->identicalTo($response)); + + $listener($event); + } + private function getTokenManager() { return $this->getMockBuilder('Symfony\Component\Security\Csrf\CsrfTokenManagerInterface')->getMock(); @@ -191,11 +215,6 @@ private function getGetResponseEvent() return [$event, $request]; } - private function getHandler() - { - return $this->getMockBuilder('Symfony\Component\Security\Http\Logout\LogoutHandlerInterface')->getMock(); - } - private function getHttpUtils() { return $this->getMockBuilder('Symfony\Component\Security\Http\HttpUtils') @@ -203,12 +222,12 @@ private function getHttpUtils() ->getMock(); } - private function getListener($successHandler = null, $tokenManager = null) + private function getListener($eventDispatcher = null, $tokenManager = null) { $listener = new LogoutListener( $tokenStorage = $this->getTokenStorage(), $httpUtils = $this->getHttpUtils(), - $successHandler ?: $this->getSuccessHandler(), + $eventDispatcher ?? $this->getEventDispatcher(), $options = [ 'csrf_parameter' => '_csrf_token', 'csrf_token_id' => 'logout', @@ -221,9 +240,9 @@ private function getListener($successHandler = null, $tokenManager = null) return [$listener, $tokenStorage, $httpUtils, $options]; } - private function getSuccessHandler() + private function getEventDispatcher() { - return $this->getMockBuilder('Symfony\Component\Security\Http\Logout\LogoutSuccessHandlerInterface')->getMock(); + return new EventDispatcher(); } private function getToken() diff --git a/src/Symfony/Component/Security/Http/Tests/Logout/DefaultLogoutSuccessHandlerTest.php b/src/Symfony/Component/Security/Http/Tests/Logout/DefaultLogoutSuccessHandlerTest.php index d0c638323680..2b74b8ccb04f 100644 --- a/src/Symfony/Component/Security/Http/Tests/Logout/DefaultLogoutSuccessHandlerTest.php +++ b/src/Symfony/Component/Security/Http/Tests/Logout/DefaultLogoutSuccessHandlerTest.php @@ -15,6 +15,9 @@ use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\Security\Http\Logout\DefaultLogoutSuccessHandler; +/** + * @group legacy + */ class DefaultLogoutSuccessHandlerTest extends TestCase { public function testLogout()