<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Security\Http\Firewall;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\LegacyEventDispatcherProxy;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authentication\Token\SwitchUserToken;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Security\Core\Exception\AuthenticationCredentialsNotFoundException;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Role\SwitchUserRole;
use Symfony\Component\Security\Core\User\UserCheckerInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Http\Event\SwitchUserEvent;
use Symfony\Component\Security\Http\SecurityEvents;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
/**
* SwitchUserListener allows a user to impersonate another one temporarily
* (like the Unix su command).
*
* @author Fabien Potencier <fabien@symfony.com>
*
* @final since Symfony 4.3
*/
class SwitchUserListener extends AbstractListener implements ListenerInterface
{
use LegacyListenerTrait;
public const EXIT_VALUE = '_exit';
private $tokenStorage;
private $provider;
private $userChecker;
private $providerKey;
private $accessDecisionManager;
private $usernameParameter;
private $role;
private $logger;
private $dispatcher;
private $stateless;
public function __construct(TokenStorageInterface $tokenStorage, UserProviderInterface $provider, UserCheckerInterface $userChecker, string $providerKey, AccessDecisionManagerInterface $accessDecisionManager, LoggerInterface $logger = null, string $usernameParameter = '_switch_user', string $role = 'ROLE_ALLOWED_TO_SWITCH', EventDispatcherInterface $dispatcher = null, bool $stateless = false)
{
if (empty($providerKey)) {
throw new \InvalidArgumentException('$providerKey must not be empty.');
}
$this->tokenStorage = $tokenStorage;
$this->provider = $provider;
$this->userChecker = $userChecker;
$this->providerKey = $providerKey;
$this->accessDecisionManager = $accessDecisionManager;
$this->usernameParameter = $usernameParameter;
$this->role = $role;
$this->logger = $logger;
if (null !== $dispatcher && class_exists(LegacyEventDispatcherProxy::class)) {
$this->dispatcher = LegacyEventDispatcherProxy::decorate($dispatcher);
} else {
$this->dispatcher = $dispatcher;
}
$this->stateless = $stateless;
}
/**
* {@inheritdoc}
*/
public function supports(Request $request): ?bool
{
// usernames can be falsy
$username = $request->get($this->usernameParameter);
if (null === $username || '' === $username) {
$username = $request->headers->get($this->usernameParameter);
}
// if it's still "empty", nothing to do.
if (null === $username || '' === $username) {
return false;
}
$request->attributes->set('_switch_user_username', $username);
return true;
}
/**
* Handles the switch to another user.
*
* @throws \LogicException if switching to a user failed
*/
public function authenticate(RequestEvent $event)
{
$request = $event->getRequest();
$username = $request->attributes->get('_switch_user_username');
$request->attributes->remove('_switch_user_username');
if (null === $this->tokenStorage->getToken()) {
throw new AuthenticationCredentialsNotFoundException('Could not find original Token object.');
}
if (self::EXIT_VALUE === $username) {
$this->tokenStorage->setToken($this->attemptExitUser($request));
} else {
try {
$this->tokenStorage->setToken($this->attemptSwitchUser($request, $username));
} catch (AuthenticationException $e) {
// Generate 403 in any conditions to prevent user enumeration vulnerabilities
throw new AccessDeniedException('Switch User failed: '.$e->getMessage(), $e);
}
}
if (!$this->stateless) {
$request->query->remove($this->usernameParameter);
$request->server->set('QUERY_STRING', http_build_query($request->query->all(), '', '&'));
$response = new RedirectResponse($request->getUri(), 302);
$event->setResponse($response);
}
}
/**
* Attempts to switch to another user and returns the new token if successfully switched.
*
* @throws \LogicException
* @throws AccessDeniedException
*/
private function attemptSwitchUser(Request $request, string $username): ?TokenInterface
{
$token = $this->tokenStorage->getToken();
$originalToken = $this->getOriginalToken($token);
if (null !== $originalToken) {
if ($token->getUsername() === $username) {
return $token;
}
// User already switched, exit before seamlessly switching to another user
$token = $this->attemptExitUser($request);
}
$currentUsername = $token->getUsername();
$nonExistentUsername = '_'.md5(random_bytes(8).$username);
// To protect against user enumeration via timing measurements
// we always load both successfully and unsuccessfully
try {
$user = $this->provider->loadUserByUsername($username);
try {
$this->provider->loadUserByUsername($nonExistentUsername);
} catch (\Exception $e) {
}
} catch (AuthenticationException $e) {
$this->provider->loadUserByUsername($currentUsername);
throw $e;
}
if (false === $this->accessDecisionManager->decide($token, [$this->role], $user)) {
$exception = new AccessDeniedException();
$exception->setAttributes($this->role);
throw $exception;
}
if (null !== $this->logger) {
$this->logger->info('Attempting to switch to user.', ['username' => $username]);
}
$this->userChecker->checkPostAuth($user);
$roles = $user->getRoles();
$roles[] = new SwitchUserRole('ROLE_PREVIOUS_ADMIN', $token, false);
$token = new SwitchUserToken($user, $user->getPassword(), $this->providerKey, $roles, $token);
if (null !== $this->dispatcher) {
$switchEvent = new SwitchUserEvent($request, $token->getUser(), $token);
$this->dispatcher->dispatch($switchEvent, SecurityEvents::SWITCH_USER);
// use the token from the event in case any listeners have replaced it.
$token = $switchEvent->getToken();
}
return $token;
}
/**
* Attempts to exit from an already switched user and returns the original token.
*
* @throws AuthenticationCredentialsNotFoundException
*/
private function attemptExitUser(Request $request): TokenInterface
{
if (null === ($currentToken = $this->tokenStorage->getToken()) || null === $original = $this->getOriginalToken($currentToken)) {
throw new AuthenticationCredentialsNotFoundException('Could not find original Token object.');
}
if (null !== $this->dispatcher && $original->getUser() instanceof UserInterface) {
$user = $this->provider->refreshUser($original->getUser());
$original->setUser($user);
$switchEvent = new SwitchUserEvent($request, $user, $original);
$this->dispatcher->dispatch($switchEvent, SecurityEvents::SWITCH_USER);
$original = $switchEvent->getToken();
}
return $original;
}
private function getOriginalToken(TokenInterface $token): ?TokenInterface
{
if ($token instanceof SwitchUserToken) {
return $token->getOriginalToken();
}
foreach ($token->getRoles(false) as $role) {
if ($role instanceof SwitchUserRole) {
return $role->getSource();
}
}
return null;
}
}