Skip to content

Commit f576173

Browse files
johnkrovitchchalasr
authored andcommitted
[Security] Add a method in the security helper to ease programmatic logout (symfony#40663)
1 parent cc66741 commit f576173

File tree

9 files changed

+257
-2
lines changed

9 files changed

+257
-2
lines changed

src/Symfony/Bundle/SecurityBundle/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ CHANGELOG
88
* Deprecate the `Symfony\Component\Security\Core\Security` service alias, use `Symfony\Bundle\SecurityBundle\Security\Security` instead
99
* Add `Security::getFirewallConfig()` to help to get the firewall configuration associated to the Request
1010
* Add `Security::login()` to login programmatically
11+
* Add `Security::logout()` to logout programmatically
1112

1213
6.1
1314
---

src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,13 @@ private function createFirewall(ContainerBuilder $container, string $id, array $
376376
$container->register($firewallEventDispatcherId, EventDispatcher::class)
377377
->addTag('event_dispatcher.dispatcher', ['name' => $firewallEventDispatcherId]);
378378

379+
$eventDispatcherLocator = $container->getDefinition('security.firewall.event_dispatcher_locator');
380+
$eventDispatcherLocator
381+
->replaceArgument(0, array_merge($eventDispatcherLocator->getArgument(0), [
382+
$id => new ServiceClosureArgument(new Reference($firewallEventDispatcherId)),
383+
]))
384+
;
385+
379386
// Register listeners
380387
$listeners = [];
381388
$listenerKeys = [];

src/Symfony/Bundle/SecurityBundle/Resources/config/security.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@
8686
'request_stack' => service('request_stack'),
8787
'security.firewall.map' => service('security.firewall.map'),
8888
'security.user_checker' => service('security.user_checker'),
89+
'security.firewall.event_dispatcher_locator' => service('security.firewall.event_dispatcher_locator'),
8990
]),
9091
abstract_arg('authenticators'),
9192
])

src/Symfony/Bundle/SecurityBundle/Resources/config/security_listeners.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
1313

14+
use Symfony\Component\DependencyInjection\ServiceLocator;
1415
use Symfony\Component\Security\Http\AccessMap;
1516
use Symfony\Component\Security\Http\Authentication\CustomAuthenticationFailureHandler;
1617
use Symfony\Component\Security\Http\Authentication\CustomAuthenticationSuccessHandler;
@@ -160,5 +161,8 @@
160161
service('security.access_map'),
161162
])
162163
->tag('monolog.logger', ['channel' => 'security'])
164+
165+
->set('security.firewall.event_dispatcher_locator', ServiceLocator::class)
166+
->args([[]])
163167
;
164168
};

src/Symfony/Bundle/SecurityBundle/Security/Security.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@
1313

1414
use Psr\Container\ContainerInterface;
1515
use Symfony\Component\HttpFoundation\Request;
16+
use Symfony\Component\HttpFoundation\Response;
1617
use Symfony\Component\Security\Core\Exception\LogicException;
1718
use Symfony\Component\Security\Core\Security as LegacySecurity;
1819
use Symfony\Component\Security\Core\User\UserInterface;
1920
use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface;
21+
use Symfony\Component\Security\Http\Event\LogoutEvent;
2022
use Symfony\Contracts\Service\ServiceProviderInterface;
2123

2224
/**
@@ -60,6 +62,28 @@ public function login(UserInterface $user, string $authenticatorName = null, str
6062
$this->container->get('security.user_authenticator')->authenticateUser($user, $authenticator, $request);
6163
}
6264

65+
/**
66+
* Logout the current user by dispatching the LogoutEvent.
67+
*
68+
* @return Response|null The LogoutEvent's Response if any
69+
*/
70+
public function logout(): ?Response
71+
{
72+
$request = $this->container->get('request_stack')->getMainRequest();
73+
$logoutEvent = new LogoutEvent($request, $this->container->get('security.token_storage')->getToken());
74+
$firewallConfig = $this->container->get('security.firewall.map')->getFirewallConfig($request);
75+
76+
if (!$firewallConfig) {
77+
throw new LogicException('It is not possible to logout, as the request is not behind a firewall.');
78+
}
79+
$firewallName = $firewallConfig->getName();
80+
81+
$this->container->get('security.firewall.event_dispatcher_locator')->get($firewallName)->dispatch($logoutEvent);
82+
$this->container->get('security.token_storage')->setToken();
83+
84+
return $logoutEvent->getResponse();
85+
}
86+
6387
private function getAuthenticator(?string $authenticatorName, string $firewallName): AuthenticatorInterface
6488
{
6589
if (!\array_key_exists($firewallName, $this->authenticators)) {

src/Symfony/Bundle/SecurityBundle/Tests/Functional/SecurityTest.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,24 @@ public function testLoginWithBuiltInAuthenticator(string $authenticator)
9999
$this->assertSame(200, $response->getStatusCode());
100100
$this->assertSame(['message' => 'Welcome @chalasr!'], json_decode($response->getContent(), true));
101101
}
102+
103+
/**
104+
* @testWith ["json_login"]
105+
* ["Symfony\\Bundle\\SecurityBundle\\Tests\\Functional\\Bundle\\AuthenticatorBundle\\ApiAuthenticator"]
106+
*/
107+
public function testLogout(string $authenticator)
108+
{
109+
$client = $this->createClient(['test_case' => 'SecurityHelper', 'root_config' => 'config.yml', 'debug' => true]);
110+
static::getContainer()->get(WelcomeController::class)->authenticator = $authenticator;
111+
$client->request('GET', '/welcome');
112+
$this->assertEquals('chalasr', static::getContainer()->get('security.helper')->getUser()->getUserIdentifier());
113+
114+
$client->request('GET', '/auto-logout');
115+
$response = $client->getResponse();
116+
$this->assertSame(200, $response->getStatusCode());
117+
$this->assertNull(static::getContainer()->get('security.helper')->getUser());
118+
$this->assertSame(['message' => 'Logout successful'], json_decode($response->getContent(), true));
119+
}
102120
}
103121

104122
final class UserWithoutEquatable implements UserInterface, PasswordAuthenticatedUserInterface
@@ -224,3 +242,17 @@ public function welcome()
224242
return new JsonResponse(['message' => sprintf('Welcome @%s!', $this->security->getUser()->getUserIdentifier())]);
225243
}
226244
}
245+
246+
class LogoutController
247+
{
248+
public function __construct(private Security $security)
249+
{
250+
}
251+
252+
public function logout()
253+
{
254+
$this->security->logout();
255+
256+
return new JsonResponse(['message' => 'Logout successful']);
257+
}
258+
}

src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/SecurityHelper/config.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ services:
1515
arguments: ['@security.helper']
1616
public: true
1717

18+
Symfony\Bundle\SecurityBundle\Tests\Functional\LogoutController:
19+
arguments: ['@security.helper']
20+
public: true
21+
1822
Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\AuthenticatorBundle\ApiAuthenticator: ~
1923

2024
security:
Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
11
welcome:
22
path: /welcome
3-
defaults: { _controller: Symfony\Bundle\SecurityBundle\Tests\Functional\WelcomeController::welcome }
3+
controller: Symfony\Bundle\SecurityBundle\Tests\Functional\WelcomeController::welcome
4+
5+
logout:
6+
path: /auto-logout
7+
controller: Symfony\Bundle\SecurityBundle\Tests\Functional\LogoutController::logout

src/Symfony/Bundle/SecurityBundle/Tests/Security/SecurityTest.php

Lines changed: 179 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,19 @@
1919
use Symfony\Component\DependencyInjection\ServiceLocator;
2020
use Symfony\Component\HttpFoundation\Request;
2121
use Symfony\Component\HttpFoundation\RequestStack;
22+
use Symfony\Component\HttpFoundation\Response;
2223
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
2324
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
2425
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
2526
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
27+
use Symfony\Component\Security\Core\Exception\LogicException;
2628
use Symfony\Component\Security\Core\User\InMemoryUser;
2729
use Symfony\Component\Security\Core\User\UserCheckerInterface;
2830
use Symfony\Component\Security\Core\User\UserInterface;
2931
use Symfony\Component\Security\Http\Authentication\UserAuthenticatorInterface;
3032
use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface;
33+
use Symfony\Component\Security\Http\Event\LogoutEvent;
34+
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
3135
use Symfony\Contracts\Service\ServiceProviderInterface;
3236

3337
class SecurityTest extends TestCase
@@ -117,7 +121,7 @@ public function getFirewallConfigTests()
117121
yield [$request, new FirewallConfig('main', 'acme_user_checker')];
118122
}
119123

120-
public function testAutoLogin()
124+
public function testLogin()
121125
{
122126
$request = new Request();
123127
$authenticator = $this->createMock(AuthenticatorInterface::class);
@@ -163,6 +167,180 @@ public function testAutoLogin()
163167
$security->login($user);
164168
}
165169

170+
public function testLogout()
171+
{
172+
$request = new Request();
173+
$requestStack = $this->createMock(RequestStack::class);
174+
$requestStack
175+
->expects($this->once())
176+
->method('getMainRequest')
177+
->willReturn($request)
178+
;
179+
180+
$token = $this->createMock(TokenInterface::class);
181+
$tokenStorage = $this->createMock(TokenStorageInterface::class);
182+
$tokenStorage
183+
->expects($this->once())
184+
->method('getToken')
185+
->willReturn($token)
186+
;
187+
$tokenStorage
188+
->expects($this->once())
189+
->method('setToken')
190+
;
191+
192+
$eventDispatcher = $this->createMock(EventDispatcherInterface::class);
193+
$eventDispatcher
194+
->expects($this->once())
195+
->method('dispatch')
196+
->with(new LogoutEvent($request, $token))
197+
;
198+
199+
$firewallMap = $this->createMock(FirewallMap::class);
200+
$firewallConfig = new FirewallConfig('my_firewall', 'user_checker');
201+
$firewallMap
202+
->expects($this->once())
203+
->method('getFirewallConfig')
204+
->willReturn($firewallConfig)
205+
;
206+
207+
$eventDispatcherLocator = $this->createMock(ContainerInterface::class);
208+
$eventDispatcherLocator
209+
->expects($this->atLeastOnce())
210+
->method('get')
211+
->willReturnMap([
212+
['my_firewall', $eventDispatcher],
213+
])
214+
;
215+
216+
$container = $this->createMock(ContainerInterface::class);
217+
$container
218+
->expects($this->atLeastOnce())
219+
->method('get')
220+
->willReturnMap([
221+
['request_stack', $requestStack],
222+
['security.token_storage', $tokenStorage],
223+
['security.firewall.map', $firewallMap],
224+
['security.firewall.event_dispatcher_locator', $eventDispatcherLocator],
225+
])
226+
;
227+
$security = new Security($container);
228+
$security->logout();
229+
}
230+
231+
public function testLogoutWithoutFirewall()
232+
{
233+
$request = new Request();
234+
$requestStack = $this->createMock(RequestStack::class);
235+
$requestStack
236+
->expects($this->once())
237+
->method('getMainRequest')
238+
->willReturn($request)
239+
;
240+
241+
$token = $this->createMock(TokenInterface::class);
242+
$tokenStorage = $this->createMock(TokenStorageInterface::class);
243+
$tokenStorage
244+
->expects($this->once())
245+
->method('getToken')
246+
->willReturn($token)
247+
;
248+
249+
$firewallMap = $this->createMock(FirewallMap::class);
250+
$firewallMap
251+
->expects($this->once())
252+
->method('getFirewallConfig')
253+
->willReturn(null)
254+
;
255+
256+
$container = $this->createMock(ContainerInterface::class);
257+
$container
258+
->expects($this->atLeastOnce())
259+
->method('get')
260+
->willReturnMap([
261+
['request_stack', $requestStack],
262+
['security.token_storage', $tokenStorage],
263+
['security.firewall.map', $firewallMap],
264+
])
265+
;
266+
267+
$this->expectException(LogicException::class);
268+
$security = new Security($container);
269+
$security->logout();
270+
}
271+
272+
public function testLogoutWithResponse()
273+
{
274+
$request = new Request();
275+
$requestStack = $this->createMock(RequestStack::class);
276+
$requestStack
277+
->expects($this->once())
278+
->method('getMainRequest')
279+
->willReturn($request)
280+
;
281+
282+
$token = $this->createMock(TokenInterface::class);
283+
$tokenStorage = $this->createMock(TokenStorageInterface::class);
284+
$tokenStorage
285+
->expects($this->once())
286+
->method('getToken')
287+
->willReturn($token)
288+
;
289+
$tokenStorage
290+
->expects($this->once())
291+
->method('setToken')
292+
;
293+
294+
$eventDispatcher = $this->createMock(EventDispatcherInterface::class);
295+
$eventDispatcher
296+
->expects($this->once())
297+
->method('dispatch')
298+
->willReturnCallback(function ($event) use ($request, $token) {
299+
$this->assertInstanceOf(LogoutEvent::class, $event);
300+
$this->assertEquals($request, $event->getRequest());
301+
$this->assertEquals($token, $event->getToken());
302+
303+
$event->setResponse(new Response('a custom response'));
304+
305+
return $event;
306+
})
307+
;
308+
309+
$firewallMap = $this->createMock(FirewallMap::class);
310+
$firewallConfig = new FirewallConfig('my_firewall', 'user_checker');
311+
$firewallMap
312+
->expects($this->once())
313+
->method('getFirewallConfig')
314+
->willReturn($firewallConfig)
315+
;
316+
317+
$eventDispatcherLocator = $this->createMock(ContainerInterface::class);
318+
$eventDispatcherLocator
319+
->expects($this->atLeastOnce())
320+
->method('get')
321+
->willReturnMap([
322+
['my_firewall', $eventDispatcher],
323+
])
324+
;
325+
326+
$container = $this->createMock(ContainerInterface::class);
327+
$container
328+
->expects($this->atLeastOnce())
329+
->method('get')
330+
->willReturnMap([
331+
['request_stack', $requestStack],
332+
['security.token_storage', $tokenStorage],
333+
['security.firewall.map', $firewallMap],
334+
['security.firewall.event_dispatcher_locator', $eventDispatcherLocator],
335+
])
336+
;
337+
$security = new Security($container);
338+
$response = $security->logout();
339+
340+
$this->assertInstanceOf(Response::class, $response);
341+
$this->assertEquals('a custom response', $response->getContent());
342+
}
343+
166344
private function createContainer(string $serviceId, object $serviceObject): ContainerInterface
167345
{
168346
return new ServiceLocator([$serviceId => fn () => $serviceObject]);

0 commit comments

Comments
 (0)