Skip to content

Commit 86f78aa

Browse files
committed
feature symfony#41406 [Security] Add a method in the security helper to ease programmatic logout (johnkrovitch, chalasr)
This PR was merged into the 6.2 branch. Discussion ---------- [Security] Add a method in the security helper to ease programmatic logout | Q | A | ------------- | --- | Branch? | 6.x | Bug fix? | no | New feature? | yes | Deprecations? | no | Tickets | Fix symfony#40663 | License | MIT | Doc PR | This PR aims to ease the programmatic login using the Security helper, to fix (symfony#40663). A simple method has been added to the Security helper. Thanks ! Commits ------- e5e7d5e Make CSRF validation opt-in f41a184 Add CSRF protection f576173 [Security] Add a method in the security helper to ease programmatic logout (symfony#40663)
2 parents c61154d + e5e7d5e commit 86f78aa

File tree

12 files changed

+484
-50
lines changed

12 files changed

+484
-50
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: 9 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 = [];
@@ -448,6 +455,8 @@ private function createFirewall(ContainerBuilder $container, string $id, array $
448455
false === $firewall['stateless'] && isset($firewall['context']) ? $firewall['context'] : null,
449456
])
450457
;
458+
459+
$config->replaceArgument(12, $firewall['logout']);
451460
}
452461

453462
// Determine default entry point

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@
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'),
90+
'security.csrf.token_manager' => service('security.csrf.token_manager')->ignoreOnInvalid(),
8991
]),
9092
abstract_arg('authenticators'),
9193
])
@@ -206,6 +208,7 @@
206208
null,
207209
[], // listeners
208210
null, // switch_user
211+
null, // logout
209212
])
210213

211214
->set('security.logout_url_generator', LogoutUrlGenerator::class)

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/FirewallConfig.php

Lines changed: 20 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -16,33 +16,21 @@
1616
*/
1717
final class FirewallConfig
1818
{
19-
private string $name;
20-
private string $userChecker;
21-
private ?string $requestMatcher;
22-
private bool $securityEnabled;
23-
private bool $stateless;
24-
private ?string $provider;
25-
private ?string $context;
26-
private ?string $entryPoint;
27-
private ?string $accessDeniedHandler;
28-
private ?string $accessDeniedUrl;
29-
private array $authenticators;
30-
private ?array $switchUser;
31-
32-
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)
33-
{
34-
$this->name = $name;
35-
$this->userChecker = $userChecker;
36-
$this->requestMatcher = $requestMatcher;
37-
$this->securityEnabled = $securityEnabled;
38-
$this->stateless = $stateless;
39-
$this->provider = $provider;
40-
$this->context = $context;
41-
$this->entryPoint = $entryPoint;
42-
$this->accessDeniedHandler = $accessDeniedHandler;
43-
$this->accessDeniedUrl = $accessDeniedUrl;
44-
$this->authenticators = $authenticators;
45-
$this->switchUser = $switchUser;
19+
public function __construct(
20+
private readonly string $name,
21+
private readonly string $userChecker,
22+
private readonly ?string $requestMatcher = null,
23+
private readonly bool $securityEnabled = true,
24+
private readonly bool $stateless = false,
25+
private readonly ?string $provider = null,
26+
private readonly ?string $context = null,
27+
private readonly ?string $entryPoint = null,
28+
private readonly ?string $accessDeniedHandler = null,
29+
private readonly ?string $accessDeniedUrl = null,
30+
private readonly array $authenticators = [],
31+
private readonly ?array $switchUser = null,
32+
private readonly ?array $logout = null
33+
) {
4634
}
4735

4836
public function getName(): string
@@ -111,4 +99,9 @@ public function getSwitchUser(): ?array
11199
{
112100
return $this->switchUser;
113101
}
102+
103+
public function getLogout(): ?array
104+
{
105+
return $this->logout;
106+
}
114107
}

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

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

1414
use Psr\Container\ContainerInterface;
1515
use Symfony\Component\HttpFoundation\Request;
16+
use Symfony\Component\HttpFoundation\Response;
17+
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
1618
use Symfony\Component\Security\Core\Exception\LogicException;
19+
use Symfony\Component\Security\Core\Exception\LogoutException;
1720
use Symfony\Component\Security\Core\Security as LegacySecurity;
1821
use Symfony\Component\Security\Core\User\UserInterface;
22+
use Symfony\Component\Security\Csrf\CsrfToken;
1923
use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface;
24+
use Symfony\Component\Security\Http\Event\LogoutEvent;
25+
use Symfony\Component\Security\Http\ParameterBagUtils;
2026
use Symfony\Contracts\Service\ServiceProviderInterface;
2127

2228
/**
@@ -60,6 +66,48 @@ public function login(UserInterface $user, string $authenticatorName = null, str
6066
$this->container->get('security.user_authenticator')->authenticateUser($user, $authenticator, $request);
6167
}
6268

69+
/**
70+
* Logout the current user by dispatching the LogoutEvent.
71+
*
72+
* @param bool $validateCsrfToken Whether to look for a valid CSRF token based on the `logout` listener configuration
73+
*
74+
* @return Response|null The LogoutEvent's Response if any
75+
*
76+
* @throws LogoutException When $validateCsrfToken is true and the CSRF token is not found or invalid
77+
*/
78+
public function logout(bool $validateCsrfToken = true): ?Response
79+
{
80+
/** @var TokenStorageInterface $tokenStorage */
81+
$tokenStorage = $this->container->get('security.token_storage');
82+
83+
if (!($token = $tokenStorage->getToken()) || !$token->getUser()) {
84+
throw new LogicException('Unable to logout as there is no logged-in user.');
85+
}
86+
87+
$request = $this->container->get('request_stack')->getMainRequest();
88+
89+
if (!$firewallConfig = $this->container->get('security.firewall.map')->getFirewallConfig($request)) {
90+
throw new LogicException('Unable to logout as the request is not behind a firewall.');
91+
}
92+
93+
if ($validateCsrfToken) {
94+
if (!$this->container->has('security.csrf.token_manager') || !$logoutConfig = $firewallConfig->getLogout()) {
95+
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()));
96+
}
97+
$csrfToken = ParameterBagUtils::getRequestParameterValue($request, $logoutConfig['csrf_parameter']);
98+
if (!\is_string($csrfToken) || !$this->container->get('security.csrf.token_manager')->isTokenValid(new CsrfToken($logoutConfig['csrf_token_id'], $csrfToken))) {
99+
throw new LogoutException('Invalid CSRF token.');
100+
}
101+
}
102+
103+
$logoutEvent = new LogoutEvent($request, $token);
104+
$this->container->get('security.firewall.event_dispatcher_locator')->get($firewallConfig->getName())->dispatch($logoutEvent);
105+
106+
$tokenStorage->setToken(null);
107+
108+
return $logoutEvent->getResponse();
109+
}
110+
63111
private function getAuthenticator(?string $authenticatorName, string $firewallName): AuthenticatorInterface
64112
{
65113
if (!\array_key_exists($firewallName, $this->authenticators)) {

src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/CompleteConfigurationTest.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ public function testFirewalls()
141141
'',
142142
[],
143143
null,
144+
null,
144145
],
145146
[
146147
'secure',
@@ -165,6 +166,14 @@ public function testFirewalls()
165166
'parameter' => '_switch_user',
166167
'role' => 'ROLE_ALLOWED_TO_SWITCH',
167168
],
169+
[
170+
'csrf_parameter' => '_csrf_token',
171+
'csrf_token_id' => 'logout',
172+
'path' => '/logout',
173+
'target' => '/',
174+
'invalidate_session' => true,
175+
'delete_cookies' => [],
176+
],
168177
],
169178
[
170179
'host',
@@ -181,6 +190,7 @@ public function testFirewalls()
181190
'http_basic',
182191
],
183192
null,
193+
null,
184194
],
185195
[
186196
'with_user_checker',
@@ -197,6 +207,7 @@ public function testFirewalls()
197207
'http_basic',
198208
],
199209
null,
210+
null,
200211
],
201212
], $configs);
202213

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

Lines changed: 95 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,12 @@
1414
use Symfony\Bundle\SecurityBundle\Security\FirewallConfig;
1515
use Symfony\Bundle\SecurityBundle\Security\Security;
1616
use Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\SecuredPageBundle\Security\Core\User\ArrayUserProvider;
17+
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
1718
use Symfony\Component\HttpFoundation\JsonResponse;
1819
use Symfony\Component\HttpFoundation\Request;
20+
use Symfony\Component\HttpFoundation\Response;
21+
use Symfony\Component\HttpKernel\Event\RequestEvent;
22+
use Symfony\Component\HttpKernel\KernelEvents;
1923
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
2024
use Symfony\Component\Security\Core\User\InMemoryUser;
2125
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
@@ -37,8 +41,10 @@ public function testServiceIsFunctional()
3741
$security = $container->get('functional_test.security.helper');
3842
$this->assertTrue($security->isGranted('ROLE_USER'));
3943
$this->assertSame($token, $security->getToken());
40-
$this->assertInstanceOf(FirewallConfig::class, $firewallConfig = $security->getFirewallConfig(new Request()));
41-
$this->assertSame('default', $firewallConfig->getName());
44+
$request = new Request();
45+
$request->server->set('REQUEST_URI', '/main/foo');
46+
$this->assertInstanceOf(FirewallConfig::class, $firewallConfig = $security->getFirewallConfig($request));
47+
$this->assertSame('main', $firewallConfig->getName());
4248
}
4349

4450
/**
@@ -85,19 +91,74 @@ public function userWillBeMarkedAsChangedIfRolesHasChangedProvider()
8591
}
8692

8793
/**
88-
* @testWith ["json_login"]
94+
* @testWith ["form_login"]
8995
* ["Symfony\\Bundle\\SecurityBundle\\Tests\\Functional\\Bundle\\AuthenticatorBundle\\ApiAuthenticator"]
9096
*/
91-
public function testLoginWithBuiltInAuthenticator(string $authenticator)
97+
public function testLogin(string $authenticator)
9298
{
93-
$client = $this->createClient(['test_case' => 'SecurityHelper', 'root_config' => 'config.yml', 'debug' => true]);
94-
static::getContainer()->get(WelcomeController::class)->authenticator = $authenticator;
95-
$client->request('GET', '/welcome');
99+
$client = $this->createClient(['test_case' => 'SecurityHelper', 'root_config' => 'config.yml', 'debug' > true]);
100+
static::getContainer()->get(ForceLoginController::class)->authenticator = $authenticator;
101+
$client->request('GET', '/main/force-login');
96102
$response = $client->getResponse();
97103

98104
$this->assertInstanceOf(JsonResponse::class, $response);
99105
$this->assertSame(200, $response->getStatusCode());
100106
$this->assertSame(['message' => 'Welcome @chalasr!'], json_decode($response->getContent(), true));
107+
$this->assertSame('chalasr', static::getContainer()->get('security.helper')->getUser()->getUserIdentifier());
108+
}
109+
110+
public function testLogout()
111+
{
112+
$client = $this->createClient(['test_case' => 'SecurityHelper', 'root_config' => 'config.yml', 'debug' => true]);
113+
$client->loginUser(new InMemoryUser('chalasr', 'the-password', ['ROLE_FOO']), 'main');
114+
115+
$client->request('GET', '/main/force-logout');
116+
$response = $client->getResponse();
117+
118+
$this->assertSame(200, $response->getStatusCode());
119+
$this->assertNull(static::getContainer()->get('security.helper')->getUser());
120+
$this->assertSame(['message' => 'Logout successful'], json_decode($response->getContent(), true));
121+
}
122+
123+
public function testLogoutWithCsrf()
124+
{
125+
$client = $this->createClient(['test_case' => 'SecurityHelper', 'root_config' => 'config_logout_csrf.yml', 'debug' => true]);
126+
$client->loginUser(new InMemoryUser('chalasr', 'the-password', ['ROLE_FOO']), 'main');
127+
128+
// put a csrf token in the storage
129+
/** @var EventDispatcherInterface $eventDispatcher */
130+
$eventDispatcher = static::getContainer()->get(EventDispatcherInterface::class);
131+
$setCsrfToken = function (RequestEvent $event) {
132+
static::getContainer()->get('security.csrf.token_storage')->setToken('logout', 'bar');
133+
$event->setResponse(new Response(''));
134+
};
135+
$eventDispatcher->addListener(KernelEvents::REQUEST, $setCsrfToken);
136+
try {
137+
$client->request('GET', '/'.uniqid('', true));
138+
} finally {
139+
$eventDispatcher->removeListener(KernelEvents::REQUEST, $setCsrfToken);
140+
}
141+
142+
static::getContainer()->get(LogoutController::class)->checkCsrf = true;
143+
$client->request('GET', '/main/force-logout', ['_csrf_token' => 'bar']);
144+
$response = $client->getResponse();
145+
146+
$this->assertSame(200, $response->getStatusCode());
147+
$this->assertNull(static::getContainer()->get('security.helper')->getUser());
148+
$this->assertSame(['message' => 'Logout successful'], json_decode($response->getContent(), true));
149+
}
150+
151+
public function testLogoutBypassCsrf()
152+
{
153+
$client = $this->createClient(['test_case' => 'SecurityHelper', 'root_config' => 'config_logout_csrf.yml']);
154+
$client->loginUser(new InMemoryUser('chalasr', 'the-password', ['ROLE_FOO']), 'main');
155+
156+
$client->request('GET', '/main/force-logout');
157+
$response = $client->getResponse();
158+
159+
$this->assertSame(200, $response->getStatusCode());
160+
$this->assertNull(static::getContainer()->get('security.helper')->getUser());
161+
$this->assertSame(['message' => 'Logout successful'], json_decode($response->getContent(), true));
101162
}
102163
}
103164

@@ -208,19 +269,43 @@ public function eraseCredentials(): void
208269
}
209270
}
210271

211-
class WelcomeController
272+
class ForceLoginController
212273
{
213-
public $authenticator = 'json_login';
274+
public $authenticator = 'form_login';
214275

215276
public function __construct(private Security $security)
216277
{
217278
}
218279

219280
public function welcome()
220281
{
221-
$user = new InMemoryUser('chalasr', '', ['ROLE_USER']);
282+
$user = new InMemoryUser('chalasr', 'the-password', ['ROLE_FOO']);
222283
$this->security->login($user, $this->authenticator);
223284

224285
return new JsonResponse(['message' => sprintf('Welcome @%s!', $this->security->getUser()->getUserIdentifier())]);
225286
}
226287
}
288+
289+
class LogoutController
290+
{
291+
public $checkCsrf = false;
292+
293+
public function __construct(private Security $security)
294+
{
295+
}
296+
297+
public function logout(UserInterface $user)
298+
{
299+
$this->security->logout($this->checkCsrf);
300+
301+
return new JsonResponse(['message' => 'Logout successful']);
302+
}
303+
}
304+
305+
class LoggedInController
306+
{
307+
public function __invoke(UserInterface $user)
308+
{
309+
return new JsonResponse(['message' => sprintf('Welcome back @%s', $user->getUserIdentifier())]);
310+
}
311+
}

0 commit comments

Comments
 (0)