Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

[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 1 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 47 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 48 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 105 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
Morty Proxy This is a proxified and sanitized view of the page, visit original site.