diff --git a/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md b/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md index 55b08604f4e41..5fc8ddbd80c74 100644 --- a/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md @@ -8,6 +8,7 @@ CHANGELOG * Deprecate the `Symfony\Component\Security\Core\Security` service alias, use `Symfony\Bundle\SecurityBundle\Security\Security` instead * Add `Security::getFirewallConfig()` to help to get the firewall configuration associated to the Request * Add `Security::login()` to login programmatically + * Add `Security::logout()` to logout programmatically 6.1 --- diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php index e9432f8662933..e4f1fddc61f30 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php @@ -376,6 +376,13 @@ private function createFirewall(ContainerBuilder $container, string $id, array $ $container->register($firewallEventDispatcherId, EventDispatcher::class) ->addTag('event_dispatcher.dispatcher', ['name' => $firewallEventDispatcherId]); + $eventDispatcherLocator = $container->getDefinition('security.firewall.event_dispatcher_locator'); + $eventDispatcherLocator + ->replaceArgument(0, array_merge($eventDispatcherLocator->getArgument(0), [ + $id => new ServiceClosureArgument(new Reference($firewallEventDispatcherId)), + ])) + ; + // Register listeners $listeners = []; $listenerKeys = []; @@ -448,6 +455,8 @@ private function createFirewall(ContainerBuilder $container, string $id, array $ false === $firewall['stateless'] && isset($firewall['context']) ? $firewall['context'] : null, ]) ; + + $config->replaceArgument(12, $firewall['logout']); } // Determine default entry point diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/security.php b/src/Symfony/Bundle/SecurityBundle/Resources/config/security.php index f584dae33b2eb..781c782d4ccf7 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/security.php +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/security.php @@ -86,6 +86,8 @@ 'request_stack' => service('request_stack'), 'security.firewall.map' => service('security.firewall.map'), 'security.user_checker' => service('security.user_checker'), + 'security.firewall.event_dispatcher_locator' => service('security.firewall.event_dispatcher_locator'), + 'security.csrf.token_manager' => service('security.csrf.token_manager')->ignoreOnInvalid(), ]), abstract_arg('authenticators'), ]) @@ -206,6 +208,7 @@ null, [], // listeners null, // switch_user + null, // logout ]) ->set('security.logout_url_generator', LogoutUrlGenerator::class) diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_listeners.php b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_listeners.php index dfd94ad0d6f49..9b7ef071d84e6 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_listeners.php +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_listeners.php @@ -11,6 +11,7 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; +use Symfony\Component\DependencyInjection\ServiceLocator; use Symfony\Component\Security\Http\AccessMap; use Symfony\Component\Security\Http\Authentication\CustomAuthenticationFailureHandler; use Symfony\Component\Security\Http\Authentication\CustomAuthenticationSuccessHandler; @@ -160,5 +161,8 @@ service('security.access_map'), ]) ->tag('monolog.logger', ['channel' => 'security']) + + ->set('security.firewall.event_dispatcher_locator', ServiceLocator::class) + ->args([[]]) ; }; diff --git a/src/Symfony/Bundle/SecurityBundle/Security/FirewallConfig.php b/src/Symfony/Bundle/SecurityBundle/Security/FirewallConfig.php index ce9373f91db72..6525a23e4b9c5 100644 --- a/src/Symfony/Bundle/SecurityBundle/Security/FirewallConfig.php +++ b/src/Symfony/Bundle/SecurityBundle/Security/FirewallConfig.php @@ -16,33 +16,21 @@ */ final class FirewallConfig { - private string $name; - private string $userChecker; - private ?string $requestMatcher; - private bool $securityEnabled; - private bool $stateless; - private ?string $provider; - private ?string $context; - private ?string $entryPoint; - private ?string $accessDeniedHandler; - private ?string $accessDeniedUrl; - private array $authenticators; - private ?array $switchUser; - - public function __construct(string $name, string $userChecker, string $requestMatcher = null, bool $securityEnabled = true, bool $stateless = false, string $provider = null, string $context = null, string $entryPoint = null, string $accessDeniedHandler = null, string $accessDeniedUrl = null, array $authenticators = [], array $switchUser = null) - { - $this->name = $name; - $this->userChecker = $userChecker; - $this->requestMatcher = $requestMatcher; - $this->securityEnabled = $securityEnabled; - $this->stateless = $stateless; - $this->provider = $provider; - $this->context = $context; - $this->entryPoint = $entryPoint; - $this->accessDeniedHandler = $accessDeniedHandler; - $this->accessDeniedUrl = $accessDeniedUrl; - $this->authenticators = $authenticators; - $this->switchUser = $switchUser; + public function __construct( + private readonly string $name, + private readonly string $userChecker, + private readonly ?string $requestMatcher = null, + private readonly bool $securityEnabled = true, + private readonly bool $stateless = false, + private readonly ?string $provider = null, + private readonly ?string $context = null, + private readonly ?string $entryPoint = null, + private readonly ?string $accessDeniedHandler = null, + private readonly ?string $accessDeniedUrl = null, + private readonly array $authenticators = [], + private readonly ?array $switchUser = null, + private readonly ?array $logout = null + ) { } public function getName(): string @@ -111,4 +99,9 @@ public function getSwitchUser(): ?array { return $this->switchUser; } + + public function getLogout(): ?array + { + return $this->logout; + } } diff --git a/src/Symfony/Bundle/SecurityBundle/Security/Security.php b/src/Symfony/Bundle/SecurityBundle/Security/Security.php index f733fc12c81e6..94d0370510d45 100644 --- a/src/Symfony/Bundle/SecurityBundle/Security/Security.php +++ b/src/Symfony/Bundle/SecurityBundle/Security/Security.php @@ -13,10 +13,16 @@ use Psr\Container\ContainerInterface; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +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\Core\Security as LegacySecurity; use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\Security\Csrf\CsrfToken; use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface; +use Symfony\Component\Security\Http\Event\LogoutEvent; +use Symfony\Component\Security\Http\ParameterBagUtils; use Symfony\Contracts\Service\ServiceProviderInterface; /** @@ -60,6 +66,48 @@ public function login(UserInterface $user, string $authenticatorName = null, str $this->container->get('security.user_authenticator')->authenticateUser($user, $authenticator, $request); } + /** + * Logout the current user by dispatching the LogoutEvent. + * + * @param bool $validateCsrfToken Whether to look for a valid CSRF token based on the `logout` listener configuration + * + * @return Response|null The LogoutEvent's Response if any + * + * @throws LogoutException When $validateCsrfToken is true and the CSRF token is not found or invalid + */ + public function logout(bool $validateCsrfToken = true): ?Response + { + /** @var TokenStorageInterface $tokenStorage */ + $tokenStorage = $this->container->get('security.token_storage'); + + if (!($token = $tokenStorage->getToken()) || !$token->getUser()) { + throw new LogicException('Unable to logout as there is no logged-in user.'); + } + + $request = $this->container->get('request_stack')->getMainRequest(); + + if (!$firewallConfig = $this->container->get('security.firewall.map')->getFirewallConfig($request)) { + throw new LogicException('Unable to logout as the request is not behind a firewall.'); + } + + if ($validateCsrfToken) { + if (!$this->container->has('security.csrf.token_manager') || !$logoutConfig = $firewallConfig->getLogout()) { + throw new LogicException(sprintf('Unable to logout with CSRF token validation. Either make sure that CSRF protection is enabled and "logout" is configured on the "%s" firewall, or bypass CSRF token validation explicitly by passing false to the $validateCsrfToken argument of this method.', $firewallConfig->getName())); + } + $csrfToken = ParameterBagUtils::getRequestParameterValue($request, $logoutConfig['csrf_parameter']); + if (!\is_string($csrfToken) || !$this->container->get('security.csrf.token_manager')->isTokenValid(new CsrfToken($logoutConfig['csrf_token_id'], $csrfToken))) { + throw new LogoutException('Invalid CSRF token.'); + } + } + + $logoutEvent = new LogoutEvent($request, $token); + $this->container->get('security.firewall.event_dispatcher_locator')->get($firewallConfig->getName())->dispatch($logoutEvent); + + $tokenStorage->setToken(null); + + return $logoutEvent->getResponse(); + } + private function getAuthenticator(?string $authenticatorName, string $firewallName): AuthenticatorInterface { if (!\array_key_exists($firewallName, $this->authenticators)) { diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/CompleteConfigurationTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/CompleteConfigurationTest.php index 7d8384456cea8..8a431f8e416f4 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/CompleteConfigurationTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/CompleteConfigurationTest.php @@ -141,6 +141,7 @@ public function testFirewalls() '', [], null, + null, ], [ 'secure', @@ -165,6 +166,14 @@ public function testFirewalls() 'parameter' => '_switch_user', 'role' => 'ROLE_ALLOWED_TO_SWITCH', ], + [ + 'csrf_parameter' => '_csrf_token', + 'csrf_token_id' => 'logout', + 'path' => '/logout', + 'target' => '/', + 'invalidate_session' => true, + 'delete_cookies' => [], + ], ], [ 'host', @@ -181,6 +190,7 @@ public function testFirewalls() 'http_basic', ], null, + null, ], [ 'with_user_checker', @@ -197,6 +207,7 @@ public function testFirewalls() 'http_basic', ], null, + null, ], ], $configs); diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/SecurityTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/SecurityTest.php index de357a998df23..95161c26ec6db 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/SecurityTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/SecurityTest.php @@ -14,8 +14,12 @@ use Symfony\Bundle\SecurityBundle\Security\FirewallConfig; use Symfony\Bundle\SecurityBundle\Security\Security; use Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\SecuredPageBundle\Security\Core\User\ArrayUserProvider; +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\RequestEvent; +use Symfony\Component\HttpKernel\KernelEvents; use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; use Symfony\Component\Security\Core\User\InMemoryUser; use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; @@ -37,8 +41,10 @@ public function testServiceIsFunctional() $security = $container->get('functional_test.security.helper'); $this->assertTrue($security->isGranted('ROLE_USER')); $this->assertSame($token, $security->getToken()); - $this->assertInstanceOf(FirewallConfig::class, $firewallConfig = $security->getFirewallConfig(new Request())); - $this->assertSame('default', $firewallConfig->getName()); + $request = new Request(); + $request->server->set('REQUEST_URI', '/main/foo'); + $this->assertInstanceOf(FirewallConfig::class, $firewallConfig = $security->getFirewallConfig($request)); + $this->assertSame('main', $firewallConfig->getName()); } /** @@ -85,19 +91,74 @@ public function userWillBeMarkedAsChangedIfRolesHasChangedProvider() } /** - * @testWith ["json_login"] + * @testWith ["form_login"] * ["Symfony\\Bundle\\SecurityBundle\\Tests\\Functional\\Bundle\\AuthenticatorBundle\\ApiAuthenticator"] */ - public function testLoginWithBuiltInAuthenticator(string $authenticator) + public function testLogin(string $authenticator) { - $client = $this->createClient(['test_case' => 'SecurityHelper', 'root_config' => 'config.yml', 'debug' => true]); - static::getContainer()->get(WelcomeController::class)->authenticator = $authenticator; - $client->request('GET', '/welcome'); + $client = $this->createClient(['test_case' => 'SecurityHelper', 'root_config' => 'config.yml', 'debug' > true]); + static::getContainer()->get(ForceLoginController::class)->authenticator = $authenticator; + $client->request('GET', '/main/force-login'); $response = $client->getResponse(); $this->assertInstanceOf(JsonResponse::class, $response); $this->assertSame(200, $response->getStatusCode()); $this->assertSame(['message' => 'Welcome @chalasr!'], json_decode($response->getContent(), true)); + $this->assertSame('chalasr', static::getContainer()->get('security.helper')->getUser()->getUserIdentifier()); + } + + public function testLogout() + { + $client = $this->createClient(['test_case' => 'SecurityHelper', 'root_config' => 'config.yml', 'debug' => true]); + $client->loginUser(new InMemoryUser('chalasr', 'the-password', ['ROLE_FOO']), 'main'); + + $client->request('GET', '/main/force-logout'); + $response = $client->getResponse(); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertNull(static::getContainer()->get('security.helper')->getUser()); + $this->assertSame(['message' => 'Logout successful'], json_decode($response->getContent(), true)); + } + + public function testLogoutWithCsrf() + { + $client = $this->createClient(['test_case' => 'SecurityHelper', 'root_config' => 'config_logout_csrf.yml', 'debug' => true]); + $client->loginUser(new InMemoryUser('chalasr', 'the-password', ['ROLE_FOO']), 'main'); + + // put a csrf token in the storage + /** @var EventDispatcherInterface $eventDispatcher */ + $eventDispatcher = static::getContainer()->get(EventDispatcherInterface::class); + $setCsrfToken = function (RequestEvent $event) { + static::getContainer()->get('security.csrf.token_storage')->setToken('logout', 'bar'); + $event->setResponse(new Response('')); + }; + $eventDispatcher->addListener(KernelEvents::REQUEST, $setCsrfToken); + try { + $client->request('GET', '/'.uniqid('', true)); + } finally { + $eventDispatcher->removeListener(KernelEvents::REQUEST, $setCsrfToken); + } + + static::getContainer()->get(LogoutController::class)->checkCsrf = true; + $client->request('GET', '/main/force-logout', ['_csrf_token' => 'bar']); + $response = $client->getResponse(); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertNull(static::getContainer()->get('security.helper')->getUser()); + $this->assertSame(['message' => 'Logout successful'], json_decode($response->getContent(), true)); + } + + public function testLogoutBypassCsrf() + { + $client = $this->createClient(['test_case' => 'SecurityHelper', 'root_config' => 'config_logout_csrf.yml']); + $client->loginUser(new InMemoryUser('chalasr', 'the-password', ['ROLE_FOO']), 'main'); + + $client->request('GET', '/main/force-logout'); + $response = $client->getResponse(); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertNull(static::getContainer()->get('security.helper')->getUser()); + $this->assertSame(['message' => 'Logout successful'], json_decode($response->getContent(), true)); } } @@ -208,9 +269,9 @@ public function eraseCredentials(): void } } -class WelcomeController +class ForceLoginController { - public $authenticator = 'json_login'; + public $authenticator = 'form_login'; public function __construct(private Security $security) { @@ -218,9 +279,33 @@ public function __construct(private Security $security) public function welcome() { - $user = new InMemoryUser('chalasr', '', ['ROLE_USER']); + $user = new InMemoryUser('chalasr', 'the-password', ['ROLE_FOO']); $this->security->login($user, $this->authenticator); return new JsonResponse(['message' => sprintf('Welcome @%s!', $this->security->getUser()->getUserIdentifier())]); } } + +class LogoutController +{ + public $checkCsrf = false; + + public function __construct(private Security $security) + { + } + + public function logout(UserInterface $user) + { + $this->security->logout($this->checkCsrf); + + return new JsonResponse(['message' => 'Logout successful']); + } +} + +class LoggedInController +{ + public function __invoke(UserInterface $user) + { + return new JsonResponse(['message' => sprintf('Welcome back @%s', $user->getUserIdentifier())]); + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/SecurityHelper/config.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/SecurityHelper/config.yml index f46a1fa682159..1a93b66275a1c 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/SecurityHelper/config.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/SecurityHelper/config.yml @@ -2,7 +2,6 @@ imports: - { resource: ./../config/framework.yml } services: - # alias the service so we can access it in the tests functional_test.security.helper: alias: security.helper public: true @@ -11,7 +10,15 @@ services: alias: security.token_storage public: true - Symfony\Bundle\SecurityBundle\Tests\Functional\WelcomeController: + Symfony\Bundle\SecurityBundle\Tests\Functional\ForceLoginController: + arguments: ['@security.helper'] + public: true + + Symfony\Bundle\SecurityBundle\Tests\Functional\LogoutController: + arguments: ['@security.helper'] + public: true + + Symfony\Bundle\SecurityBundle\Tests\Functional\LoggedInController: arguments: ['@security.helper'] public: true @@ -19,18 +26,23 @@ services: security: enable_authenticator_manager: true + providers: - in_memory: + main: memory: - users: [] + users: + chalasr: { password: the-password, roles: ['ROLE_FOO'] } + no-role-username: { password: the-password, roles: [] } firewalls: - default: - json_login: - username_path: user.login - password_path: user.password + main: + pattern: ^/main + form_login: + check_path: /main/login/check custom_authenticators: - 'Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\AuthenticatorBundle\ApiAuthenticator' + provider: main access_control: - - { path: ^/foo, roles: PUBLIC_ACCESS } + - { path: '^/main/login/check$', roles: IS_AUTHENTICATED_FULLY } + - { path: '^/main/logged-in$', roles: IS_AUTHENTICATED_FULLY } diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/SecurityHelper/config_logout_csrf.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/SecurityHelper/config_logout_csrf.yml new file mode 100644 index 0000000000000..86cfe2b1e6d8b --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/SecurityHelper/config_logout_csrf.yml @@ -0,0 +1,48 @@ +imports: + - { resource: ./../config/framework.yml } + +services: + functional_test.security.helper: + alias: security.helper + public: true + + functional.test.security.token_storage: + alias: security.token_storage + public: true + + Symfony\Bundle\SecurityBundle\Tests\Functional\ForceLoginController: + arguments: ['@security.helper'] + public: true + + Symfony\Bundle\SecurityBundle\Tests\Functional\LogoutController: + arguments: ['@security.helper'] + public: true + + Symfony\Bundle\SecurityBundle\Tests\Functional\LoggedInController: + arguments: ['@security.helper'] + public: true + + Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\AuthenticatorBundle\ApiAuthenticator: ~ + +security: + enable_authenticator_manager: true + + providers: + main: + memory: + users: + chalasr: { password: the-password, roles: ['ROLE_FOO'] } + no-role-username: { password: the-password, roles: [] } + + firewalls: + main: + pattern: ^/main + form_login: + check_path: /main/login/check + custom_authenticators: + - 'Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\AuthenticatorBundle\ApiAuthenticator' + provider: main + + access_control: + - { path: '^/main/logged-in$', roles: IS_AUTHENTICATED_FULLY } + - { path: '^/main/force-logout$', roles: IS_AUTHENTICATED_FULLY } diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/SecurityHelper/routing.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/SecurityHelper/routing.yml index cbe982ae6f310..1f74d27501207 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/SecurityHelper/routing.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/SecurityHelper/routing.yml @@ -1,3 +1,11 @@ -welcome: - path: /welcome - defaults: { _controller: Symfony\Bundle\SecurityBundle\Tests\Functional\WelcomeController::welcome } +force-login: + path: /main/force-login + controller: Symfony\Bundle\SecurityBundle\Tests\Functional\ForceLoginController::welcome + +logged-in: + path: /main/logged-in + controller: Symfony\Bundle\SecurityBundle\Tests\Functional\LoggedInController + +force-logout: + path: /main/force-logout + controller: Symfony\Bundle\SecurityBundle\Tests\Functional\LogoutController::logout diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Security/SecurityTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/Security/SecurityTest.php index 1929d688701c2..3eefbc8bccc68 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Security/SecurityTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Security/SecurityTest.php @@ -19,15 +19,21 @@ use Symfony\Component\DependencyInjection\ServiceLocator; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; +use Symfony\Component\Security\Core\Exception\LogicException; use Symfony\Component\Security\Core\User\InMemoryUser; use Symfony\Component\Security\Core\User\UserCheckerInterface; use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\Security\Csrf\CsrfToken; +use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; use Symfony\Component\Security\Http\Authentication\UserAuthenticatorInterface; use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface; +use Symfony\Component\Security\Http\Event\LogoutEvent; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; use Symfony\Contracts\Service\ServiceProviderInterface; class SecurityTest extends TestCase @@ -117,7 +123,7 @@ public function getFirewallConfigTests() yield [$request, new FirewallConfig('main', 'acme_user_checker')]; } - public function testAutoLogin() + public function testLogin() { $request = new Request(); $authenticator = $this->createMock(AuthenticatorInterface::class); @@ -163,6 +169,212 @@ public function testAutoLogin() $security->login($user); } + public function testLogout() + { + $request = new Request(); + $requestStack = $this->createMock(RequestStack::class); + $requestStack->expects($this->once())->method('getMainRequest')->willReturn($request); + + $token = $this->createMock(TokenInterface::class); + $token->method('getUser')->willReturn(new InMemoryUser('foo', 'bar')); + $tokenStorage = $this->createMock(TokenStorageInterface::class); + $tokenStorage->expects($this->once())->method('getToken')->willReturn($token); + $tokenStorage->expects($this->once())->method('setToken')->with(null); + + $eventDispatcher = $this->createMock(EventDispatcherInterface::class); + $eventDispatcher + ->expects($this->once()) + ->method('dispatch') + ->with(new LogoutEvent($request, $token)) + ; + + $firewallMap = $this->createMock(FirewallMap::class); + $firewallConfig = new FirewallConfig('my_firewall', 'user_checker'); + $firewallMap + ->expects($this->once()) + ->method('getFirewallConfig') + ->willReturn($firewallConfig) + ; + + $eventDispatcherLocator = $this->createMock(ContainerInterface::class); + $eventDispatcherLocator + ->expects($this->atLeastOnce()) + ->method('get') + ->willReturnMap([ + ['my_firewall', $eventDispatcher], + ]) + ; + + $container = $this->createMock(ContainerInterface::class); + $container + ->expects($this->atLeastOnce()) + ->method('get') + ->willReturnMap([ + ['request_stack', $requestStack], + ['security.token_storage', $tokenStorage], + ['security.firewall.map', $firewallMap], + ['security.firewall.event_dispatcher_locator', $eventDispatcherLocator], + ]) + ; + $security = new Security($container); + $security->logout(false); + } + + public function testLogoutWithoutFirewall() + { + $request = new Request(); + $requestStack = $this->createMock(RequestStack::class); + $requestStack->expects($this->once())->method('getMainRequest')->willReturn($request); + + $token = $this->createMock(TokenInterface::class); + $token->method('getUser')->willReturn(new InMemoryUser('foo', 'bar')); + $tokenStorage = $this->createMock(TokenStorageInterface::class); + $tokenStorage + ->expects($this->once()) + ->method('getToken') + ->willReturn($token) + ; + + $firewallMap = $this->createMock(FirewallMap::class); + $firewallMap + ->expects($this->once()) + ->method('getFirewallConfig') + ->willReturn(null) + ; + + $container = $this->createMock(ContainerInterface::class); + $container + ->expects($this->atLeastOnce()) + ->method('get') + ->willReturnMap([ + ['request_stack', $requestStack], + ['security.token_storage', $tokenStorage], + ['security.firewall.map', $firewallMap], + ]) + ; + + $this->expectException(LogicException::class); + $security = new Security($container); + $security->logout(false); + } + + public function testLogoutWithResponse() + { + $request = new Request(); + $requestStack = $this->createMock(RequestStack::class); + $requestStack->expects($this->once())->method('getMainRequest')->willReturn($request); + + $token = $this->createMock(TokenInterface::class); + $token->method('getUser')->willReturn(new InMemoryUser('foo', 'bar')); + $tokenStorage = $this->createMock(TokenStorageInterface::class); + $tokenStorage->expects($this->once())->method('getToken')->willReturn($token); + $tokenStorage->expects($this->once())->method('setToken')->with(null); + + $eventDispatcher = $this->createMock(EventDispatcherInterface::class); + $eventDispatcher + ->expects($this->once()) + ->method('dispatch') + ->willReturnCallback(function ($event) use ($request, $token) { + $this->assertInstanceOf(LogoutEvent::class, $event); + $this->assertEquals($request, $event->getRequest()); + $this->assertEquals($token, $event->getToken()); + + $event->setResponse(new Response('a custom response')); + + return $event; + }) + ; + + $firewallMap = $this->createMock(FirewallMap::class); + $firewallConfig = new FirewallConfig('my_firewall', 'user_checker'); + $firewallMap->expects($this->once())->method('getFirewallConfig')->willReturn($firewallConfig); + + $eventDispatcherLocator = $this->createMock(ContainerInterface::class); + $eventDispatcherLocator + ->expects($this->atLeastOnce()) + ->method('get') + ->willReturnMap([['my_firewall', $eventDispatcher]]) + ; + + $container = $this->createMock(ContainerInterface::class); + $container + ->expects($this->atLeastOnce()) + ->method('get') + ->willReturnMap([ + ['request_stack', $requestStack], + ['security.token_storage', $tokenStorage], + ['security.firewall.map', $firewallMap], + ['security.firewall.event_dispatcher_locator', $eventDispatcherLocator], + ]) + ; + $security = new Security($container); + $response = $security->logout(false); + + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals('a custom response', $response->getContent()); + } + + public function testLogoutWithValidCsrf() + { + $request = new Request(['_csrf_token' => 'dummytoken']); + $requestStack = $this->createMock(RequestStack::class); + $requestStack->expects($this->once())->method('getMainRequest')->willReturn($request); + + $token = $this->createMock(TokenInterface::class); + $token->method('getUser')->willReturn(new InMemoryUser('foo', 'bar')); + $tokenStorage = $this->createMock(TokenStorageInterface::class); + $tokenStorage->expects($this->once())->method('getToken')->willReturn($token); + $tokenStorage->expects($this->once())->method('setToken')->with(null); + + $eventDispatcher = $this->createMock(EventDispatcherInterface::class); + $eventDispatcher + ->expects($this->once()) + ->method('dispatch') + ->willReturnCallback(function ($event) use ($request, $token) { + $this->assertInstanceOf(LogoutEvent::class, $event); + $this->assertEquals($request, $event->getRequest()); + $this->assertEquals($token, $event->getToken()); + + $event->setResponse(new Response('a custom response')); + + return $event; + }) + ; + + $firewallMap = $this->createMock(FirewallMap::class); + $firewallConfig = new FirewallConfig(name: 'my_firewall', userChecker: 'user_checker', logout: ['csrf_parameter' => '_csrf_token', 'csrf_token_id' => 'logout']); + $firewallMap->expects($this->once())->method('getFirewallConfig')->willReturn($firewallConfig); + + $eventDispatcherLocator = $this->createMock(ContainerInterface::class); + $eventDispatcherLocator + ->expects($this->atLeastOnce()) + ->method('get') + ->willReturnMap([['my_firewall', $eventDispatcher]]) + ; + + $csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class); + $csrfTokenManager->expects($this->once())->method('isTokenValid')->with($this->equalTo(new CsrfToken('logout', 'dummytoken')))->willReturn(true); + + $container = $this->createMock(ContainerInterface::class); + $container->expects($this->once())->method('has')->with('security.csrf.token_manager')->willReturn(true); + $container + ->expects($this->atLeastOnce()) + ->method('get') + ->willReturnMap([ + ['request_stack', $requestStack], + ['security.token_storage', $tokenStorage], + ['security.firewall.map', $firewallMap], + ['security.firewall.event_dispatcher_locator', $eventDispatcherLocator], + ['security.csrf.token_manager', $csrfTokenManager], + ]) + ; + $security = new Security($container); + $response = $security->logout(); + + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals('a custom response', $response->getContent()); + } + private function createContainer(string $serviceId, object $serviceObject): ContainerInterface { return new ServiceLocator([$serviceId => fn () => $serviceObject]);