Skip to content

[Security] Add a method in the security helper to ease programmatic logout #41406

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jul 20, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/Symfony/Bundle/SecurityBundle/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
])
Expand Down Expand Up @@ -206,6 +208,7 @@
null,
[], // listeners
null, // switch_user
null, // logout
])

->set('security.logout_url_generator', LogoutUrlGenerator::class)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -160,5 +161,8 @@
service('security.access_map'),
])
->tag('monolog.logger', ['channel' => 'security'])

->set('security.firewall.event_dispatcher_locator', ServiceLocator::class)
->args([[]])
;
};
47 changes: 20 additions & 27 deletions src/Symfony/Bundle/SecurityBundle/Security/FirewallConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -111,4 +99,9 @@ public function getSwitchUser(): ?array
{
return $this->switchUser;
}

public function getLogout(): ?array
{
return $this->logout;
}
}
48 changes: 48 additions & 0 deletions src/Symfony/Bundle/SecurityBundle/Security/Security.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ public function testFirewalls()
'',
[],
null,
null,
],
[
'secure',
Expand All @@ -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',
Expand All @@ -181,6 +190,7 @@ public function testFirewalls()
'http_basic',
],
null,
null,
],
[
'with_user_checker',
Expand All @@ -197,6 +207,7 @@ public function testFirewalls()
'http_basic',
],
null,
null,
],
], $configs);

Expand Down
105 changes: 95 additions & 10 deletions src/Symfony/Bundle/SecurityBundle/Tests/Functional/SecurityTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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());
}

/**
Expand Down Expand Up @@ -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));
}
}

Expand Down Expand Up @@ -208,19 +269,43 @@ public function eraseCredentials(): void
}
}

class WelcomeController
class ForceLoginController
{
public $authenticator = 'json_login';
public $authenticator = 'form_login';

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())]);
}
}
Loading
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy