<?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\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
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\AccessDecision;
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\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
 */
class SwitchUserListener extends AbstractListener
{
    public const EXIT_VALUE = '_exit';

    public function __construct(
        private TokenStorageInterface $tokenStorage,
        private UserProviderInterface $provider,
        private UserCheckerInterface $userChecker,
        private string $firewallName,
        private AccessDecisionManagerInterface $accessDecisionManager,
        private ?LoggerInterface $logger = null,
        private string $usernameParameter = '_switch_user',
        private string $role = 'ROLE_ALLOWED_TO_SWITCH',
        private ?EventDispatcherInterface $dispatcher = null,
        private bool $stateless = false,
        private ?UrlGeneratorInterface $urlGenerator = null,
        private ?string $targetRoute = null,
    ) {
        if ('' === $firewallName) {
            throw new \InvalidArgumentException('$firewallName must not be empty.');
        }
    }

    public function supports(Request $request): ?bool
    {
        // usernames can be falsy
        $username = $request->query->get($this->usernameParameter) ?? (!\in_array($request->getMethod(), ['GET', 'HEAD'], true) ? $request->request->get($this->usernameParameter) : null);

        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): void
    {
        $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->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($this->urlGenerator && $this->targetRoute && self::EXIT_VALUE !== $username ? $this->urlGenerator->generate($this->targetRoute) : $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->getUserIdentifier() === $username) {
                return $token;
            }

            // User already switched, exit before seamlessly switching to another user
            $token = $this->attemptExitUser($request);
        }

        $currentUsername = $token->getUserIdentifier();
        $nonExistentUsername = '_'.hash('xxh128', random_bytes(8).$username);

        // To protect against user enumeration via timing measurements
        // we always load both successfully and unsuccessfully
        try {
            $user = $this->provider->loadUserByIdentifier($username);

            try {
                $this->provider->loadUserByIdentifier($nonExistentUsername);
            } catch (\Exception) {
            }
        } catch (AuthenticationException $e) {
            $this->provider->loadUserByIdentifier($currentUsername);

            throw $e;
        }
        $accessDecision = new AccessDecision();

        if (!$accessDecision->isGranted = $this->accessDecisionManager->decide($token, [$this->role], $user, $accessDecision)) {
            $e = new AccessDeniedException($accessDecision->getMessage());
            $e->setAttributes($this->role);
            $e->setAccessDecision($accessDecision);

            throw $e;
        }

        $this->logger?->info('Attempting to switch to user.', ['username' => $username]);

        $this->userChecker->checkPostAuth($user, $token);

        $roles = $user->getRoles();
        $originatedFromUri = str_replace('/&', '/?', preg_replace('#[&?]'.$this->usernameParameter.'=[^&]*#', '', $request->getRequestUri()));
        $token = new SwitchUserToken($user, $this->firewallName, $roles, $token, $originatedFromUri);

        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();
        }

        $this->tokenStorage->setToken($original);

        return $original;
    }

    private function getOriginalToken(TokenInterface $token): ?TokenInterface
    {
        if ($token instanceof SwitchUserToken) {
            return $token->getOriginalToken();
        }

        return null;
    }
}
