diff --git a/src/Symfony/Bridge/Doctrine/SchemaListener/RememberMeTokenProviderDoctrineSchemaSubscriber.php b/src/Symfony/Bridge/Doctrine/SchemaListener/RememberMeTokenProviderDoctrineSchemaSubscriber.php new file mode 100644 index 0000000000..60a849789e --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/SchemaListener/RememberMeTokenProviderDoctrineSchemaSubscriber.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Doctrine\SchemaListener; + +use Doctrine\Common\EventSubscriber; +use Doctrine\ORM\Tools\Event\GenerateSchemaEventArgs; +use Doctrine\ORM\Tools\ToolEvents; +use Symfony\Bridge\Doctrine\Security\RememberMe\DoctrineTokenProvider; +use Symfony\Component\Security\Http\RememberMe\PersistentRememberMeHandler; +use Symfony\Component\Security\Http\RememberMe\RememberMeHandlerInterface; + +/** + * Automatically adds the rememberme table needed for the {@see DoctrineTokenProvider}. + * + * @author Wouter de Jong + */ +final class RememberMeTokenProviderDoctrineSchemaSubscriber implements EventSubscriber +{ + private $rememberMeHandlers; + + /** + * @param iterable|RememberMeHandlerInterface[] $rememberMeHandlers + */ + public function __construct(iterable $rememberMeHandlers) + { + $this->rememberMeHandlers = $rememberMeHandlers; + } + + public function postGenerateSchema(GenerateSchemaEventArgs $event): void + { + $dbalConnection = $event->getEntityManager()->getConnection(); + + foreach ($this->rememberMeHandlers as $rememberMeHandler) { + if ( + $rememberMeHandler instanceof PersistentRememberMeHandler + && ($tokenProvider = $rememberMeHandler->getTokenProvider()) instanceof DoctrineTokenProvider + ) { + $tokenProvider->configureSchema($event->getSchema(), $dbalConnection); + } + } + } + + public function getSubscribedEvents(): array + { + if (!class_exists(ToolEvents::class)) { + return []; + } + + return [ + ToolEvents::postGenerateSchema, + ]; + } +} diff --git a/src/Symfony/Bridge/Doctrine/Security/RememberMe/DoctrineTokenProvider.php b/src/Symfony/Bridge/Doctrine/Security/RememberMe/DoctrineTokenProvider.php index d2ac616db7..4712065e35 100644 --- a/src/Symfony/Bridge/Doctrine/Security/RememberMe/DoctrineTokenProvider.php +++ b/src/Symfony/Bridge/Doctrine/Security/RememberMe/DoctrineTokenProvider.php @@ -14,6 +14,7 @@ namespace Symfony\Bridge\Doctrine\Security\RememberMe; use Doctrine\DBAL\Connection; use Doctrine\DBAL\Driver\Result as DriverResult; use Doctrine\DBAL\Result; +use Doctrine\DBAL\Schema\Schema; use Doctrine\DBAL\Types\Types; use Symfony\Component\Security\Core\Authentication\RememberMe\PersistentToken; use Symfony\Component\Security\Core\Authentication\RememberMe\PersistentTokenInterface; @@ -21,7 +22,7 @@ use Symfony\Component\Security\Core\Authentication\RememberMe\TokenProviderInter use Symfony\Component\Security\Core\Exception\TokenNotFoundException; /** - * This class provides storage for the tokens that is set in "remember me" + * This class provides storage for the tokens that is set in "remember-me" * cookies. This way no password secrets will be stored in the cookies on * the client machine, and thus the security is improved. * @@ -53,8 +54,7 @@ class DoctrineTokenProvider implements TokenProviderInterface public function loadTokenBySeries(string $series) { // the alias for lastUsed works around case insensitivity in PostgreSQL - $sql = 'SELECT class, username, value, lastUsed AS last_used' - .' FROM rememberme_token WHERE series=:series'; + $sql = 'SELECT class, username, value, lastUsed AS last_used FROM rememberme_token WHERE series=:series'; $paramValues = ['series' => $series]; $paramTypes = ['series' => \PDO::PARAM_STR]; $stmt = $this->conn->executeQuery($sql, $paramValues, $paramTypes); @@ -87,8 +87,7 @@ class DoctrineTokenProvider implements TokenProviderInterface */ public function updateToken(string $series, string $tokenValue, \DateTime $lastUsed) { - $sql = 'UPDATE rememberme_token SET value=:value, lastUsed=:lastUsed' - .' WHERE series=:series'; + $sql = 'UPDATE rememberme_token SET value=:value, lastUsed=:lastUsed WHERE series=:series'; $paramValues = [ 'value' => $tokenValue, 'lastUsed' => $lastUsed, @@ -114,9 +113,7 @@ class DoctrineTokenProvider implements TokenProviderInterface */ public function createNewToken(PersistentTokenInterface $token) { - $sql = 'INSERT INTO rememberme_token' - .' (class, username, series, value, lastUsed)' - .' VALUES (:class, :username, :series, :value, :lastUsed)'; + $sql = 'INSERT INTO rememberme_token (class, username, series, value, lastUsed) VALUES (:class, :username, :series, :value, :lastUsed)'; $paramValues = [ 'class' => $token->getClass(), // @deprecated since 5.3, change to $token->getUserIdentifier() in 6.0 @@ -138,4 +135,32 @@ class DoctrineTokenProvider implements TokenProviderInterface $this->conn->executeUpdate($sql, $paramValues, $paramTypes); } } + + /** + * Adds the Table to the Schema if "remember me" uses this Connection. + */ + public function configureSchema(Schema $schema, Connection $forConnection): void + { + // only update the schema for this connection + if ($forConnection !== $this->conn) { + return; + } + + if ($schema->hasTable('rememberme_token')) { + return; + } + + $this->addTableToSchema($schema); + } + + private function addTableToSchema(Schema $schema): void + { + $table = $schema->createTable('rememberme_token'); + $table->addColumn('series', Types::STRING, ['length' => 88]); + $table->addColumn('value', Types::STRING, ['length' => 88]); + $table->addColumn('lastUsed', Types::DATETIME_MUTABLE); + $table->addColumn('class', Types::STRING, ['length' => 100]); + $table->addColumn('username', Types::STRING, ['length' => 200]); + $table->setPrimaryKey(['series']); + } } diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php index ded8b90028..3590d0074c 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php @@ -77,6 +77,7 @@ class UnusedTagsPass implements CompilerPassInterface 'security.authenticator.login_linker', 'security.expression_language_provider', 'security.remember_me_aware', + 'security.remember_me_handler', 'security.voter', 'serializer.encoder', 'serializer.normalizer', diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Compiler/RegisterGlobalSecurityEventListenersPass.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Compiler/RegisterGlobalSecurityEventListenersPass.php index 9ffbba4ac9..20094957cd 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Compiler/RegisterGlobalSecurityEventListenersPass.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Compiler/RegisterGlobalSecurityEventListenersPass.php @@ -21,6 +21,7 @@ use Symfony\Component\Security\Http\Event\InteractiveLoginEvent; use Symfony\Component\Security\Http\Event\LoginFailureEvent; use Symfony\Component\Security\Http\Event\LoginSuccessEvent; use Symfony\Component\Security\Http\Event\LogoutEvent; +use Symfony\Component\Security\Http\Event\TokenDeauthenticatedEvent; use Symfony\Component\Security\Http\SecurityEvents; /** @@ -44,6 +45,7 @@ class RegisterGlobalSecurityEventListenersPass implements CompilerPassInterface AuthenticationTokenCreatedEvent::class, AuthenticationSuccessEvent::class, InteractiveLoginEvent::class, + TokenDeauthenticatedEvent::class, // When events are registered by their name AuthenticationEvents::AUTHENTICATION_SUCCESS, diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Compiler/ReplaceDecoratedRememberMeHandlerPass.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Compiler/ReplaceDecoratedRememberMeHandlerPass.php new file mode 100644 index 0000000000..5de431c2c0 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Compiler/ReplaceDecoratedRememberMeHandlerPass.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler; + +use Symfony\Bundle\SecurityBundle\RememberMe\DecoratedRememberMeHandler; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; + +/** + * Replaces the DecoratedRememberMeHandler services with the real definition. + * + * @author Wouter de Jong + * + * @internal + */ +final class ReplaceDecoratedRememberMeHandlerPass implements CompilerPassInterface +{ + private const HANDLER_TAG = 'security.remember_me_handler'; + + /** + * {@inheritdoc} + */ + public function process(ContainerBuilder $container): void + { + $handledFirewalls = []; + foreach ($container->findTaggedServiceIds(self::HANDLER_TAG) as $definitionId => $rememberMeHandlerTags) { + $definition = $container->findDefinition($definitionId); + if (DecoratedRememberMeHandler::class !== $definition->getClass()) { + continue; + } + + // get the actual custom remember me handler definition (passed to the decorator) + $realRememberMeHandler = $container->findDefinition((string) $definition->getArgument(0)); + if (null === $realRememberMeHandler) { + throw new \LogicException(sprintf('Invalid service definition for custom remember me handler; no service found with ID "%s".', (string) $definition->getArgument(0))); + } + + foreach ($rememberMeHandlerTags as $rememberMeHandlerTag) { + // some custom handlers may be used on multiple firewalls in the same application + if (\in_array($rememberMeHandlerTag['firewall'], $handledFirewalls, true)) { + continue; + } + + $rememberMeHandler = clone $realRememberMeHandler; + $rememberMeHandler->addTag(self::HANDLER_TAG, $rememberMeHandlerTag); + $container->setDefinition('security.authenticator.remember_me_handler.'.$rememberMeHandlerTag['firewall'], $rememberMeHandler); + + $handledFirewalls[] = $rememberMeHandlerTag['firewall']; + } + } + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/LoginLinkFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/LoginLinkFactory.php index 7680bffad6..05c2f28f97 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/LoginLinkFactory.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/LoginLinkFactory.php @@ -113,18 +113,24 @@ class LoginLinkFactory extends AbstractFactory implements AuthenticatorFactoryIn ->replaceArgument(1, $config['lifetime']); } + $signatureHasherId = 'security.authenticator.login_link_signature_hasher.'.$firewallName; + $container + ->setDefinition($signatureHasherId, new ChildDefinition('security.authenticator.abstract_login_link_signature_hasher')) + ->replaceArgument(1, $config['signature_properties']) + ->replaceArgument(3, $expiredStorageId ? new Reference($expiredStorageId) : null) + ->replaceArgument(4, $config['max_uses'] ?? null) + ; + $linkerId = 'security.authenticator.login_link_handler.'.$firewallName; $linkerOptions = [ 'route_name' => $config['check_route'], 'lifetime' => $config['lifetime'], - 'max_uses' => $config['max_uses'] ?? null, ]; $container ->setDefinition($linkerId, new ChildDefinition('security.authenticator.abstract_login_link_handler')) ->replaceArgument(1, new Reference($userProviderId)) - ->replaceArgument(3, $config['signature_properties']) - ->replaceArgument(5, $linkerOptions) - ->replaceArgument(6, $expiredStorageId ? new Reference($expiredStorageId) : null) + ->replaceArgument(2, new Reference($signatureHasherId)) + ->replaceArgument(3, $linkerOptions) ->addTag('security.authenticator.login_linker', ['firewall' => $firewallName]) ; diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/RememberMeFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/RememberMeFactory.php index 27ec6ff9e0..809f189350 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/RememberMeFactory.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/RememberMeFactory.php @@ -11,11 +11,16 @@ namespace Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory; +use Symfony\Bridge\Doctrine\Security\RememberMe\DoctrineTokenProvider; +use Symfony\Bundle\SecurityBundle\RememberMe\DecoratedRememberMeHandler; use Symfony\Component\Config\Definition\Builder\NodeDefinition; +use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; +use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\Argument\IteratorArgument; use Symfony\Component\DependencyInjection\ChildDefinition; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; +use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\HttpFoundation\Cookie; use Symfony\Component\Security\Http\EventListener\RememberMeLogoutListener; @@ -94,31 +99,66 @@ class RememberMeFactory implements SecurityFactoryInterface, AuthenticatorFactor public function createAuthenticator(ContainerBuilder $container, string $firewallName, array $config, string $userProviderId): string { - $templateId = $this->generateRememberMeServicesTemplateId($config, $firewallName); - $rememberMeServicesId = $templateId.'.'.$firewallName; + if (!$container->hasDefinition('security.authenticator.remember_me')) { + $loader = new PhpFileLoader($container, new FileLocator(\dirname(__DIR__).'/../../Resources/config')); + $loader->load('security_authenticator_remember_me.php'); + } - // create remember me services (which manage the remember me cookies) - $this->createRememberMeServices($container, $firewallName, $templateId, [new Reference($userProviderId)], $config); + // create remember me handler (which manage the remember-me cookies) + $rememberMeHandlerId = 'security.authenticator.remember_me_handler.'.$firewallName; + if (isset($config['service']) && isset($config['token_provider'])) { + throw new InvalidConfigurationException(sprintf('You cannot use both "service" and "token_provider" in "security.firewalls.%s.remember_me".', $firewallName)); + } + + if (isset($config['service'])) { + $container->register($rememberMeHandlerId, DecoratedRememberMeHandler::class) + ->addArgument(new Reference($config['service'])) + ->addTag('security.remember_me_handler', ['firewall' => $firewallName]); + } elseif (isset($config['token_provider'])) { + $tokenProviderId = $this->createTokenProvider($container, $firewallName, $config['token_provider']); + $container->setDefinition($rememberMeHandlerId, new ChildDefinition('security.authenticator.persistent_remember_me_handler')) + ->replaceArgument(0, new Reference($tokenProviderId)) + ->replaceArgument(2, new Reference($userProviderId)) + ->replaceArgument(4, $config) + ->addTag('security.remember_me_handler', ['firewall' => $firewallName]); + } else { + $signatureHasherId = 'security.authenticator.remember_me_signature_hasher.'.$firewallName; + $container->setDefinition($signatureHasherId, new ChildDefinition('security.authenticator.remember_me_signature_hasher')) + ->replaceArgument(1, $config['signature_properties']) + ; + + $container->setDefinition($rememberMeHandlerId, new ChildDefinition('security.authenticator.signature_remember_me_handler')) + ->replaceArgument(0, new Reference($signatureHasherId)) + ->replaceArgument(1, new Reference($userProviderId)) + ->replaceArgument(3, $config) + ->addTag('security.remember_me_handler', ['firewall' => $firewallName]); + } + + // create check remember me conditions listener (which checks if a remember-me cookie is supported and requested) + $rememberMeConditionsListenerId = 'security.listener.check_remember_me_conditions.'.$firewallName; + $container->setDefinition($rememberMeConditionsListenerId, new ChildDefinition('security.listener.check_remember_me_conditions')) + ->replaceArgument(0, array_intersect_key($config, ['always_remember_me' => true, 'remember_me_parameter' => true])) + ->addTag('kernel.event_subscriber', ['dispatcher' => 'security.event_dispatcher.'.$firewallName]) + ; // create remember me listener (which executes the remember me services for other authenticators and logout) - $this->createRememberMeListener($container, $firewallName, $rememberMeServicesId); + $rememberMeListenerId = 'security.listener.remember_me.'.$firewallName; + $container->setDefinition($rememberMeListenerId, new ChildDefinition('security.listener.remember_me')) + ->replaceArgument(0, new Reference($rememberMeHandlerId)) + ->addTag('kernel.event_subscriber', ['dispatcher' => 'security.event_dispatcher.'.$firewallName]) + ; - // create remember me authenticator (which re-authenticates the user based on the remember me cookie) + // create remember me authenticator (which re-authenticates the user based on the remember-me cookie) $authenticatorId = 'security.authenticator.remember_me.'.$firewallName; $container ->setDefinition($authenticatorId, new ChildDefinition('security.authenticator.remember_me')) - ->replaceArgument(0, new Reference($rememberMeServicesId)) - ->replaceArgument(3, $container->getDefinition($rememberMeServicesId)->getArgument(3)) + ->replaceArgument(0, new Reference($rememberMeHandlerId)) + ->replaceArgument(3, $config['name'] ?? $this->options['name']) ; foreach ($container->findTaggedServiceIds('security.remember_me_aware') as $serviceId => $attributes) { // register ContextListener if ('security.context_listener' === substr($serviceId, 0, 25)) { - $container - ->getDefinition($serviceId) - ->addMethodCall('setRememberMeServices', [new Reference($rememberMeServicesId)]) - ; - continue; } @@ -148,7 +188,6 @@ class RememberMeFactory implements SecurityFactoryInterface, AuthenticatorFactor $builder ->scalarNode('secret')->isRequired()->cannotBeEmpty()->end() ->scalarNode('service')->end() - ->scalarNode('token_provider')->end() ->arrayNode('user_providers') ->beforeNormalization() ->ifString()->then(function ($v) { return [$v]; }) @@ -156,7 +195,26 @@ class RememberMeFactory implements SecurityFactoryInterface, AuthenticatorFactor ->prototype('scalar')->end() ->end() ->booleanNode('catch_exceptions')->defaultTrue()->end() - ; + ->arrayNode('signature_properties') + ->prototype('scalar')->end() + ->requiresAtLeastOneElement() + ->info('An array of properties on your User that are used to sign the remember-me cookie. If any of these change, all existing cookies will become invalid.') + ->example(['email', 'password']) + ->end() + ->arrayNode('token_provider') + ->beforeNormalization() + ->ifString()->then(function ($v) { return ['service' => $v]; }) + ->end() + ->children() + ->scalarNode('service')->info('The service ID of a custom rememberme token provider.')->end() + ->arrayNode('doctrine') + ->canBeEnabled() + ->children() + ->scalarNode('connection')->defaultNull()->end() + ->end() + ->end() + ->end() + ->end(); foreach ($this->options as $name => $value) { if ('secure' === $name) { @@ -195,9 +253,8 @@ class RememberMeFactory implements SecurityFactoryInterface, AuthenticatorFactor $rememberMeServices->replaceArgument(2, $id); if (isset($config['token_provider'])) { - $rememberMeServices->addMethodCall('setTokenProvider', [ - new Reference($config['token_provider']), - ]); + $tokenProviderId = $this->createTokenProvider($container, $id, $config['token_provider']); + $rememberMeServices->addMethodCall('setTokenProvider', [new Reference($tokenProviderId)]); } // remember-me options @@ -222,17 +279,29 @@ class RememberMeFactory implements SecurityFactoryInterface, AuthenticatorFactor $rememberMeServices->replaceArgument(0, new IteratorArgument(array_unique($userProviders))); } - private function createRememberMeListener(ContainerBuilder $container, string $id, string $rememberMeServicesId): void + private function createTokenProvider(ContainerBuilder $container, string $firewallName, array $config): string { - $container - ->setDefinition('security.listener.remember_me.'.$id, new ChildDefinition('security.listener.remember_me')) - ->addTag('kernel.event_subscriber', ['dispatcher' => 'security.event_dispatcher.'.$id]) - ->replaceArgument(0, new Reference($rememberMeServicesId)) - ; + $tokenProviderId = $config['service'] ?? false; + if ($config['doctrine']['enabled'] ?? false) { + if (!class_exists(DoctrineTokenProvider::class)) { + throw new InvalidConfigurationException('Cannot use the "doctrine" token provider for "remember_me" because the Doctrine Bridge is not installed. Try running "composer require symfony/doctrine-bridge".'); + } - $container - ->setDefinition('security.logout.listener.remember_me.'.$id, new Definition(RememberMeLogoutListener::class)) - ->addTag('kernel.event_subscriber', ['dispatcher' => 'security.event_dispatcher.'.$id]) - ->addArgument(new Reference($rememberMeServicesId)); + if (null === $config['doctrine']['connection']) { + $connectionId = 'database_connection'; + } else { + $connectionId = 'doctrine.dbal.'.$config['doctrine']['connection'].'_connection'; + } + + $tokenProviderId = 'security.remember_me.doctrine_token_provider.'.$firewallName; + $container->register($tokenProviderId, DoctrineTokenProvider::class) + ->addArgument(new Reference($connectionId)); + } + + if (!$tokenProviderId) { + throw new InvalidConfigurationException(sprintf('No token provider was set for firewall "%s". Either configure a service ID or set "remember_me.token_provider.doctrine" to true.', $firewallName)); + } + + return $tokenProviderId; } } diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php index 2f5c674fb8..8da513de14 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php @@ -34,6 +34,7 @@ use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\ExpressionLanguage\ExpressionLanguage; use Symfony\Component\HttpKernel\DependencyInjection\Extension; +use Symfony\Component\HttpKernel\KernelEvents; use Symfony\Component\PasswordHasher\Hasher\NativePasswordHasher; use Symfony\Component\PasswordHasher\Hasher\Pbkdf2PasswordHasher; use Symfony\Component\PasswordHasher\Hasher\PlaintextPasswordHasher; @@ -392,7 +393,7 @@ class SecurityExtension extends Extension implements PrependExtensionInterface // Context serializer listener if (false === $firewall['stateless']) { $contextKey = $firewall['context'] ?? $id; - $listeners[] = new Reference($contextListenerId = $this->createContextListener($container, $contextKey)); + $listeners[] = new Reference($contextListenerId = $this->createContextListener($container, $contextKey, $this->authenticatorManagerEnabled ? $firewallEventDispatcherId : null)); $sessionStrategyId = 'security.authentication.session_strategy'; if ($this->authenticatorManagerEnabled) { @@ -557,7 +558,7 @@ class SecurityExtension extends Extension implements PrependExtensionInterface return [$matcher, $listeners, $exceptionListener, null !== $logoutListenerId ? new Reference($logoutListenerId) : null]; } - private function createContextListener(ContainerBuilder $container, string $contextKey) + private function createContextListener(ContainerBuilder $container, string $contextKey, ?string $firewallEventDispatcherId) { if (isset($this->contextListeners[$contextKey])) { return $this->contextListeners[$contextKey]; @@ -566,6 +567,10 @@ class SecurityExtension extends Extension implements PrependExtensionInterface $listenerId = 'security.context_listener.'.\count($this->contextListeners); $listener = $container->setDefinition($listenerId, new ChildDefinition('security.context_listener')); $listener->replaceArgument(2, $contextKey); + if (null !== $firewallEventDispatcherId) { + $listener->replaceArgument(4, new Reference($firewallEventDispatcherId)); + $listener->addTag('kernel.event_listener', ['event' => KernelEvents::RESPONSE, 'method' => 'onKernelResponse']); + } return $this->contextListeners[$contextKey] = $listenerId; } diff --git a/src/Symfony/Bundle/SecurityBundle/LoginLink/FirewallAwareLoginLinkHandler.php b/src/Symfony/Bundle/SecurityBundle/LoginLink/FirewallAwareLoginLinkHandler.php index 04b633ff72..5c61cfcfab 100644 --- a/src/Symfony/Bundle/SecurityBundle/LoginLink/FirewallAwareLoginLinkHandler.php +++ b/src/Symfony/Bundle/SecurityBundle/LoginLink/FirewallAwareLoginLinkHandler.php @@ -12,6 +12,7 @@ namespace Symfony\Bundle\SecurityBundle\LoginLink; use Psr\Container\ContainerInterface; +use Symfony\Bundle\SecurityBundle\Security\FirewallAwareTrait; use Symfony\Bundle\SecurityBundle\Security\FirewallMap; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; @@ -26,43 +27,24 @@ use Symfony\Component\Security\Http\LoginLink\LoginLinkHandlerInterface; */ class FirewallAwareLoginLinkHandler implements LoginLinkHandlerInterface { - private $firewallMap; - private $loginLinkHandlerLocator; - private $requestStack; + use FirewallAwareTrait; + + private const FIREWALL_OPTION = 'login_link'; public function __construct(FirewallMap $firewallMap, ContainerInterface $loginLinkHandlerLocator, RequestStack $requestStack) { $this->firewallMap = $firewallMap; - $this->loginLinkHandlerLocator = $loginLinkHandlerLocator; + $this->locator = $loginLinkHandlerLocator; $this->requestStack = $requestStack; } public function createLoginLink(UserInterface $user, Request $request = null): LoginLinkDetails { - return $this->getLoginLinkHandler()->createLoginLink($user, $request); + return $this->getForFirewall()->createLoginLink($user, $request); } public function consumeLoginLink(Request $request): UserInterface { - return $this->getLoginLinkHandler()->consumeLoginLink($request); - } - - private function getLoginLinkHandler(): LoginLinkHandlerInterface - { - if (null === $request = $this->requestStack->getCurrentRequest()) { - throw new \LogicException('Cannot determine the correct LoginLinkHandler to use: there is no active Request and so, the firewall cannot be determined. Try using the specific login link handler service.'); - } - - $firewall = $this->firewallMap->getFirewallConfig($request); - if (!$firewall) { - throw new \LogicException('No login link handler found as the current route is not covered by a firewall.'); - } - - $firewallName = $firewall->getName(); - if (!$this->loginLinkHandlerLocator->has($firewallName)) { - throw new \LogicException(sprintf('No login link handler found. Did you add a login_link key under your "%s" firewall?', $firewallName)); - } - - return $this->loginLinkHandlerLocator->get($firewallName); + return $this->getForFirewall()->consumeLoginLink($request); } } diff --git a/src/Symfony/Bundle/SecurityBundle/RememberMe/DecoratedRememberMeHandler.php b/src/Symfony/Bundle/SecurityBundle/RememberMe/DecoratedRememberMeHandler.php new file mode 100644 index 0000000000..a060fb5116 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/RememberMe/DecoratedRememberMeHandler.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\SecurityBundle\RememberMe; + +use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\Security\Http\RememberMe\RememberMeDetails; +use Symfony\Component\Security\Http\RememberMe\RememberMeHandlerInterface; + +/** + * Used as a "workaround" for tagging aliases in the RememberMeFactory. + * + * @author Wouter de Jong + * + * @internal + */ +final class DecoratedRememberMeHandler implements RememberMeHandlerInterface +{ + private $handler; + + public function __construct(RememberMeHandlerInterface $handler) + { + $this->handler = $handler; + } + + /** + * {@inheritDoc} + */ + public function createRememberMeCookie(UserInterface $user): void + { + $this->handler->createRememberMeCookie($user); + } + + /** + * {@inheritDoc} + */ + public function consumeRememberMeCookie(RememberMeDetails $rememberMeDetails): UserInterface + { + return $this->handler->consumeRememberMeCookie($rememberMeDetails); + } + + /** + * {@inheritDoc} + */ + public function clearRememberMeCookie(): void + { + $this->handler->clearRememberMeCookie(); + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/RememberMe/FirewallAwareRememberMeHandler.php b/src/Symfony/Bundle/SecurityBundle/RememberMe/FirewallAwareRememberMeHandler.php new file mode 100644 index 0000000000..14252662b8 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/RememberMe/FirewallAwareRememberMeHandler.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\SecurityBundle\RememberMe; + +use Psr\Container\ContainerInterface; +use Symfony\Bundle\SecurityBundle\Security\FirewallAwareTrait; +use Symfony\Bundle\SecurityBundle\Security\FirewallMap; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\Security\Http\RememberMe\RememberMeDetails; +use Symfony\Component\Security\Http\RememberMe\RememberMeHandlerInterface; + +/** + * Decorates {@see RememberMeHandlerInterface} for the current firewall. + * + * @author Wouter de Jong + * + * @experimental in 5.3 + */ +final class FirewallAwareRememberMeHandler implements RememberMeHandlerInterface +{ + use FirewallAwareTrait; + + private const FIREWALL_OPTION = 'remember_me'; + + public function __construct(FirewallMap $firewallMap, ContainerInterface $rememberMeHandlerLocator, RequestStack $requestStack) + { + $this->firewallMap = $firewallMap; + $this->locator = $rememberMeHandlerLocator; + $this->requestStack = $requestStack; + } + + public function createRememberMeCookie(UserInterface $user): void + { + $this->getForFirewall()->createRememberMeCookie($user); + } + + public function consumeRememberMeCookie(RememberMeDetails $rememberMeDetails): UserInterface + { + return $this->getForFirewall()->consumeRememberMeCookie($rememberMeDetails); + } + + public function clearRememberMeCookie(): void + { + $this->getForFirewall()->clearRememberMeCookie(); + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/schema/security-1.0.xsd b/src/Symfony/Bundle/SecurityBundle/Resources/config/schema/security-1.0.xsd index 3de6b98b38..d960f02351 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/schema/security-1.0.xsd +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/schema/security-1.0.xsd @@ -334,9 +334,12 @@ - - - + + + + + + @@ -352,6 +355,18 @@ + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator.php b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator.php index 57c2afeada..ebc9a5fa64 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator.php +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator.php @@ -20,14 +20,12 @@ use Symfony\Component\Security\Http\Authentication\UserAuthenticatorInterface; use Symfony\Component\Security\Http\Authenticator\FormLoginAuthenticator; use Symfony\Component\Security\Http\Authenticator\HttpBasicAuthenticator; use Symfony\Component\Security\Http\Authenticator\JsonLoginAuthenticator; -use Symfony\Component\Security\Http\Authenticator\RememberMeAuthenticator; use Symfony\Component\Security\Http\Authenticator\RemoteUserAuthenticator; use Symfony\Component\Security\Http\Authenticator\X509Authenticator; use Symfony\Component\Security\Http\Event\CheckPassportEvent; use Symfony\Component\Security\Http\EventListener\CheckCredentialsListener; use Symfony\Component\Security\Http\EventListener\LoginThrottlingListener; use Symfony\Component\Security\Http\EventListener\PasswordMigratingListener; -use Symfony\Component\Security\Http\EventListener\RememberMeListener; use Symfony\Component\Security\Http\EventListener\SessionStrategyListener; use Symfony\Component\Security\Http\EventListener\UserCheckerListener; use Symfony\Component\Security\Http\EventListener\UserProviderListener; @@ -107,14 +105,6 @@ return static function (ContainerConfigurator $container) { service('security.authentication.session_strategy'), ]) - ->set('security.listener.remember_me', RememberMeListener::class) - ->abstract() - ->args([ - abstract_arg('remember me services'), - service('logger')->nullOnInvalid(), - ]) - ->tag('monolog.logger', ['channel' => 'security']) - ->set('security.listener.login_throttling', LoginThrottlingListener::class) ->abstract() ->args([ @@ -154,16 +144,6 @@ return static function (ContainerConfigurator $container) { ]) ->call('setTranslator', [service('translator')->ignoreOnInvalid()]) - ->set('security.authenticator.remember_me', RememberMeAuthenticator::class) - ->abstract() - ->args([ - abstract_arg('remember me services'), - param('kernel.secret'), - service('security.token_storage'), - abstract_arg('options'), - service('security.authentication.session_strategy'), - ]) - ->set('security.authenticator.x509', X509Authenticator::class) ->abstract() ->args([ diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator_login_link.php b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator_login_link.php index 2248b5e8ee..b3782e471f 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator_login_link.php +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator_login_link.php @@ -12,8 +12,9 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; use Symfony\Bundle\SecurityBundle\LoginLink\FirewallAwareLoginLinkHandler; +use Symfony\Component\Security\Core\Signature\ExpiredSignatureStorage; +use Symfony\Component\Security\Core\Signature\SignatureHasher; use Symfony\Component\Security\Http\Authenticator\LoginLinkAuthenticator; -use Symfony\Component\Security\Http\LoginLink\ExpiredLoginLinkStorage; use Symfony\Component\Security\Http\LoginLink\LoginLinkHandler; use Symfony\Component\Security\Http\LoginLink\LoginLinkHandlerInterface; @@ -34,14 +35,20 @@ return static function (ContainerConfigurator $container) { ->args([ service('router'), abstract_arg('user provider'), + abstract_arg('signature hasher'), + abstract_arg('options'), + ]) + + ->set('security.authenticator.abstract_login_link_signature_hasher', SignatureHasher::class) + ->args([ service('property_accessor'), abstract_arg('signature properties'), '%kernel.secret%', - abstract_arg('options'), - abstract_arg('expired login link storage'), + abstract_arg('expired signature storage'), + abstract_arg('max signature uses'), ]) - ->set('security.authenticator.expired_login_link_storage', ExpiredLoginLinkStorage::class) + ->set('security.authenticator.expired_login_link_storage', ExpiredSignatureStorage::class) ->abstract() ->args([ abstract_arg('cache pool service'), diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator_remember_me.php b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator_remember_me.php new file mode 100644 index 0000000000..67813c28d1 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator_remember_me.php @@ -0,0 +1,91 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Loader\Configurator; + +use Symfony\Bundle\SecurityBundle\RememberMe\FirewallAwareRememberMeHandler; +use Symfony\Component\Security\Core\Signature\SignatureHasher; +use Symfony\Component\Security\Http\Authenticator\RememberMeAuthenticator; +use Symfony\Component\Security\Http\EventListener\CheckRememberMeConditionsListener; +use Symfony\Component\Security\Http\EventListener\RememberMeListener; +use Symfony\Component\Security\Http\RememberMe\PersistentRememberMeHandler; +use Symfony\Component\Security\Http\RememberMe\RememberMeHandlerInterface; +use Symfony\Component\Security\Http\RememberMe\SignatureRememberMeHandler; + +return static function (ContainerConfigurator $container) { + $container->services() + ->set('security.authenticator.remember_me_signature_hasher', SignatureHasher::class) + ->args([ + service('property_accessor'), + abstract_arg('signature properties'), + '%kernel.secret%', + null, + null, + ]) + + ->set('security.authenticator.signature_remember_me_handler', SignatureRememberMeHandler::class) + ->abstract() + ->args([ + abstract_arg('signature hasher'), + abstract_arg('user provider'), + service('request_stack'), + abstract_arg('options'), + service('logger')->nullOnInvalid(), + ]) + ->tag('monolog.logger', ['channel' => 'security']) + + ->set('security.authenticator.persistent_remember_me_handler', PersistentRememberMeHandler::class) + ->abstract() + ->args([ + abstract_arg('token provider'), + param('kernel.secret'), + abstract_arg('user provider'), + service('request_stack'), + abstract_arg('options'), + service('logger')->nullOnInvalid(), + ]) + ->tag('monolog.logger', ['channel' => 'security']) + + ->set('security.authenticator.firewall_aware_remember_me_handler', FirewallAwareRememberMeHandler::class) + ->args([ + service('security.firewall.map'), + tagged_locator('security.remember_me_handler', 'firewall'), + service('request_stack'), + ]) + ->alias(RememberMeHandlerInterface::class, 'security.authenticator.firewall_aware_remember_me_handler') + + ->set('security.listener.check_remember_me_conditions', CheckRememberMeConditionsListener::class) + ->abstract() + ->args([ + abstract_arg('options'), + service('logger')->nullOnInvalid(), + ]) + + ->set('security.listener.remember_me', RememberMeListener::class) + ->abstract() + ->args([ + abstract_arg('remember me handler'), + service('logger')->nullOnInvalid(), + ]) + ->tag('monolog.logger', ['channel' => 'security']) + + ->set('security.authenticator.remember_me', RememberMeAuthenticator::class) + ->abstract() + ->args([ + abstract_arg('remember me handler'), + param('kernel.secret'), + service('security.token_storage'), + abstract_arg('options'), + service('logger')->nullOnInvalid(), + ]) + ->tag('monolog.logger', ['channel' => 'security']) + ; +}; diff --git a/src/Symfony/Bundle/SecurityBundle/Security/FirewallAwareTrait.php b/src/Symfony/Bundle/SecurityBundle/Security/FirewallAwareTrait.php new file mode 100644 index 0000000000..70d9178f8a --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Security/FirewallAwareTrait.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\SecurityBundle\Security; + +/** + * Provides basic functionality for services mapped by the firewall name + * in a container locator. + * + * @author Wouter de Jong + * + * @internal + */ +trait FirewallAwareTrait +{ + private $locator; + private $requestStack; + private $firewallMap; + + private function getForFirewall(): object + { + $serviceIdentifier = str_replace('FirewallAware', '', static::class); + if (null === $request = $this->requestStack->getCurrentRequest()) { + throw new \LogicException('Cannot determine the correct '.$serviceIdentifier.' to use: there is no active Request and so, the firewall cannot be determined. Try using a specific '.$serviceIdentifier().' service.'); + } + + $firewall = $this->firewallMap->getFirewallConfig($request); + if (!$firewall) { + throw new \LogicException('No '.$serviceIdentifier.' found as the current route is not covered by a firewall.'); + } + + $firewallName = $firewall->getName(); + if (!$this->locator->has($firewallName)) { + $message = 'No '.$serviceIdentifier.' found for this firewall.'; + if (\defined(static::class.'::FIREWALL_OPTION')) { + $message .= sprintf('Did you forget to add a "'.static::FIREWALL_OPTION.'" key under your "%s" firewall?', $firewallName); + } + + throw new \LogicException($message); + } + + return $this->locator->get($firewallName); + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/Security/UserAuthenticator.php b/src/Symfony/Bundle/SecurityBundle/Security/UserAuthenticator.php index 76f46e80ec..4ca7e15ddb 100644 --- a/src/Symfony/Bundle/SecurityBundle/Security/UserAuthenticator.php +++ b/src/Symfony/Bundle/SecurityBundle/Security/UserAuthenticator.php @@ -15,11 +15,9 @@ use Psr\Container\ContainerInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Security\Core\Exception\LogicException; use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Http\Authentication\UserAuthenticatorInterface; use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface; -use Symfony\Component\Security\Http\Authenticator\Passport\Badge\BadgeInterface; /** * A decorator that delegates all method calls to the authenticator @@ -32,14 +30,12 @@ use Symfony\Component\Security\Http\Authenticator\Passport\Badge\BadgeInterface; */ class UserAuthenticator implements UserAuthenticatorInterface { - private $firewallMap; - private $userAuthenticators; - private $requestStack; + use FirewallAwareTrait; public function __construct(FirewallMap $firewallMap, ContainerInterface $userAuthenticators, RequestStack $requestStack) { $this->firewallMap = $firewallMap; - $this->userAuthenticators = $userAuthenticators; + $this->locator = $userAuthenticators; $this->requestStack = $requestStack; } @@ -48,16 +44,6 @@ class UserAuthenticator implements UserAuthenticatorInterface */ public function authenticateUser(UserInterface $user, AuthenticatorInterface $authenticator, Request $request, array $badges = []): ?Response { - return $this->getUserAuthenticator()->authenticateUser($user, $authenticator, $request, $badges); - } - - private function getUserAuthenticator(): UserAuthenticatorInterface - { - $firewallConfig = $this->firewallMap->getFirewallConfig($this->requestStack->getMainRequest()); - if (null === $firewallConfig) { - throw new LogicException('Cannot call authenticate on this request, as it is not behind a firewall.'); - } - - return $this->userAuthenticators->get($firewallConfig->getName()); + return $this->getForFirewall()->authenticateUser($user, $authenticator, $request, $badges); } } diff --git a/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php b/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php index 2b20e3d90d..05a0c5c7a7 100644 --- a/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php +++ b/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php @@ -19,6 +19,7 @@ use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\RegisterEntryPoin use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\RegisterGlobalSecurityEventListenersPass; use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\RegisterLdapLocatorPass; use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\RegisterTokenUsageTrackingPass; +use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\ReplaceDecoratedRememberMeHandlerPass; use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\SortFirewallListenersPass; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\AnonymousFactory; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\CustomAuthenticatorFactory; @@ -83,6 +84,7 @@ class SecurityBundle extends Bundle $container->addCompilerPass(new RegisterGlobalSecurityEventListenersPass(), PassConfig::TYPE_BEFORE_REMOVING, -200); // execute after ResolveChildDefinitionsPass optimization pass, to ensure class names are set $container->addCompilerPass(new SortFirewallListenersPass(), PassConfig::TYPE_BEFORE_REMOVING); + $container->addCompilerPass(new ReplaceDecoratedRememberMeHandlerPass(), PassConfig::TYPE_OPTIMIZE); $container->addCompilerPass(new AddEventAliasesPass(array_merge( AuthenticationEvents::ALIASES, diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/CompleteConfigurationTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/CompleteConfigurationTest.php index be3e8d5e43..317da3930b 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/CompleteConfigurationTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/CompleteConfigurationTest.php @@ -51,13 +51,15 @@ abstract class CompleteConfigurationTest extends TestCase $this->assertEquals(3600, (string) $expiredStorage->getArgument(1)); $linker = $container->getDefinition($linkerId = 'security.authenticator.login_link_handler.main'); - $this->assertEquals(['id', 'email'], $linker->getArgument(3)); $this->assertEquals([ 'route_name' => 'login_check', 'lifetime' => 3600, - 'max_uses' => 1, - ], $linker->getArgument(5)); - $this->assertEquals($expiredStorageId, (string) $linker->getArgument(6)); + ], $linker->getArgument(3)); + + $hasher = $container->getDefinition((string) $linker->getArgument(2)); + $this->assertEquals(['id', 'email'], $hasher->getArgument(1)); + $this->assertEquals($expiredStorageId, (string) $hasher->getArgument(3)); + $this->assertEquals(1, $hasher->getArgument(4)); $authenticator = $container->getDefinition('security.authenticator.login_link.main'); $this->assertEquals($linkerId, (string) $authenticator->getArgument(0)); diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/SecurityExtensionTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/SecurityExtensionTest.php index 8b1a150262..59b65e0db7 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/SecurityExtensionTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/SecurityExtensionTest.php @@ -388,6 +388,27 @@ class SecurityExtensionTest extends TestCase $this->assertEquals($secure, $definition->getArgument(3)['secure']); } + public function testCustomRememberMeHandler() + { + $container = $this->getRawContainer(); + + $container->register('custom_remember_me', \stdClass::class); + $container->loadFromExtension('security', [ + 'enable_authenticator_manager' => true, + 'firewalls' => [ + 'default' => [ + 'remember_me' => ['secret' => 'very', 'service' => 'custom_remember_me'], + ], + ], + ]); + + $container->compile(); + + $handler = $container->getDefinition('security.authenticator.remember_me_handler.default'); + $this->assertEquals(\stdClass::class, $handler->getClass()); + $this->assertEquals([['firewall' => 'default']], $handler->getTag('security.remember_me_handler')); + } + public function sessionConfigurationProvider() { return [ @@ -661,13 +682,13 @@ class SecurityExtensionTest extends TestCase $security = new SecurityExtension(); $container->registerExtension($security); - $bundle = new SecurityBundle(); - $bundle->build($container); - $container->getCompilerPassConfig()->setOptimizationPasses([new ResolveChildDefinitionsPass()]); $container->getCompilerPassConfig()->setRemovingPasses([]); $container->getCompilerPassConfig()->setAfterRemovingPasses([]); + $bundle = new SecurityBundle(); + $bundle->build($container); + return $container; } diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/RememberMeBundle/Controller/ProfileController.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/RememberMeBundle/Controller/ProfileController.php new file mode 100644 index 0000000000..7f99d17c90 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/RememberMeBundle/Controller/ProfileController.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\RememberMeBundle\Controller; + +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Security\Core\User\UserInterface; + +class ProfileController +{ + public function __invoke(UserInterface $user) + { + return new Response($user->getUserIdentifier()); + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/ClearRememberMe/bundles.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/RememberMeBundle/RememberMeBundle.php similarity index 58% rename from src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/ClearRememberMe/bundles.php rename to src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/RememberMeBundle/RememberMeBundle.php index 9a26fb163a..191af0057e 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/ClearRememberMe/bundles.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/RememberMeBundle/RememberMeBundle.php @@ -9,10 +9,10 @@ * file that was distributed with this source code. */ -use Symfony\Bundle\FrameworkBundle\FrameworkBundle; -use Symfony\Bundle\SecurityBundle\SecurityBundle; +namespace Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\RememberMeBundle; -return [ - new FrameworkBundle(), - new SecurityBundle(), -]; +use Symfony\Component\HttpKernel\Bundle\Bundle; + +class RememberMeBundle extends Bundle +{ +} diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/RememberMeBundle/Security/StaticTokenProvider.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/RememberMeBundle/Security/StaticTokenProvider.php new file mode 100644 index 0000000000..43479ca9cf --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/RememberMeBundle/Security/StaticTokenProvider.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\RememberMeBundle\Security; + +use Symfony\Component\Security\Core\Authentication\RememberMe\PersistentTokenInterface; +use Symfony\Component\Security\Core\Authentication\RememberMe\TokenProviderInterface; +use Symfony\Component\Security\Core\Exception\TokenNotFoundException; + +class StaticTokenProvider implements TokenProviderInterface +{ + private static $db = []; + private static $kernelClass; + + public function __construct($kernel) + { + // only reset the "internal db" for new tests + if (self::$kernelClass !== \get_class($kernel)) { + self::$kernelClass = \get_class($kernel); + self::$db = []; + } + } + + public function loadTokenBySeries(string $series) + { + $token = self::$db[$series] ?? false; + if (!$token) { + throw new TokenNotFoundException(); + } + + return $token; + } + + public function deleteTokenBySeries(string $series) + { + unset(self::$db[$series]); + } + + public function updateToken(string $series, string $tokenValue, \DateTime $lastUsed) + { + $token = $this->loadTokenBySeries($series); + $refl = new \ReflectionClass($token); + $tokenValueProp = $refl->getProperty('tokenValue'); + $tokenValueProp->setAccessible(true); + $tokenValueProp->setValue($token, $tokenValue); + + $lastUsedProp = $refl->getProperty('lastUsed'); + $lastUsedProp->setAccessible(true); + $lastUsedProp->setValue($token, $lastUsed); + + self::$db[$series] = $token; + } + + public function createNewToken(PersistentTokenInterface $token) + { + self::$db[$token->getSeries()] = $token; + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/RememberMeBundle/Security/UserChangingUserProvider.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/RememberMeBundle/Security/UserChangingUserProvider.php new file mode 100644 index 0000000000..e7206f4020 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/RememberMeBundle/Security/UserChangingUserProvider.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\RememberMeBundle\Security; + +use Symfony\Component\Security\Core\User\InMemoryUserProvider; +use Symfony\Component\Security\Core\User\User; +use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\Security\Core\User\UserProviderInterface; + +class UserChangingUserProvider implements UserProviderInterface +{ + private $inner; + + public function __construct(InMemoryUserProvider $inner) + { + $this->inner = $inner; + } + + public function loadUserByUsername($username) + { + return $this->inner->loadUserByUsername($username); + } + + public function loadUserByIdentifier(string $userIdentifier): UserInterface + { + return $this->inner->loadUserByIdentifier($userIdentifier); + } + + public function refreshUser(UserInterface $user) + { + $user = $this->inner->refreshUser($user); + + $alterUser = \Closure::bind(function (User $user) { $user->password = 'foo'; }, null, User::class); + $alterUser($user); + + return $user; + } + + public function supportsClass($class) + { + return $this->inner->supportsClass($class); + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/ClearRememberMeTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/ClearRememberMeTest.php deleted file mode 100644 index 66a6676375..0000000000 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/ClearRememberMeTest.php +++ /dev/null @@ -1,91 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Bundle\SecurityBundle\Tests\Functional; - -use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Security\Core\User\InMemoryUserProvider; -use Symfony\Component\Security\Core\User\User; -use Symfony\Component\Security\Core\User\UserInterface; -use Symfony\Component\Security\Core\User\UserProviderInterface; - -class ClearRememberMeTest extends AbstractWebTestCase -{ - /** - * @dataProvider provideClientOptions - */ - public function testUserChangeClearsCookie(array $options) - { - $client = $this->createClient($options); - - $client->request('POST', '/login', [ - '_username' => 'johannes', - '_password' => 'test', - ]); - - $this->assertSame(302, $client->getResponse()->getStatusCode()); - $cookieJar = $client->getCookieJar(); - $this->assertNotNull($cookieJar->get('REMEMBERME')); - - $client->request('GET', '/foo'); - $this->assertRedirect($client->getResponse(), '/login'); - $this->assertNull($cookieJar->get('REMEMBERME')); - } - - public function provideClientOptions() - { - yield [['test_case' => 'ClearRememberMe', 'root_config' => 'config.yml', 'enable_authenticator_manager' => true]]; - yield [['test_case' => 'ClearRememberMe', 'root_config' => 'legacy_config.yml', 'enable_authenticator_manager' => false]]; - } -} - -class RememberMeFooController -{ - public function __invoke(UserInterface $user) - { - return new Response($user->getUserIdentifier()); - } -} - -class RememberMeUserProvider implements UserProviderInterface -{ - private $inner; - - public function __construct(InMemoryUserProvider $inner) - { - $this->inner = $inner; - } - - public function loadUserByUsername($username) - { - return $this->loadUserByIdentifier($username); - } - - public function loadUserByIdentifier(string $identifier): UserInterface - { - return $this->inner->loadUserByIdentifier($identifier); - } - - public function refreshUser(UserInterface $user) - { - $user = $this->inner->refreshUser($user); - - $alterUser = \Closure::bind(function (User $user) { $user->password = 'foo'; }, null, User::class); - $alterUser($user); - - return $user; - } - - public function supportsClass($class) - { - return $this->inner->supportsClass($class); - } -} diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/LogoutTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/LogoutTest.php index f5bc921042..8af5aa7c35 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/LogoutTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/LogoutTest.php @@ -20,29 +20,6 @@ use Symfony\Component\HttpKernel\KernelEvents; class LogoutTest extends AbstractWebTestCase { - /** - * @dataProvider provideSecuritySystems - */ - public function testSessionLessRememberMeLogout(array $options) - { - $client = $this->createClient($options + ['test_case' => 'RememberMeLogout', 'root_config' => 'config.yml']); - - $client->request('POST', '/login', [ - '_username' => 'johannes', - '_password' => 'test', - ]); - - $cookieJar = $client->getCookieJar(); - $cookieJar->expire(session_name()); - - $this->assertNotNull($cookieJar->get('REMEMBERME')); - $this->assertSame('lax', $cookieJar->get('REMEMBERME')->getSameSite()); - - $client->request('GET', '/logout'); - - $this->assertNull($cookieJar->get('REMEMBERME')); - } - /** * @dataProvider provideSecuritySystems */ diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/RememberMeTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/RememberMeTest.php new file mode 100644 index 0000000000..9e736f0955 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/RememberMeTest.php @@ -0,0 +1,95 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\SecurityBundle\Tests\Functional; + +class RememberMeTest extends AbstractWebTestCase +{ + public function provideRememberMeSystems() + { + foreach ($this->provideSecuritySystems() as $securitySystem) { + yield [$securitySystem[0] + ['root_config' => 'config_session.yml']]; + yield [$securitySystem[0] + ['root_config' => 'config_persistent.yml']]; + } + } + + /** + * @dataProvider provideRememberMeSystems + */ + public function testRememberMe(array $options) + { + $client = $this->createClient(array_merge_recursive(['root_config' => 'config.yml', 'test_case' => 'RememberMe'], $options)); + + $client->request('POST', '/login', [ + '_username' => 'johannes', + '_password' => 'test', + ]); + $this->assertSame(302, $client->getResponse()->getStatusCode()); + + $client->request('GET', '/profile'); + $this->assertSame('johannes', $client->getResponse()->getContent()); + + // clear session, this should trigger remember me on the next request + $client->getCookieJar()->expire('MOCKSESSID'); + + $client->request('GET', '/profile'); + $this->assertSame('johannes', $client->getResponse()->getContent(), 'Not logged in after resetting session.'); + + // logout, this should clear the remember-me cookie + $client->request('GET', '/logout'); + $this->assertSame(302, $client->getResponse()->getStatusCode(), 'Logout unsuccessful.'); + $this->assertNull($client->getCookieJar()->get('REMEMBERME')); + } + + /** + * @dataProvider provideSecuritySystems + */ + public function testUserChangeClearsCookie(array $options) + { + $client = $this->createClient(['test_case' => 'RememberMe', 'root_config' => 'clear_on_change_config.yml'] + $options); + + $client->request('POST', '/login', [ + '_username' => 'johannes', + '_password' => 'test', + ]); + + $this->assertSame(302, $client->getResponse()->getStatusCode()); + $cookieJar = $client->getCookieJar(); + $this->assertNotNull($cookieJar->get('REMEMBERME')); + + $client->request('GET', '/profile'); + $this->assertRedirect($client->getResponse(), '/login'); + $this->assertNull($cookieJar->get('REMEMBERME')); + } + + /** + * @dataProvider provideSecuritySystems + */ + public function testSessionLessRememberMeLogout(array $options) + { + $client = $this->createClient(['test_case' => 'RememberMe', 'root_config' => 'stateless_config.yml'] + $options); + + $client->request('POST', '/login', [ + '_username' => 'johannes', + '_password' => 'test', + ]); + + $cookieJar = $client->getCookieJar(); + $cookieJar->expire(session_name()); + + $this->assertNotNull($cookieJar->get('REMEMBERME')); + $this->assertSame('lax', $cookieJar->get('REMEMBERME')->getSameSite()); + + $client->request('GET', '/logout'); + $this->assertSame(302, $client->getResponse()->getStatusCode(), 'Logout unsuccessful.'); + $this->assertNull($cookieJar->get('REMEMBERME')); + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AppKernel.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AppKernel.php index 72d23f03f3..96670d1322 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AppKernel.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AppKernel.php @@ -36,10 +36,13 @@ class AppKernel extends Kernel $this->testCase = $testCase; $fs = new Filesystem(); - if (!$fs->isAbsolutePath($rootConfig) && !is_file($rootConfig = __DIR__.'/'.$testCase.'/'.$rootConfig)) { - throw new \InvalidArgumentException(sprintf('The root config "%s" does not exist.', $rootConfig)); + foreach ((array) $rootConfig as $config) { + if (!$fs->isAbsolutePath($config) && !is_file($config = __DIR__.'/'.$testCase.'/'.$config)) { + throw new \InvalidArgumentException(sprintf('The root config "%s" does not exist.', $config)); + } + + $this->rootConfig[] = $config; } - $this->rootConfig = $rootConfig; $this->authenticatorManagerEnabled = $authenticatorManagerEnabled; parent::__construct($environment, $debug); @@ -50,7 +53,7 @@ class AppKernel extends Kernel */ public function getContainerClass(): string { - return parent::getContainerClass().substr(md5($this->rootConfig.$this->authenticatorManagerEnabled), -16); + return parent::getContainerClass().substr(md5(implode('', $this->rootConfig).$this->authenticatorManagerEnabled), -16); } public function registerBundles(): iterable @@ -79,7 +82,9 @@ class AppKernel extends Kernel public function registerContainerConfiguration(LoaderInterface $loader) { - $loader->load($this->rootConfig); + foreach ($this->rootConfig as $config) { + $loader->load($config); + } if ($this->authenticatorManagerEnabled) { $loader->load(function ($container) { diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/ClearRememberMe/config.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/ClearRememberMe/config.yml deleted file mode 100644 index 24c6581f29..0000000000 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/ClearRememberMe/config.yml +++ /dev/null @@ -1,30 +0,0 @@ -imports: - - { resource: ./../config/framework.yml } - -security: - password_hashers: - Symfony\Component\Security\Core\User\InMemoryUser: plaintext - - providers: - in_memory: - memory: - users: - johannes: { password: test, roles: [ROLE_USER] } - - firewalls: - default: - form_login: - check_path: login - remember_me: true - remember_me: - always_remember_me: true - secret: key - - access_control: - - { path: ^/foo, roles: ROLE_USER } - -services: - Symfony\Bundle\SecurityBundle\Tests\Functional\RememberMeUserProvider: - public: true - decorates: security.user.provider.concrete.in_memory - arguments: ['@Symfony\Bundle\SecurityBundle\Tests\Functional\RememberMeUserProvider.inner'] diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/ClearRememberMe/legacy_config.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/ClearRememberMe/legacy_config.yml deleted file mode 100644 index 5dfc173869..0000000000 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/ClearRememberMe/legacy_config.yml +++ /dev/null @@ -1,7 +0,0 @@ -imports: - - { resource: ./config.yml } - -security: - firewalls: - default: - anonymous: ~ diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/ClearRememberMe/routing.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/ClearRememberMe/routing.yml deleted file mode 100644 index 08975bdcb3..0000000000 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/ClearRememberMe/routing.yml +++ /dev/null @@ -1,7 +0,0 @@ -login: - path: /login - -foo: - path: /foo - defaults: - _controller: Symfony\Bundle\SecurityBundle\Tests\Functional\RememberMeFooController diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/RememberMeLogout/bundles.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/RememberMe/bundles.php similarity index 77% rename from src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/RememberMeLogout/bundles.php rename to src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/RememberMe/bundles.php index a52ae15f6d..341dac04c2 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/RememberMeLogout/bundles.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/RememberMe/bundles.php @@ -11,10 +11,10 @@ use Symfony\Bundle\FrameworkBundle\FrameworkBundle; use Symfony\Bundle\SecurityBundle\SecurityBundle; -use Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\TestBundle; +use Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\RememberMeBundle\RememberMeBundle; return [ new FrameworkBundle(), new SecurityBundle(), - new TestBundle(), + new RememberMeBundle(), ]; diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/RememberMe/clear_on_change_config.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/RememberMe/clear_on_change_config.yml new file mode 100644 index 0000000000..b01603b3f6 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/RememberMe/clear_on_change_config.yml @@ -0,0 +1,9 @@ +imports: + - { resource: ./config.yml } + - { resource: ./config_session.yml } + +services: + Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\RememberMeBundle\Security\UserChangingUserProvider: + public: true + decorates: security.user.provider.concrete.in_memory + arguments: ['@.inner'] diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/RememberMeLogout/config.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/RememberMe/config.yml similarity index 59% rename from src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/RememberMeLogout/config.yml rename to src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/RememberMe/config.yml index 542b40ba6b..696a9041e8 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/RememberMeLogout/config.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/RememberMe/config.yml @@ -1,12 +1,6 @@ imports: - { resource: ./../config/framework.yml } -framework: - session: - storage_factory_id: session.storage.factory.mock_file - cookie_secure: auto - cookie_samesite: lax - security: password_hashers: Symfony\Component\Security\Core\User\InMemoryUser: plaintext @@ -19,12 +13,10 @@ security: firewalls: default: + logout: ~ form_login: check_path: login remember_me: true - require_previous_session: false - remember_me: - always_remember_me: true - secret: key - logout: ~ - stateless: true + + access_control: + - { path: ^/profile, roles: ROLE_USER } diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/RememberMe/config_persistent.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/RememberMe/config_persistent.yml new file mode 100644 index 0000000000..a529c217f2 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/RememberMe/config_persistent.yml @@ -0,0 +1,12 @@ +services: + app.static_token_provider: + class: Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\RememberMeBundle\Security\StaticTokenProvider + arguments: ['@kernel'] + +security: + firewalls: + default: + remember_me: + always_remember_me: true + secret: key + token_provider: app.static_token_provider diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/RememberMe/config_session.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/RememberMe/config_session.yml new file mode 100644 index 0000000000..411de7211e --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/RememberMe/config_session.yml @@ -0,0 +1,6 @@ +security: + firewalls: + default: + remember_me: + always_remember_me: true + secret: key diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/RememberMe/routing.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/RememberMe/routing.yml new file mode 100644 index 0000000000..a4f97930a2 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/RememberMe/routing.yml @@ -0,0 +1,9 @@ +login: + path: /login + +logout: + path: /logout + +profile: + path: /profile + controller: Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\RememberMeBundle\Controller\ProfileController diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/RememberMe/stateless_config.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/RememberMe/stateless_config.yml new file mode 100644 index 0000000000..69a5586c80 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/RememberMe/stateless_config.yml @@ -0,0 +1,13 @@ +imports: + - { resource: ./config.yml } + - { resource: ./config_session.yml } + +framework: + session: + cookie_secure: auto + cookie_samesite: lax + +security: + firewalls: + default: + stateless: true diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/RememberMeLogout/routing.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/RememberMeLogout/routing.yml deleted file mode 100644 index 1dddfca2f8..0000000000 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/RememberMeLogout/routing.yml +++ /dev/null @@ -1,5 +0,0 @@ -login: - path: /login - -logout: - path: /logout diff --git a/src/Symfony/Component/Security/CHANGELOG.md b/src/Symfony/Component/Security/CHANGELOG.md index adfd240992..e8c3d88388 100644 --- a/src/Symfony/Component/Security/CHANGELOG.md +++ b/src/Symfony/Component/Security/CHANGELOG.md @@ -4,6 +4,11 @@ CHANGELOG 5.3 --- + * Add `RememberMeConditionsListener` to check if remember me is requested and supported, and set priority of `RememberMeListener` to -63 + * Add `RememberMeHandlerInterface` and implementations, used as a replacement of `RememberMeServicesInterface` when using the AuthenticatorManager + * Add `TokenDeauthenticatedEvent` that is dispatched when the current security token is deauthenticated + * [BC break] Change constructor signature of `LoginLinkHandler` to `__construct(UrlGeneratorInterface $urlGenerator, UserProviderInterface $userProvider, SignatureHasher $signatureHashUtil, array $options)` + * Add `Core\Signature\SignatureHasher` and moved `Http\LoginLink\ExpiredLoginLinkStorage` to `Core\Signature\ExpiredLoginLinkStorage` * Deprecate `PersistentTokenInterface::getUsername()` in favor of `PersistentTokenInterface::getUserIdentifier()` * Deprecate `UsernameNotFoundException` in favor of `UserNotFoundException` and `getUsername()`/`setUsername()` in favor of `getUserIdentifier()`/`setUserIdentifier()` * Deprecate `UserProviderInterface::loadUserByUsername()` in favor of `UserProviderInterface::loadUserByIdentifier()` diff --git a/src/Symfony/Component/Security/Core/Signature/Exception/ExpiredSignatureException.php b/src/Symfony/Component/Security/Core/Signature/Exception/ExpiredSignatureException.php new file mode 100644 index 0000000000..8986c62f3d --- /dev/null +++ b/src/Symfony/Component/Security/Core/Signature/Exception/ExpiredSignatureException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Core\Signature\Exception; + +use Symfony\Component\Security\Core\Exception\RuntimeException; + +/** + * @author Wouter de Jong + */ +class ExpiredSignatureException extends RuntimeException +{ +} diff --git a/src/Symfony/Component/Security/Core/Signature/Exception/InvalidSignatureException.php b/src/Symfony/Component/Security/Core/Signature/Exception/InvalidSignatureException.php new file mode 100644 index 0000000000..72102fe86c --- /dev/null +++ b/src/Symfony/Component/Security/Core/Signature/Exception/InvalidSignatureException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Core\Signature\Exception; + +use Symfony\Component\Security\Core\Exception\RuntimeException; + +/** + * @author Wouter de Jong + */ +class InvalidSignatureException extends RuntimeException +{ +} diff --git a/src/Symfony/Component/Security/Http/LoginLink/ExpiredLoginLinkStorage.php b/src/Symfony/Component/Security/Core/Signature/ExpiredSignatureStorage.php similarity index 88% rename from src/Symfony/Component/Security/Http/LoginLink/ExpiredLoginLinkStorage.php rename to src/Symfony/Component/Security/Core/Signature/ExpiredSignatureStorage.php index 1a7dbd68fb..e5b9f9007d 100644 --- a/src/Symfony/Component/Security/Http/LoginLink/ExpiredLoginLinkStorage.php +++ b/src/Symfony/Component/Security/Core/Signature/ExpiredSignatureStorage.php @@ -9,16 +9,18 @@ * file that was distributed with this source code. */ -namespace Symfony\Component\Security\Http\LoginLink; +namespace Symfony\Component\Security\Core\Signature; use Psr\Cache\CacheItemPoolInterface; /** + * @author Ryan Weaver + * * @experimental in 5.2 * * @final */ -class ExpiredLoginLinkStorage +final class ExpiredSignatureStorage { private $cache; private $lifetime; diff --git a/src/Symfony/Component/Security/Core/Signature/SignatureHasher.php b/src/Symfony/Component/Security/Core/Signature/SignatureHasher.php new file mode 100644 index 0000000000..ad40283205 --- /dev/null +++ b/src/Symfony/Component/Security/Core/Signature/SignatureHasher.php @@ -0,0 +1,99 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Core\Signature; + +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\Security\Core\Signature\Exception\ExpiredSignatureException; +use Symfony\Component\Security\Core\Signature\Exception\InvalidSignatureException; +use Symfony\Component\Security\Core\Signature\ExpiredSignatureStorage; + +/** + * Creates and validates secure hashes used in login links and remember-me cookies. + * + * @author Wouter de Jong + * @author Ryan Weaver + */ +class SignatureHasher +{ + private $propertyAccessor; + private $signatureProperties; + private $secret; + private $expiredSignaturesStorage; + private $maxUses; + + /** + * @param array $signatureProperties properties of the User; the hash is invalidated if these properties change + * @param ExpiredSignatureStorage|null $expiredSignaturesStorage if provided, secures a sequence of hashes that are expired + * @param int|null $maxUses used together with $expiredSignatureStorage to allow a maximum usage of a hash + */ + public function __construct(PropertyAccessorInterface $propertyAccessor, array $signatureProperties, string $secret, ?ExpiredSignatureStorage $expiredSignaturesStorage = null, ?int $maxUses = null) + { + $this->propertyAccessor = $propertyAccessor; + $this->signatureProperties = $signatureProperties; + $this->secret = $secret; + $this->expiredSignaturesStorage = $expiredSignaturesStorage; + $this->maxUses = $maxUses; + } + + /** + * Verifies the hash using the provided user and expire time. + * + * @param int $expires the expiry time as a unix timestamp + * @param string $hash the plaintext hash provided by the request + * + * @throws InvalidSignatureException If the signature does not match the provided parameters + * @throws ExpiredSignatureException If the signature is no longer valid + */ + public function verifySignatureHash(UserInterface $user, int $expires, string $hash): void + { + if (!hash_equals($hash, $this->computeSignatureHash($user, $expires))) { + throw new InvalidSignatureException('Invalid or expired signature.'); + } + + if ($expires < time()) { + throw new ExpiredSignatureException('Signature has expired.'); + } + + if ($this->expiredSignaturesStorage && $this->maxUses) { + if ($this->expiredSignaturesStorage->countUsages($hash) >= $this->maxUses) { + throw new ExpiredSignatureException(sprintf('Signature can only be used "%d" times.', $this->maxUses)); + } + + $this->expiredSignaturesStorage->incrementUsages($hash); + } + } + + /** + * Computes the secure hash for the provided user and expire time. + * + * @param int $expires the expiry time as a unix timestamp + */ + public function computeSignatureHash(UserInterface $user, int $expires): string + { + $signatureFields = [base64_encode(method_exists($user, 'getUserIdentifier') ? $user->getUserIdentifier() : $user->getUsername()), $expires]; + + foreach ($this->signatureProperties as $property) { + $value = $this->propertyAccessor->getValue($user, $property) ?? ''; + if ($value instanceof \DateTimeInterface) { + $value = $value->format('c'); + } + + if (!is_scalar($value) && !(\is_object($value) && method_exists($value, '__toString'))) { + throw new \InvalidArgumentException(sprintf('The property path "%s" on the user object "%s" must return a value that can be cast to a string, but "%s" was returned.', $property, \get_class($user), get_debug_type($value))); + } + $signatureFields[] = base64_encode($value); + } + + return base64_encode(hash_hmac('sha256', implode(':', $signatureFields), $this->secret)); + } +} diff --git a/src/Symfony/Component/Security/Http/Tests/LoginLink/ExpiredLoginLinkStorageTest.php b/src/Symfony/Component/Security/Core/Tests/Signature/ExpiredSignatureStorageTest.php similarity index 71% rename from src/Symfony/Component/Security/Http/Tests/LoginLink/ExpiredLoginLinkStorageTest.php rename to src/Symfony/Component/Security/Core/Tests/Signature/ExpiredSignatureStorageTest.php index d4527c5acc..7293d8737d 100644 --- a/src/Symfony/Component/Security/Http/Tests/LoginLink/ExpiredLoginLinkStorageTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Signature/ExpiredSignatureStorageTest.php @@ -9,18 +9,18 @@ * file that was distributed with this source code. */ -namespace Symfony\Component\Security\Http\Tests\LoginLink; +namespace Symfony\Component\Security\Core\Tests\Signature; use PHPUnit\Framework\TestCase; use Symfony\Component\Cache\Adapter\ArrayAdapter; -use Symfony\Component\Security\Http\LoginLink\ExpiredLoginLinkStorage; +use Symfony\Component\Security\Core\Signature\ExpiredSignatureStorage; -class ExpiredLoginLinkStorageTest extends TestCase +class ExpiredSignatureStorageTest extends TestCase { public function testUsage() { $cache = new ArrayAdapter(); - $storage = new ExpiredLoginLinkStorage($cache, 600); + $storage = new ExpiredSignatureStorage($cache, 600); $this->assertSame(0, $storage->countUsages('hash+more')); $storage->incrementUsages('hash+more'); diff --git a/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/RememberMeBadge.php b/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/RememberMeBadge.php index 8ce47fce27..5a03f90c52 100644 --- a/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/RememberMeBadge.php +++ b/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/RememberMeBadge.php @@ -14,14 +14,9 @@ namespace Symfony\Component\Security\Http\Authenticator\Passport\Badge; /** * Adds support for remember me to this authenticator. * - * Remember me cookie will be set if *all* of the following are met: - * A) This badge is present in the Passport - * B) The remember_me key under your firewall is configured - * C) The "remember me" functionality is activated. This is usually - * done by having a _remember_me checkbox in your form, but - * can be configured by the "always_remember_me" and "remember_me_parameter" - * parameters under the "remember_me" firewall key - * D) The authentication process returns a success Response object + * The presence of this badge doesn't create the remember-me cookie. The actual + * cookie is only created if this badge is enabled. By default, this is done + * by the {@see RememberMeConditionsListener} if all conditions are met. * * @author Wouter de Jong * @@ -30,6 +25,40 @@ namespace Symfony\Component\Security\Http\Authenticator\Passport\Badge; */ class RememberMeBadge implements BadgeInterface { + private $enabled = false; + + /** + * Enables remember-me cookie creation. + * + * In most cases, {@see RememberMeConditionsListener} enables this + * automatically if always_remember_me is true or the remember_me_parameter + * exists in the request. + * + * @return $this + */ + public function enable(): self + { + $this->enabled = true; + + return $this; + } + + /** + * Disables remember-me cookie creation. + * + * The default is disabled, this can be called to suppress creation + * after it was enabled. + */ + public function disable(): void + { + $this->enabled = false; + } + + public function isEnabled(): bool + { + return $this->enabled; + } + public function isResolved(): bool { return true; // remember me does not need to be explicitly resolved diff --git a/src/Symfony/Component/Security/Http/Authenticator/RememberMeAuthenticator.php b/src/Symfony/Component/Security/Http/Authenticator/RememberMeAuthenticator.php index b663a27506..d47b10189a 100644 --- a/src/Symfony/Component/Security/Http/Authenticator/RememberMeAuthenticator.php +++ b/src/Symfony/Component/Security/Http/Authenticator/RememberMeAuthenticator.php @@ -11,22 +11,28 @@ namespace Symfony\Component\Security\Http\Authenticator; +use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Security\Core\Authentication\Token\RememberMeToken; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Exception\AuthenticationException; +use Symfony\Component\Security\Core\Exception\CookieTheftException; +use Symfony\Component\Security\Core\Exception\UnsupportedUserException; +use Symfony\Component\Security\Core\Exception\UsernameNotFoundException; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface; use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; -use Symfony\Component\Security\Http\RememberMe\RememberMeServicesInterface; +use Symfony\Component\Security\Http\RememberMe\RememberMeDetails; +use Symfony\Component\Security\Http\RememberMe\RememberMeHandlerInterface; +use Symfony\Component\Security\Http\RememberMe\ResponseListener; /** * The RememberMe *Authenticator* performs remember me authentication. * * This authenticator is executed whenever a user's session - * expired and a remember me cookie was found. This authenticator + * expired and a remember-me cookie was found. This authenticator * then "re-authenticates" the user using the information in the * cookie. * @@ -37,17 +43,19 @@ use Symfony\Component\Security\Http\RememberMe\RememberMeServicesInterface; */ class RememberMeAuthenticator implements InteractiveAuthenticatorInterface { - private $rememberMeServices; + private $rememberMeHandler; private $secret; private $tokenStorage; - private $options = []; + private $cookieName; + private $logger; - public function __construct(RememberMeServicesInterface $rememberMeServices, string $secret, TokenStorageInterface $tokenStorage, array $options) + public function __construct(RememberMeHandlerInterface $rememberMeHandler, string $secret, TokenStorageInterface $tokenStorage, string $cookieName, LoggerInterface $logger = null) { - $this->rememberMeServices = $rememberMeServices; + $this->rememberMeHandler = $rememberMeHandler; $this->secret = $secret; $this->tokenStorage = $tokenStorage; - $this->options = $options; + $this->cookieName = $cookieName; + $this->logger = $logger; } public function supports(Request $request): ?bool @@ -57,19 +65,17 @@ class RememberMeAuthenticator implements InteractiveAuthenticatorInterface return false; } - // if the attribute is set, this is a lazy firewall. The previous - // support call already indicated support, so return null and avoid - // recreating the cookie - if ($request->attributes->has('_remember_me_token')) { - return null; - } - - $token = $this->rememberMeServices->autoLogin($request); - if (null === $token) { + if (($cookie = $request->attributes->get(ResponseListener::COOKIE_ATTR_NAME)) && null === $cookie->getValue()) { return false; } - $request->attributes->set('_remember_me_token', $token); + if (!$request->cookies->has($this->cookieName)) { + return false; + } + + if (null !== $this->logger) { + $this->logger->debug('Remember-me cookie detected.'); + } // the `null` return value indicates that this authenticator supports lazy firewalls return null; @@ -77,13 +83,16 @@ class RememberMeAuthenticator implements InteractiveAuthenticatorInterface public function authenticate(Request $request): PassportInterface { - $token = $request->attributes->get('_remember_me_token'); - if (null === $token) { - throw new \LogicException('No remember me token is set.'); + $rawCookie = $request->cookies->get($this->cookieName); + if (!$rawCookie) { + throw new \LogicException('No remember-me cookie is found.'); } - // @deprecated since 5.3, change to $token->getUserIdentifier() in 6.0 - return new SelfValidatingPassport(new UserBadge(method_exists($token, 'getUserIdentifier') ? $token->getUserIdentifier() : $token->getUsername(), [$token, 'getUser'])); + $rememberMeCookie = RememberMeDetails::fromRawCookie($rawCookie); + + return new SelfValidatingPassport(new UserBadge($rememberMeCookie->getUserIdentifier(), function () use ($rememberMeCookie) { + return $this->rememberMeHandler->consumeRememberMeCookie($rememberMeCookie); + })); } public function createAuthenticatedToken(PassportInterface $passport, string $firewallName): TokenInterface @@ -98,7 +107,15 @@ class RememberMeAuthenticator implements InteractiveAuthenticatorInterface public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response { - $this->rememberMeServices->loginFail($request, $exception); + if (null !== $this->logger) { + if ($exception instanceof UsernameNotFoundException) { + $this->logger->info('User for remember-me cookie not found.', ['exception' => $exception]); + } elseif ($exception instanceof UnsupportedUserException) { + $this->logger->warning('User class for remember-me cookie not supported.', ['exception' => $exception]); + } elseif (!$exception instanceof CookieTheftException) { + $this->logger->debug('Remember me authentication failed.', ['exception' => $exception]); + } + } return null; } diff --git a/src/Symfony/Component/Security/Http/Event/DeauthenticatedEvent.php b/src/Symfony/Component/Security/Http/Event/DeauthenticatedEvent.php index d36fb50e75..cd4e8e01de 100644 --- a/src/Symfony/Component/Security/Http/Event/DeauthenticatedEvent.php +++ b/src/Symfony/Component/Security/Http/Event/DeauthenticatedEvent.php @@ -15,7 +15,11 @@ use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Contracts\EventDispatcher\Event; /** - * Deauthentication happens in case the user has changed when trying to refresh the token. + * Deauthentication happens in case the user has changed when trying to + * refresh the token. + * + * Use {@see TokenDeauthenticatedEvent} if you want to cover all cases where + * a session is deauthenticated. * * @author Hamza Amrouche */ diff --git a/src/Symfony/Component/Security/Http/Event/TokenDeauthenticatedEvent.php b/src/Symfony/Component/Security/Http/Event/TokenDeauthenticatedEvent.php new file mode 100644 index 0000000000..b09f4ec1fc --- /dev/null +++ b/src/Symfony/Component/Security/Http/Event/TokenDeauthenticatedEvent.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Event; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Contracts\EventDispatcher\Event; + +/** + * This event is dispatched when the current security token is deauthenticated + * when trying to reference the token. + * + * This includes changes in the user ({@see DeauthenticatedEvent}), but + * also cases where there is no user provider available to refresh the user. + * + * Use this event if you want to trigger some actions whenever a user is + * deauthenticated and redirected back to the authentication entry point + * (e.g. clearing all remember-me cookies). + * + * @author Wouter de Jong + */ +final class TokenDeauthenticatedEvent extends Event +{ + private $originalToken; + private $request; + + public function __construct(TokenInterface $originalToken, Request $request) + { + $this->originalToken = $originalToken; + $this->request = $request; + } + + public function getOriginalToken(): TokenInterface + { + return $this->originalToken; + } + + public function getRequest(): Request + { + return $this->request; + } +} diff --git a/src/Symfony/Component/Security/Http/EventListener/CheckRememberMeConditionsListener.php b/src/Symfony/Component/Security/Http/EventListener/CheckRememberMeConditionsListener.php new file mode 100644 index 0000000000..ccf201d722 --- /dev/null +++ b/src/Symfony/Component/Security/Http/EventListener/CheckRememberMeConditionsListener.php @@ -0,0 +1,79 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\EventListener; + +use Psr\Log\LoggerInterface; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\RememberMeBadge; +use Symfony\Component\Security\Http\Event\LoginFailureEvent; +use Symfony\Component\Security\Http\Event\LoginSuccessEvent; +use Symfony\Component\Security\Http\Event\LogoutEvent; +use Symfony\Component\Security\Http\Event\TokenDeauthenticatedEvent; +use Symfony\Component\Security\Http\ParameterBagUtils; +use Symfony\Component\Security\Http\RememberMe\RememberMeHandlerInterface; + +/** + * Checks if all conditions are met for remember me. + * + * The conditions that must be met for this listener to enable remember me: + * A) This badge is present in the Passport + * B) The remember_me key under your firewall is configured + * C) The "remember me" functionality is activated. This is usually + * done by having a _remember_me checkbox in your form, but + * can be configured by the "always_remember_me" and "remember_me_parameter" + * parameters under the "remember_me" firewall key (or "always_remember_me" + * is enabled) + * + * @author Wouter de Jong + * + * @final + * @experimental in 5.3 + */ +class CheckRememberMeConditionsListener implements EventSubscriberInterface +{ + private $options; + private $logger; + + public function __construct(array $options = [], ?LoggerInterface $logger = null) + { + $this->options = $options + ['always_remember_me' => false, 'remember_me_parameter' => '_remember_me']; + $this->logger = $logger; + } + + public function onSuccessfulLogin(LoginSuccessEvent $event): void + { + $passport = $event->getPassport(); + if (!$passport->hasBadge(RememberMeBadge::class)) { + return; + } + + /** @var RememberMeBadge $badge */ + $badge = $passport->getBadge(RememberMeBadge::class); + if (!$this->options['always_remember_me']) { + $parameter = ParameterBagUtils::getRequestParameterValue($event->getRequest(), $this->options['remember_me_parameter']); + if (!('true' === $parameter || 'on' === $parameter || '1' === $parameter || 'yes' === $parameter || true === $parameter)) { + if (null !== $this->logger) { + $this->logger->debug('Remember me disabled; request does not contain remember me parameter ("{parameter}").', ['parameter' => $this->options['remember_me_parameter']]); + } + + return; + } + } + + $badge->enable(); + } + + public static function getSubscribedEvents(): array + { + return [LoginSuccessEvent::class => ['onSuccessfulLogin', -32]]; + } +} diff --git a/src/Symfony/Component/Security/Http/EventListener/RememberMeListener.php b/src/Symfony/Component/Security/Http/EventListener/RememberMeListener.php index 70e15aa406..08f58e1078 100644 --- a/src/Symfony/Component/Security/Http/EventListener/RememberMeListener.php +++ b/src/Symfony/Component/Security/Http/EventListener/RememberMeListener.php @@ -16,15 +16,18 @@ use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\RememberMeBadge; use Symfony\Component\Security\Http\Event\LoginFailureEvent; use Symfony\Component\Security\Http\Event\LoginSuccessEvent; -use Symfony\Component\Security\Http\RememberMe\RememberMeServicesInterface; +use Symfony\Component\Security\Http\Event\LogoutEvent; +use Symfony\Component\Security\Http\Event\TokenDeauthenticatedEvent; +use Symfony\Component\Security\Http\ParameterBagUtils; +use Symfony\Component\Security\Http\RememberMe\RememberMeHandlerInterface; /** - * The RememberMe *listener* creates and deletes remember me cookies. + * The RememberMe *listener* creates and deletes remember-me cookies. * * Upon login success or failure and support for remember me * in the firewall and authenticator, this listener will create - * a remember me cookie. - * Upon login failure, all remember me cookies are removed. + * a remember-me cookie. + * Upon login failure, all remember-me cookies are removed. * * @author Wouter de Jong * @@ -33,12 +36,12 @@ use Symfony\Component\Security\Http\RememberMe\RememberMeServicesInterface; */ class RememberMeListener implements EventSubscriberInterface { - private $rememberMeServices; + private $rememberMeHandler; private $logger; - public function __construct(RememberMeServicesInterface $rememberMeServices, ?LoggerInterface $logger = null) + public function __construct(RememberMeHandlerInterface $rememberMeHandler, ?LoggerInterface $logger = null) { - $this->rememberMeServices = $rememberMeServices; + $this->rememberMeHandler = $rememberMeHandler; $this->logger = $logger; } @@ -53,27 +56,38 @@ class RememberMeListener implements EventSubscriberInterface return; } - if (null === $event->getResponse()) { + // Make sure any old remember-me cookies are cancelled + $this->rememberMeHandler->clearRememberMeCookie(); + + /** @var RememberMeBadge $badge */ + $badge = $passport->getBadge(RememberMeBadge::class); + if (!$badge->isEnabled()) { if (null !== $this->logger) { - $this->logger->debug('Remember me skipped: the authenticator did not set a success response.', ['authenticator' => \get_class($event->getAuthenticator())]); + $this->logger->debug('Remember me skipped: the RememberMeBadge is not enabled.'); } return; } - $this->rememberMeServices->loginSuccess($event->getRequest(), $event->getResponse(), $event->getAuthenticatedToken()); + if (null !== $this->logger) { + $this->logger->debug('Remember-me was requested; setting cookie.'); + } + + $this->rememberMeHandler->createRememberMeCookie($event->getUser()); } - public function onFailedLogin(LoginFailureEvent $event): void + public function clearCookie(): void { - $this->rememberMeServices->loginFail($event->getRequest(), $event->getException()); + $this->rememberMeHandler->clearRememberMeCookie(); } public static function getSubscribedEvents(): array { return [ - LoginSuccessEvent::class => 'onSuccessfulLogin', - LoginFailureEvent::class => 'onFailedLogin', + LoginSuccessEvent::class => ['onSuccessfulLogin', -64], + LoginFailureEvent::class => 'clearCookie', + LogoutEvent::class => 'clearCookie', + TokenDeauthenticatedEvent::class => 'clearCookie', ]; } } diff --git a/src/Symfony/Component/Security/Http/Firewall/ContextListener.php b/src/Symfony/Component/Security/Http/Firewall/ContextListener.php index ec8482a046..9416777b81 100644 --- a/src/Symfony/Component/Security/Http/Firewall/ContextListener.php +++ b/src/Symfony/Component/Security/Http/Firewall/ContextListener.php @@ -31,6 +31,7 @@ use Symfony\Component\Security\Core\Exception\UserNotFoundException; use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserProviderInterface; use Symfony\Component\Security\Http\Event\DeauthenticatedEvent; +use Symfony\Component\Security\Http\Event\TokenDeauthenticatedEvent; use Symfony\Component\Security\Http\RememberMe\RememberMeServicesInterface; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; @@ -94,6 +95,8 @@ class ContextListener extends AbstractListener $request = $event->getRequest(); $session = $request->hasPreviousSession() && $request->hasSession() ? $request->getSession() : null; + $request->attributes->set('_security_firewall_run', true); + if (null !== $session) { $usageIndexValue = $session instanceof Session ? $usageIndexReference = &$session->getUsageIndex() : 0; $usageIndexReference = \PHP_INT_MIN; @@ -128,10 +131,17 @@ class ContextListener extends AbstractListener } if ($token instanceof TokenInterface) { + $originalToken = $token; $token = $this->refreshUser($token); - if (!$token && $this->rememberMeServices) { - $this->rememberMeServices->loginFail($request); + if (!$token) { + if ($this->dispatcher) { + $this->dispatcher->dispatch(new TokenDeauthenticatedEvent($originalToken, $request)); + } + + if ($this->rememberMeServices) { + $this->rememberMeServices->loginFail($request); + } } } elseif (null !== $token) { if (null !== $this->logger) { @@ -159,11 +169,13 @@ class ContextListener extends AbstractListener $request = $event->getRequest(); - if (!$request->hasSession()) { + if (!$request->hasSession() || !$request->attributes->get('_security_firewall_run', false)) { return; } - $this->dispatcher->removeListener(KernelEvents::RESPONSE, [$this, 'onKernelResponse']); + if ($this->dispatcher) { + $this->dispatcher->removeListener(KernelEvents::RESPONSE, [$this, 'onKernelResponse']); + } $this->registered = false; $session = $request->getSession(); $sessionId = $session->getId(); @@ -260,7 +272,7 @@ class ContextListener extends AbstractListener $this->logger->debug('Token was deauthenticated after trying to refresh it.'); } - if (null !== $this->dispatcher) { + if ($this->dispatcher) { $this->dispatcher->dispatch(new DeauthenticatedEvent($token, $newToken), DeauthenticatedEvent::class); } diff --git a/src/Symfony/Component/Security/Http/LoginLink/Exception/ExpiredLoginLinkException.php b/src/Symfony/Component/Security/Http/LoginLink/Exception/ExpiredLoginLinkException.php index adaba2c715..b0f3383292 100644 --- a/src/Symfony/Component/Security/Http/LoginLink/Exception/ExpiredLoginLinkException.php +++ b/src/Symfony/Component/Security/Http/LoginLink/Exception/ExpiredLoginLinkException.php @@ -11,10 +11,12 @@ namespace Symfony\Component\Security\Http\LoginLink\Exception; +use Symfony\Component\Security\Core\Signature\Exception\ExpiredSignatureException; + /** * @author Ryan Weaver * @experimental in 5.3 */ -class ExpiredLoginLinkException extends \Exception implements InvalidLoginLinkExceptionInterface +class ExpiredLoginLinkException extends ExpiredSignatureException implements InvalidLoginLinkExceptionInterface { } diff --git a/src/Symfony/Component/Security/Http/LoginLink/Exception/InvalidLoginLinkException.php b/src/Symfony/Component/Security/Http/LoginLink/Exception/InvalidLoginLinkException.php index 46f91298ff..b1c94aa19e 100644 --- a/src/Symfony/Component/Security/Http/LoginLink/Exception/InvalidLoginLinkException.php +++ b/src/Symfony/Component/Security/Http/LoginLink/Exception/InvalidLoginLinkException.php @@ -15,6 +15,6 @@ namespace Symfony\Component\Security\Http\LoginLink\Exception; * @author Ryan Weaver * @experimental in 5.3 */ -class InvalidLoginLinkException extends \Exception implements InvalidLoginLinkExceptionInterface +class InvalidLoginLinkException extends \RuntimeException implements InvalidLoginLinkExceptionInterface { } diff --git a/src/Symfony/Component/Security/Http/LoginLink/LoginLinkHandler.php b/src/Symfony/Component/Security/Http/LoginLink/LoginLinkHandler.php index ec4cf65f0a..3baa73a137 100644 --- a/src/Symfony/Component/Security/Http/LoginLink/LoginLinkHandler.php +++ b/src/Symfony/Component/Security/Http/LoginLink/LoginLinkHandler.php @@ -12,10 +12,12 @@ namespace Symfony\Component\Security\Http\LoginLink; use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Routing\RequestContext; use Symfony\Component\Security\Core\Exception\UserNotFoundException; +use Symfony\Component\Security\Core\Signature\Exception\ExpiredSignatureException; +use Symfony\Component\Security\Core\Signature\Exception\InvalidSignatureException; +use Symfony\Component\Security\Core\Signature\SignatureHasher; use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserProviderInterface; use Symfony\Component\Security\Http\LoginLink\Exception\ExpiredLoginLinkException; @@ -29,25 +31,18 @@ final class LoginLinkHandler implements LoginLinkHandlerInterface { private $urlGenerator; private $userProvider; - private $propertyAccessor; - private $signatureProperties; - private $secret; private $options; - private $expiredStorage; + private $signatureHashUtil; - public function __construct(UrlGeneratorInterface $urlGenerator, UserProviderInterface $userProvider, PropertyAccessorInterface $propertyAccessor, array $signatureProperties, string $secret, array $options, ?ExpiredLoginLinkStorage $expiredStorage) + public function __construct(UrlGeneratorInterface $urlGenerator, UserProviderInterface $userProvider, SignatureHasher $signatureHashUtil, array $options) { $this->urlGenerator = $urlGenerator; $this->userProvider = $userProvider; - $this->propertyAccessor = $propertyAccessor; - $this->signatureProperties = $signatureProperties; - $this->secret = $secret; + $this->signatureHashUtil = $signatureHashUtil; $this->options = array_merge([ 'route_name' => null, 'lifetime' => 600, - 'max_uses' => null, ], $options); - $this->expiredStorage = $expiredStorage; } public function createLoginLink(UserInterface $user, Request $request = null): LoginLinkDetails @@ -59,7 +54,7 @@ final class LoginLinkHandler implements LoginLinkHandlerInterface // @deprecated since 5.3, change to $user->getUserIdentifier() in 6.0 'user' => method_exists($user, 'getUserIdentifier') ? $user->getUserIdentifier() : $user->getUsername(), 'expires' => $expires, - 'hash' => $this->computeSignatureHash($user, $expires), + 'hash' => $this->signatureHashUtil->computeSignatureHash($user, $expires), ]; if ($request) { @@ -105,43 +100,15 @@ final class LoginLinkHandler implements LoginLinkHandlerInterface $hash = $request->get('hash'); $expires = $request->get('expires'); - if (false === hash_equals($hash, $this->computeSignatureHash($user, $expires))) { - throw new InvalidLoginLinkException('Invalid or expired signature.'); - } - if ($expires < time()) { - throw new ExpiredLoginLinkException('Login link has expired.'); - } - - if ($this->expiredStorage && $this->options['max_uses']) { - $hash = $request->get('hash'); - if ($this->expiredStorage->countUsages($hash) >= $this->options['max_uses']) { - throw new ExpiredLoginLinkException(sprintf('Login link can only be used "%d" times.', $this->options['max_uses'])); - } - - $this->expiredStorage->incrementUsages($hash); + try { + $this->signatureHashUtil->verifySignatureHash($user, $expires, $hash); + } catch (ExpiredSignatureException $e) { + throw new ExpiredLoginLinkException(ucfirst(str_ireplace('signature', 'login link', $e->getMessage())), 0, $e); + } catch (InvalidSignatureException $e) { + throw new InvalidLoginLinkException(ucfirst(str_ireplace('signature', 'login link', $e->getMessage())), 0, $e); } return $user; } - - private function computeSignatureHash(UserInterface $user, int $expires): string - { - // @deprecated since 5.3, change to $user->getUserIdentifier() in 6.0 - $signatureFields = [base64_encode(method_exists($user, 'getUserIdentifier') ? $user->getUserIdentifier() : $user->getUsername()), $expires]; - - foreach ($this->signatureProperties as $property) { - $value = $this->propertyAccessor->getValue($user, $property) ?? ''; - if ($value instanceof \DateTimeInterface) { - $value = $value->format('c'); - } - - if (!is_scalar($value) && !(\is_object($value) && method_exists($value, '__toString'))) { - throw new \InvalidArgumentException(sprintf('The property path "%s" on the user object "%s" must return a value that can be cast to a string, but "%s" was returned.', $property, \get_class($user), get_debug_type($value))); - } - $signatureFields[] = base64_encode($value); - } - - return base64_encode(hash_hmac('sha256', implode(':', $signatureFields), $this->secret)); - } } diff --git a/src/Symfony/Component/Security/Http/RememberMe/AbstractRememberMeHandler.php b/src/Symfony/Component/Security/Http/RememberMe/AbstractRememberMeHandler.php new file mode 100644 index 0000000000..42a5e05528 --- /dev/null +++ b/src/Symfony/Component/Security/Http/RememberMe/AbstractRememberMeHandler.php @@ -0,0 +1,131 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\RememberMe; + +use Psr\Log\LoggerInterface; +use Symfony\Component\HttpFoundation\Cookie; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\Security\Core\Exception\AuthenticationException; +use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\Security\Core\User\UserProviderInterface; + +/** + * @author Wouter de Jong + * + * @experimental in 5.3 + */ +abstract class AbstractRememberMeHandler implements RememberMeHandlerInterface +{ + private $userProvider; + protected $requestStack; + protected $options; + protected $logger; + + public function __construct(UserProviderInterface $userProvider, RequestStack $requestStack, array $options = [], ?LoggerInterface $logger = null) + { + $this->userProvider = $userProvider; + $this->requestStack = $requestStack; + $this->options = $options + [ + 'name' => 'REMEMBERME', + 'lifetime' => 31536000, + 'path' => '/', + 'domain' => null, + 'secure' => false, + 'httponly' => true, + 'samesite' => null, + 'always_remember_me' => false, + 'remember_me_parameter' => '_remember_me', + ]; + $this->logger = $logger; + } + + /** + * Checks if the RememberMeDetails is a valid cookie to login the given User. + * + * This method should also: + * - Create a new remember-me cookie to be sent with the response (using {@see createCookie()}); + * - If you store the token somewhere else (e.g. in a database), invalidate the stored token. + * + * @throws AuthenticationException throw this exception if the remember me details are not accepted + */ + abstract protected function processRememberMe(RememberMeDetails $rememberMeDetails, UserInterface $user): void; + + /** + * {@inheritdoc} + */ + public function consumeRememberMeCookie(RememberMeDetails $rememberMeDetails): UserInterface + { + try { + // @deprecated since 5.3, change to $this->userProvider->loadUserByIdentifier() in 6.0 + $method = 'loadUserByIdentifier'; + if (!method_exists($this->userProvider, 'loadUserByIdentifier')) { + trigger_deprecation('symfony/security-core', '5.3', 'Not implementing method "loadUserByIdentifier()" in user provider "%s" is deprecated. This method will replace "loadUserByUsername()" in Symfony 6.0.', get_debug_type($this->userProvider)); + + $method = 'loadUserByUsername'; + } + + $user = $this->userProvider->$method($rememberMeDetails->getUserIdentifier()); + } catch (AuthenticationException $e) { + throw $e; + } + + if (!$user instanceof UserInterface) { + throw new \LogicException(sprintf('The UserProviderInterface implementation must return an instance of UserInterface, but returned "%s".', get_debug_type($user))); + } + + $this->processRememberMe($rememberMeDetails, $user); + + if (null !== $this->logger) { + $this->logger->info('Remember-me cookie accepted.'); + } + + return $user; + } + + /** + * {@inheritdoc} + */ + public function clearRememberMeCookie(): void + { + if (null !== $this->logger) { + $this->logger->debug('Clearing remember-me cookie.', ['name' => $this->options['name']]); + } + + $this->createCookie(null); + } + + /** + * Creates the remember-me cookie using the correct configuration. + * + * @param RememberMeDetails|null $rememberMeDetails The details for the cookie, or null to clear the remember-me cookie + */ + protected function createCookie(?RememberMeDetails $rememberMeDetails) + { + $request = $this->requestStack->getMainRequest(); + if (!$request) { + throw new \LogicException('Cannot create the remember-me cookie; no master request available.'); + } + + // the ResponseListener configures the cookie saved in this attribute on the final response object + $request->attributes->set(ResponseListener::COOKIE_ATTR_NAME, new Cookie( + $this->options['name'], + $rememberMeDetails ? $rememberMeDetails->toString() : null, + $rememberMeDetails ? $rememberMeDetails->getExpires() : 1, + $this->options['path'], + $this->options['domain'], + $this->options['secure'] ?? $request->isSecure(), + $this->options['httponly'], + false, + $this->options['samesite'] + )); + } +} diff --git a/src/Symfony/Component/Security/Http/RememberMe/PersistentRememberMeHandler.php b/src/Symfony/Component/Security/Http/RememberMe/PersistentRememberMeHandler.php new file mode 100644 index 0000000000..24a03861d9 --- /dev/null +++ b/src/Symfony/Component/Security/Http/RememberMe/PersistentRememberMeHandler.php @@ -0,0 +1,114 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\RememberMe; + +use Psr\Log\LoggerInterface; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\Security\Core\Authentication\RememberMe\PersistentToken; +use Symfony\Component\Security\Core\Authentication\RememberMe\TokenProviderInterface; +use Symfony\Component\Security\Core\Exception\AuthenticationException; +use Symfony\Component\Security\Core\Exception\CookieTheftException; +use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\Security\Core\User\UserProviderInterface; + +/** + * Implements remember-me tokens using a {@see TokenProviderInterface}. + * + * This requires storing remember-me tokens in a database. This allows + * more control over the invalidation of remember-me tokens. See + * {@see SignatureRememberMeHandler} if you don't want to use a database. + * + * @author Wouter de Jong + * + * @experimental in 5.3 + */ +final class PersistentRememberMeHandler extends AbstractRememberMeHandler +{ + private $tokenProvider; + private $secret; + + public function __construct(TokenProviderInterface $tokenProvider, string $secret, UserProviderInterface $userProvider, RequestStack $requestStack, array $options, ?LoggerInterface $logger = null) + { + parent::__construct($userProvider, $requestStack, $options, $logger); + + $this->tokenProvider = $tokenProvider; + $this->secret = $secret; + } + + /** + * {@inheritdoc} + */ + public function createRememberMeCookie(UserInterface $user): void + { + $series = base64_encode(random_bytes(64)); + $tokenValue = $this->generateHash(base64_encode(random_bytes(64))); + $token = new PersistentToken(\get_class($user), method_exists($user, 'getUserIdentifier') ? $user->getUserIdentifier() : $user->getUsername(), $series, $tokenValue, new \DateTime()); + + $this->tokenProvider->createNewToken($token); + $this->createCookie(RememberMeDetails::fromPersistentToken($token, time() + $this->options['lifetime'])); + } + + /** + * {@inheritdoc} + */ + public function processRememberMe(RememberMeDetails $rememberMeDetails, UserInterface $user): void + { + if (!str_contains($rememberMeDetails->getValue(), ':')) { + throw new AuthenticationException('The cookie is incorrectly formatted.'); + } + + [$series, $tokenValue] = explode(':', $rememberMeDetails->getValue()); + $persistentToken = $this->tokenProvider->loadTokenBySeries($series); + if (!hash_equals($persistentToken->getTokenValue(), $tokenValue)) { + throw new CookieTheftException('This token was already used. The account is possibly compromised.'); + } + + if ($persistentToken->getLastUsed()->getTimestamp() + $this->options['lifetime'] < time()) { + throw new AuthenticationException('The cookie has expired.'); + } + + $tokenValue = base64_encode(random_bytes(64)); + $this->tokenProvider->updateToken($series, $this->generateHash($tokenValue), new \DateTime()); + + $this->createCookie($rememberMeDetails->withValue($tokenValue)); + } + + /** + * {@inheritdoc} + */ + public function clearRememberMeCookie(): void + { + parent::clearRememberMeCookie(); + + $cookie = $this->requestStack->getMainRequest()->cookies->get($this->options['name']); + if (null === $cookie) { + return; + } + + $rememberMeDetails = RememberMeDetails::fromRawCookie($cookie); + [$series, ] = explode(':', $rememberMeDetails->getValue()); + $this->tokenProvider->deleteTokenBySeries($series); + } + + /** + * @internal + */ + public function getTokenProvider(): TokenProviderInterface + { + return $this->tokenProvider; + } + + private function generateHash(string $tokenValue): string + { + return hash_hmac('sha256', $tokenValue, $this->secret); + } +} diff --git a/src/Symfony/Component/Security/Http/RememberMe/RememberMeDetails.php b/src/Symfony/Component/Security/Http/RememberMe/RememberMeDetails.php new file mode 100644 index 0000000000..8bf2cdd3f4 --- /dev/null +++ b/src/Symfony/Component/Security/Http/RememberMe/RememberMeDetails.php @@ -0,0 +1,87 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\RememberMe; + +use Symfony\Component\Security\Core\Authentication\RememberMe\PersistentToken; +use Symfony\Component\Security\Core\Exception\AuthenticationException; + +/** + * @author Wouter de Jong + * + * @experimental in 5.3 + */ +class RememberMeDetails +{ + public const COOKIE_DELIMITER = ':'; + + private $userFqcn; + private $userIdentifier; + private $expires; + private $value; + + public function __construct(string $userFqcn, string $userIdentifier, int $expires, string $value) + { + $this->userFqcn = $userFqcn; + $this->userIdentifier = $userIdentifier; + $this->expires = $expires; + $this->value = $value; + } + + public static function fromRawCookie(string $rawCookie): self + { + $cookieParts = explode(self::COOKIE_DELIMITER, base64_decode($rawCookie), 4); + if (false === $cookieParts[1] = base64_decode($cookieParts[1], true)) { + throw new AuthenticationException('The user identifier contains a character from outside the base64 alphabet.'); + } + + return new static(...$cookieParts); + } + + public static function fromPersistentToken(PersistentToken $persistentToken, int $expires): self + { + return new static($persistentToken->getClass(), $persistentToken->getUserIdentifier(), $expires, $persistentToken->getSeries().':'.$persistentToken->getTokenValue()); + } + + public function withValue(string $value): self + { + $details = clone $this; + $details->value = $value; + + return $details; + } + + public function getUserFqcn(): string + { + return $this->userFqcn; + } + + public function getUserIdentifier(): string + { + return $this->userIdentifier; + } + + public function getExpires(): int + { + return $this->expires; + } + + public function getValue(): string + { + return $this->value; + } + + public function toString(): string + { + // $userIdentifier is encoded because it might contain COOKIE_DELIMITER, we assume other values don't + return base64_encode(implode(self::COOKIE_DELIMITER, [$this->userFqcn, base64_encode($this->userIdentifier), $this->expires, $this->value])); + } +} diff --git a/src/Symfony/Component/Security/Http/RememberMe/RememberMeHandlerInterface.php b/src/Symfony/Component/Security/Http/RememberMe/RememberMeHandlerInterface.php new file mode 100644 index 0000000000..9ab2f69df8 --- /dev/null +++ b/src/Symfony/Component/Security/Http/RememberMe/RememberMeHandlerInterface.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\RememberMe; + +use Symfony\Component\Security\Core\Exception\AuthenticationException; +use Symfony\Component\Security\Core\User\UserInterface; + +/** + * Handles creating and validating remember-me cookies. + * + * If you want to add a custom implementation, you want to extend from + * {@see AbstractRememberMeHandler} instead. + * + * @author Wouter de Jong + * + * @experimental in 5.3 + */ +interface RememberMeHandlerInterface +{ + /** + * Creates a remember-me cookie. + * + * The actual cookie should be set as an attribute on the main request, + * which is transformed into a response cookie by {@see ResponseListener}. + */ + public function createRememberMeCookie(UserInterface $user): void; + + /** + * Validates the remember-me cookie and returns the associated User. + * + * Every cookie should only be used once. This means that this method should also: + * - Create a new remember-me cookie to be sent with the response (using the + * {@see ResponseListener::COOKIE_ATTR_NAME} request attribute); + * - If you store the token somewhere else (e.g. in a database), invalidate the + * stored token. + * + * @throws AuthenticationException + */ + public function consumeRememberMeCookie(RememberMeDetails $rememberMeDetails): UserInterface; + + /** + * Clears the remember-me cookie. + * + * This should set a cookie with a `null` value on the request attribute. + */ + public function clearRememberMeCookie(): void; +} diff --git a/src/Symfony/Component/Security/Http/RememberMe/ResponseListener.php b/src/Symfony/Component/Security/Http/RememberMe/ResponseListener.php index ba2a86c2a1..82eab6969f 100644 --- a/src/Symfony/Component/Security/Http/RememberMe/ResponseListener.php +++ b/src/Symfony/Component/Security/Http/RememberMe/ResponseListener.php @@ -24,6 +24,12 @@ use Symfony\Component\HttpKernel\KernelEvents; */ class ResponseListener implements EventSubscriberInterface { + /** + * This attribute name can be used by the implementation if it needs to set + * a cookie on the Request when there is no actual Response, yet. + */ + public const COOKIE_ATTR_NAME = '_security_remember_me_cookie'; + public function onKernelResponse(ResponseEvent $event) { if (!$event->isMainRequest()) { @@ -33,8 +39,8 @@ class ResponseListener implements EventSubscriberInterface $request = $event->getRequest(); $response = $event->getResponse(); - if ($request->attributes->has(RememberMeServicesInterface::COOKIE_ATTR_NAME)) { - $response->headers->setCookie($request->attributes->get(RememberMeServicesInterface::COOKIE_ATTR_NAME)); + if ($request->attributes->has(self::COOKIE_ATTR_NAME)) { + $response->headers->setCookie($request->attributes->get(self::COOKIE_ATTR_NAME)); } } diff --git a/src/Symfony/Component/Security/Http/RememberMe/SignatureRememberMeHandler.php b/src/Symfony/Component/Security/Http/RememberMe/SignatureRememberMeHandler.php new file mode 100644 index 0000000000..79c3814dd6 --- /dev/null +++ b/src/Symfony/Component/Security/Http/RememberMe/SignatureRememberMeHandler.php @@ -0,0 +1,73 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\RememberMe; + +use Psr\Log\LoggerInterface; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\Security\Core\Exception\AuthenticationException; +use Symfony\Component\Security\Core\Signature\Exception\ExpiredSignatureException; +use Symfony\Component\Security\Core\Signature\Exception\InvalidSignatureException; +use Symfony\Component\Security\Core\Signature\SignatureHasher; +use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\Security\Core\User\UserProviderInterface; + +/** + * Implements safe remember-me cookies using the {@see SignatureHasher}. + * + * This handler doesn't require a database for the remember-me tokens. + * However, it cannot invalidate a specific user session, all sessions for + * that user will be invalidated instead. Use {@see PersistentRememberMeHandler} + * if you need this. + * + * @author Wouter de Jong + * + * @experimental in 5.3 + */ +final class SignatureRememberMeHandler extends AbstractRememberMeHandler +{ + private $signatureHasher; + + public function __construct(SignatureHasher $signatureHasher, UserProviderInterface $userProvider, RequestStack $requestStack, array $options, ?LoggerInterface $logger = null) + { + parent::__construct($userProvider, $requestStack, $options, $logger); + + $this->signatureHasher = $signatureHasher; + } + + /** + * {@inheritdoc} + */ + public function createRememberMeCookie(UserInterface $user): void + { + $expires = time() + $this->options['lifetime']; + $value = $this->signatureHasher->computeSignatureHash($user, $expires); + + $details = new RememberMeDetails(\get_class($user), method_exists($user, 'getUserIdentifier') ? $user->getUserIdentifier() : $user->getUsername(), $expires, $value); + $this->createCookie($details); + } + + /** + * {@inheritdoc} + */ + public function processRememberMe(RememberMeDetails $rememberMeDetails, UserInterface $user): void + { + try { + $this->signatureHasher->verifySignatureHash($user, $rememberMeDetails->getExpires(), $rememberMeDetails->getValue()); + } catch (InvalidSignatureException $e) { + throw new AuthenticationException('The cookie\'s hash is invalid.', 0, $e); + } catch (ExpiredSignatureException $e) { + throw new AuthenticationException('The cookie has expired.', 0, $e); + } + + $this->createRememberMeCookie($user); + } +} diff --git a/src/Symfony/Component/Security/Http/Tests/Authenticator/RememberMeAuthenticatorTest.php b/src/Symfony/Component/Security/Http/Tests/Authenticator/RememberMeAuthenticatorTest.php index c8ccdc80b8..27adff550d 100644 --- a/src/Symfony/Component/Security/Http/Tests/Authenticator/RememberMeAuthenticatorTest.php +++ b/src/Symfony/Component/Security/Http/Tests/Authenticator/RememberMeAuthenticatorTest.php @@ -12,74 +12,72 @@ namespace Symfony\Component\Security\Http\Tests\Authenticator; use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Cookie; use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\Security\Core\Authentication\Token\RememberMeToken; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage; -use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; use Symfony\Component\Security\Core\User\InMemoryUser; use Symfony\Component\Security\Http\Authenticator\RememberMeAuthenticator; -use Symfony\Component\Security\Http\RememberMe\RememberMeServicesInterface; +use Symfony\Component\Security\Http\RememberMe\RememberMeDetails; +use Symfony\Component\Security\Http\RememberMe\RememberMeHandlerInterface; +use Symfony\Component\Security\Http\RememberMe\ResponseListener; class RememberMeAuthenticatorTest extends TestCase { - private $rememberMeServices; + private $rememberMeHandler; private $tokenStorage; private $authenticator; - private $request; protected function setUp(): void { - $this->rememberMeServices = $this->createMock(RememberMeServicesInterface::class); - $this->tokenStorage = $this->createMock(TokenStorage::class); - $this->authenticator = new RememberMeAuthenticator($this->rememberMeServices, 's3cr3t', $this->tokenStorage, [ - 'name' => '_remember_me_cookie', - ]); - $this->request = new Request(); + $this->rememberMeHandler = $this->createMock(RememberMeHandlerInterface::class); + $this->tokenStorage = new TokenStorage(); + $this->authenticator = new RememberMeAuthenticator($this->rememberMeHandler, 's3cr3t', $this->tokenStorage, '_remember_me_cookie'); } public function testSupportsTokenStorageWithToken() { - $this->tokenStorage->expects($this->any())->method('getToken')->willReturn(TokenInterface::class); + $this->tokenStorage->setToken(new UsernamePasswordToken('username', 'credentials', 'main')); - $this->assertFalse($this->authenticator->supports($this->request)); + $this->assertFalse($this->authenticator->supports(Request::create('/'))); } /** * @dataProvider provideSupportsData */ - public function testSupports($autoLoginResult, $support) + public function testSupports($request, $support) { - $this->rememberMeServices->expects($this->once())->method('autoLogin')->with($this->request)->willReturn($autoLoginResult); - - $this->assertSame($support, $this->authenticator->supports($this->request)); + $this->assertSame($support, $this->authenticator->supports($request)); } public function provideSupportsData() { - yield [null, false]; - yield [$this->createMock(TokenInterface::class), null]; - } + yield [Request::create('/'), false]; - public function testConsecutiveSupportsCalls() - { - $this->rememberMeServices->expects($this->once())->method('autoLogin')->with($this->request)->willReturn($this->createMock(TokenInterface::class)); + $request = Request::create('/', 'GET', [], ['_remember_me_cookie' => 'rememberme']); + yield [$request, null]; - $this->assertNull($this->authenticator->supports($this->request)); - $this->assertNull($this->authenticator->supports($this->request)); + $request = Request::create('/', 'GET', [], ['_remember_me_cookie' => 'rememberme']); + $request->attributes->set(ResponseListener::COOKIE_ATTR_NAME, new Cookie('_remember_me_cookie', null)); + yield [$request, false]; } public function testAuthenticate() { - $this->request->attributes->set('_remember_me_token', new RememberMeToken($user = new InMemoryUser('wouter', 'test'), 'main', 'secret')); - $passport = $this->authenticator->authenticate($this->request); + $rememberMeDetails = new RememberMeDetails(InMemoryUser::class, 'wouter', 1, 'secret'); + $request = Request::create('/', 'GET', [], ['_remember_me_cookie' => $rememberMeDetails->toString()]); + $passport = $this->authenticator->authenticate($request); - $this->assertSame($user, $passport->getUser()); + $this->rememberMeHandler->expects($this->once())->method('consumeRememberMeCookie')->with($this->callback(function ($arg) use ($rememberMeDetails) { + return $rememberMeDetails == $arg; + })); + $passport->getUser(); // trigger the user loader } public function testAuthenticateWithoutToken() { $this->expectException(\LogicException::class); - $this->authenticator->authenticate($this->request); + $this->authenticator->authenticate(Request::create('/')); } } diff --git a/src/Symfony/Component/Security/Http/Tests/EventListener/CheckRememberMeConditionsListenerTest.php b/src/Symfony/Component/Security/Http/Tests/EventListener/CheckRememberMeConditionsListenerTest.php new file mode 100644 index 0000000000..adc4a51251 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/EventListener/CheckRememberMeConditionsListenerTest.php @@ -0,0 +1,101 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\EventListener; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\User\User; +use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\RememberMeBadge; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; +use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface; +use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; +use Symfony\Component\Security\Http\Event\LoginSuccessEvent; +use Symfony\Component\Security\Http\EventListener\CheckRememberMeConditionsListener; + +class CheckRememberMeConditionsListenerTest extends TestCase +{ + private $listener; + private $request; + private $response; + + protected function setUp(): void + { + $this->listener = new CheckRememberMeConditionsListener(); + $this->request = Request::create('/login'); + $this->request->request->set('_remember_me', true); + $this->response = new Response(); + } + + public function testSuccessfulLoginWithoutSupportingAuthenticator() + { + $passport = $this->createPassport([]); + + $this->listener->onSuccessfulLogin($this->createLoginSuccessfulEvent($passport)); + + $this->assertFalse($passport->hasBadge(RememberMeBadge::class)); + } + + public function testSuccessfulLoginWithoutRequestParameter() + { + $this->request = Request::create('/login'); + $passport = $this->createPassport(); + + $this->listener->onSuccessfulLogin($this->createLoginSuccessfulEvent($passport)); + + $this->assertFalse($passport->getBadge(RememberMeBadge::class)->isEnabled()); + } + + public function testSuccessfulLoginWhenRememberMeAlwaysIsTrue() + { + $passport = $this->createPassport(); + $listener = new CheckRememberMeConditionsListener(['always_remember_me' => true]); + + $this->listener->onSuccessfulLogin($this->createLoginSuccessfulEvent($passport)); + + $this->assertTrue($passport->getBadge(RememberMeBadge::class)->isEnabled()); + } + + /** + * @dataProvider provideRememberMeOptInValues + */ + public function testSuccessfulLoginWithOptInRequestParameter($optInValue) + { + $this->request->request->set('_remember_me', $optInValue); + $passport = $this->createPassport(); + + $this->listener->onSuccessfulLogin($this->createLoginSuccessfulEvent($passport)); + + $this->assertTrue($passport->getBadge(RememberMeBadge::class)->isEnabled()); + } + + public function provideRememberMeOptInValues() + { + yield ['true']; + yield ['1']; + yield ['on']; + yield ['yes']; + yield [true]; + } + + private function createLoginSuccessfulEvent(PassportInterface $passport) + { + return new LoginSuccessEvent($this->createMock(AuthenticatorInterface::class), $passport, $this->createMock(TokenInterface::class), $this->request, $this->response, 'main_firewall'); + } + + private function createPassport(array $badges = null) + { + return new SelfValidatingPassport(new UserBadge('test', function ($username) { return new User($username, null); }), $badges ?? [new RememberMeBadge()]); + } +} diff --git a/src/Symfony/Component/Security/Http/Tests/EventListener/RememberMeListenerTest.php b/src/Symfony/Component/Security/Http/Tests/EventListener/RememberMeListenerTest.php index d0ca59949e..a952dc363f 100644 --- a/src/Symfony/Component/Security/Http/Tests/EventListener/RememberMeListenerTest.php +++ b/src/Symfony/Component/Security/Http/Tests/EventListener/RememberMeListenerTest.php @@ -22,71 +22,66 @@ use Symfony\Component\Security\Http\Authenticator\Passport\Badge\RememberMeBadge use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface; use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; -use Symfony\Component\Security\Http\Event\LoginFailureEvent; use Symfony\Component\Security\Http\Event\LoginSuccessEvent; use Symfony\Component\Security\Http\EventListener\RememberMeListener; -use Symfony\Component\Security\Http\RememberMe\RememberMeServicesInterface; +use Symfony\Component\Security\Http\RememberMe\RememberMeHandlerInterface; class RememberMeListenerTest extends TestCase { - private $rememberMeServices; + private $rememberMeHandler; private $listener; private $request; private $response; - private $token; protected function setUp(): void { - $this->rememberMeServices = $this->createMock(RememberMeServicesInterface::class); - $this->listener = new RememberMeListener($this->rememberMeServices); - $this->request = $this->createMock(Request::class); - $this->response = $this->createMock(Response::class); - $this->token = $this->createMock(TokenInterface::class); + $this->rememberMeHandler = $this->createMock(RememberMeHandlerInterface::class); + $this->listener = new RememberMeListener($this->rememberMeHandler); + $this->request = Request::create('/login'); + $this->request->request->set('_remember_me', true); + $this->response = new Response(); } public function testSuccessfulLoginWithoutSupportingAuthenticator() { - $this->rememberMeServices->expects($this->never())->method('loginSuccess'); + $this->rememberMeHandler->expects($this->never())->method('createRememberMeCookie'); - $event = $this->createLoginSuccessfulEvent('main_firewall', $this->response, new SelfValidatingPassport(new UserBadge('wouter', function ($username) { return new InMemoryUser($username, null); }))); + $event = $this->createLoginSuccessfulEvent($this->createPassport([])); $this->listener->onSuccessfulLogin($event); } - public function testSuccessfulLoginWithoutSuccessResponse() + public function testSuccessfulLoginWithRememberMeDisabled() { - $this->rememberMeServices->expects($this->never())->method('loginSuccess'); + $this->rememberMeHandler->expects($this->never())->method('createRememberMeCookie'); - $event = $this->createLoginSuccessfulEvent('main_firewall', null); - $this->listener->onSuccessfulLogin($event); - } - - public function testSuccessfulLogin() - { - $this->rememberMeServices->expects($this->once())->method('loginSuccess')->with($this->request, $this->response, $this->token); - - $event = $this->createLoginSuccessfulEvent('main_firewall', $this->response); + $event = $this->createLoginSuccessfulEvent($this->createPassport([new RememberMeBadge()])); $this->listener->onSuccessfulLogin($event); } public function testCredentialsInvalid() { - $this->rememberMeServices->expects($this->once())->method('loginFail')->with($this->request, $this->isInstanceOf(AuthenticationException::class)); + $this->rememberMeHandler->expects($this->once())->method('clearRememberMeCookie'); - $event = $this->createLoginFailureEvent('main_firewall'); - $this->listener->onFailedLogin($event); + $this->listener->clearCookie(); } - private function createLoginSuccessfulEvent($firewallName, $response, PassportInterface $passport = null) + private function createLoginSuccessfulEvent(PassportInterface $passport = null) { if (null === $passport) { - $passport = new SelfValidatingPassport(new UserBadge('test', function ($username) { return new InMemoryUser($username, null); }), [new RememberMeBadge()]); + $passport = $this->createPassport(); } - return new LoginSuccessEvent($this->createMock(AuthenticatorInterface::class), $passport, $this->token, $this->request, $response, $firewallName); + return new LoginSuccessEvent($this->createMock(AuthenticatorInterface::class), $passport, $this->createMock(TokenInterface::class), $this->request, $this->response, 'main_firewall'); } - private function createLoginFailureEvent($firewallName) + private function createPassport(array $badges = null) { - return new LoginFailureEvent(new AuthenticationException(), $this->createMock(AuthenticatorInterface::class), $this->request, null, $firewallName, null); + if (null === $badges) { + $badge = new RememberMeBadge(); + $badge->enable(); + $badges = [$badge]; + } + + return new SelfValidatingPassport(new UserBadge('test', function ($username) { return new InMemoryUser($username, null); }), $badges); } } diff --git a/src/Symfony/Component/Security/Http/Tests/Firewall/ContextListenerTest.php b/src/Symfony/Component/Security/Http/Tests/Firewall/ContextListenerTest.php index 4f79cd956d..f995d215ca 100644 --- a/src/Symfony/Component/Security/Http/Tests/Firewall/ContextListenerTest.php +++ b/src/Symfony/Component/Security/Http/Tests/Firewall/ContextListenerTest.php @@ -106,6 +106,7 @@ class ContextListenerTest extends TestCase $tokenStorage = new TokenStorage(); $tokenStorage->setToken(new UsernamePasswordToken('test1', 'pass1', 'phpunit')); $request = new Request(); + $request->attributes->set('_security_firewall_run', true); $session = new Session(new MockArraySessionStorage()); $request->setSession($session); @@ -148,22 +149,18 @@ class ContextListenerTest extends TestCase { $tokenStorage = $this->createMock(TokenStorageInterface::class); $event = $this->createMock(RequestEvent::class); - $request = $this->createMock(Request::class); $session = $this->createMock(SessionInterface::class); - - $event->expects($this->any()) - ->method('getRequest') - ->willReturn($request); - $request->expects($this->any()) - ->method('hasPreviousSession') - ->willReturn(true); - $request->expects($this->any()) - ->method('getSession') - ->willReturn($session); + $session->expects($this->any())->method('getName')->willReturn('SESSIONNAME'); $session->expects($this->any()) ->method('get') ->with('_security_key123') ->willReturn($token); + $request = new Request([], [], [], ['SESSIONNAME' => true]); + $request->setSession($session); + + $event->expects($this->any()) + ->method('getRequest') + ->willReturn($request); $tokenStorage->expects($this->once()) ->method('setToken') ->with(null); @@ -196,7 +193,7 @@ class ContextListenerTest extends TestCase ->willReturn(true); $event->expects($this->any()) ->method('getRequest') - ->willReturn($this->createMock(Request::class)); + ->willReturn(new Request()); $dispatcher->expects($this->once()) ->method('addListener') @@ -208,18 +205,15 @@ class ContextListenerTest extends TestCase public function testOnKernelResponseListenerRemovesItself() { $session = $this->createMock(SessionInterface::class); + $session->expects($this->any())->method('getName')->willReturn('SESSIONNAME'); $tokenStorage = $this->createMock(TokenStorageInterface::class); $dispatcher = $this->createMock(EventDispatcherInterface::class); $listener = new ContextListener($tokenStorage, [], 'key123', null, $dispatcher); - $request = $this->createMock(Request::class); - $request->expects($this->any()) - ->method('hasSession') - ->willReturn(true); - $request->expects($this->any()) - ->method('getSession') - ->willReturn($session); + $request = new Request(); + $request->attributes->set('_security_firewall_run', true); + $request->setSession($session); $event = new ResponseEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST, new Response()); @@ -232,8 +226,7 @@ class ContextListenerTest extends TestCase public function testHandleRemovesTokenIfNoPreviousSessionWasFound() { - $request = $this->createMock(Request::class); - $request->expects($this->any())->method('hasPreviousSession')->willReturn(false); + $request = new Request(); $event = $this->createMock(RequestEvent::class); $event->expects($this->any())->method('getRequest')->willReturn($request); @@ -377,6 +370,7 @@ class ContextListenerTest extends TestCase { $session = new Session(new MockArraySessionStorage()); $request = new Request(); + $request->attributes->set('_security_firewall_run', true); $request->setSession($session); $requestStack = new RequestStack(); $requestStack->push($request); diff --git a/src/Symfony/Component/Security/Http/Tests/LoginLink/LoginLinkHandlerTest.php b/src/Symfony/Component/Security/Http/Tests/LoginLink/LoginLinkHandlerTest.php index 1392a5214f..0e07a0805a 100644 --- a/src/Symfony/Component/Security/Http/Tests/LoginLink/LoginLinkHandlerTest.php +++ b/src/Symfony/Component/Security/Http/Tests/LoginLink/LoginLinkHandlerTest.php @@ -13,17 +13,20 @@ namespace Symfony\Component\Security\Http\Tests\LoginLink; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Psr\Cache\CacheItemPoolInterface; +use Symfony\Component\Cache\Adapter\ArrayAdapter; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Routing\RequestContext; use Symfony\Component\Security\Core\Exception\UserNotFoundException; +use Symfony\Component\Security\Core\Signature\ExpiredSignatureStorage; +use Symfony\Component\Security\Core\Signature\SignatureHasher; use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserProviderInterface; use Symfony\Component\Security\Http\LoginLink\Exception\ExpiredLoginLinkException; use Symfony\Component\Security\Http\LoginLink\Exception\InvalidLoginLinkException; -use Symfony\Component\Security\Http\LoginLink\ExpiredLoginLinkStorage; use Symfony\Component\Security\Http\LoginLink\LoginLinkHandler; class LoginLinkHandlerTest extends TestCase @@ -34,15 +37,18 @@ class LoginLinkHandlerTest extends TestCase private $userProvider; /** @var PropertyAccessorInterface */ private $propertyAccessor; - /** @var MockObject|ExpiredLoginLinkStorage */ + /** @var MockObject|ExpiredSignatureStorage */ private $expiredLinkStorage; + /** @var CacheItemPoolInterface */ + private $expiredLinkCache; protected function setUp(): void { $this->router = $this->createMock(UrlGeneratorInterface::class); $this->userProvider = new TestLoginLinkHandlerUserProvider(); $this->propertyAccessor = PropertyAccess::createPropertyAccessor(); - $this->expiredLinkStorage = $this->createMock(ExpiredLoginLinkStorage::class); + $this->expiredLinkCache = new ArrayAdapter(); + $this->expiredLinkStorage = new ExpiredSignatureStorage($this->expiredLinkCache, 360); } /** @@ -118,13 +124,12 @@ class LoginLinkHandlerTest extends TestCase $user = new TestLoginLinkHandlerUser('weaverryan', 'ryan@symfonycasts.com', 'pwhash'); $this->userProvider->createUser($user); - $this->expiredLinkStorage->expects($this->once()) - ->method('incrementUsages') - ->with($signature); - $linker = $this->createLinker(['max_uses' => 3]); $actualUser = $linker->consumeLoginLink($request); $this->assertEquals($user, $actualUser); + + $item = $this->expiredLinkCache->getItem(rawurlencode($signature)); + $this->assertSame(1, $item->get()); } public function testConsumeLoginLinkWithExpired() @@ -172,10 +177,9 @@ class LoginLinkHandlerTest extends TestCase $user = new TestLoginLinkHandlerUser('weaverryan', 'ryan@symfonycasts.com', 'pwhash'); $this->userProvider->createUser($user); - $this->expiredLinkStorage->expects($this->once()) - ->method('countUsages') - ->with($signature) - ->willReturn(3); + $item = $this->expiredLinkCache->getItem(rawurlencode($signature)); + $item->set(3); + $this->expiredLinkCache->save($item); $linker = $this->createLinker(['max_uses' => 3]); $linker->consumeLoginLink($request); @@ -199,7 +203,7 @@ class LoginLinkHandlerTest extends TestCase 'route_name' => 'app_check_login_link_route', ], $options); - return new LoginLinkHandler($this->router, $this->userProvider, $this->propertyAccessor, $extraProperties, 's3cret', $options, $this->expiredLinkStorage); + return new LoginLinkHandler($this->router, $this->userProvider, new SignatureHasher($this->propertyAccessor, $extraProperties, 's3cret', $this->expiredLinkStorage, $options['max_uses'] ?? null), $options); } } @@ -209,7 +213,7 @@ class TestLoginLinkHandlerUserProvider implements UserProviderInterface public function createUser(TestLoginLinkHandlerUser $user): void { - $this->users[$user->getUsername()] = $user; + $this->users[$user->getUserIdentifier()] = $user; } public function loadUserByIdentifier(string $userIdentifier): TestLoginLinkHandlerUser diff --git a/src/Symfony/Component/Security/Http/Tests/RememberMe/PersistentRememberMeHandlerTest.php b/src/Symfony/Component/Security/Http/Tests/RememberMe/PersistentRememberMeHandlerTest.php new file mode 100644 index 0000000000..3eb3959827 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/RememberMe/PersistentRememberMeHandlerTest.php @@ -0,0 +1,127 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\RememberMe; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Cookie; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\Security\Core\Authentication\RememberMe\PersistentToken; +use Symfony\Component\Security\Core\Authentication\RememberMe\TokenProviderInterface; +use Symfony\Component\Security\Core\Exception\AuthenticationException; +use Symfony\Component\Security\Core\Exception\CookieTheftException; +use Symfony\Component\Security\Core\User\InMemoryUserProvider; +use Symfony\Component\Security\Core\User\InMemoryUser; +use Symfony\Component\Security\Http\RememberMe\PersistentRememberMeHandler; +use Symfony\Component\Security\Http\RememberMe\RememberMeDetails; +use Symfony\Component\Security\Http\RememberMe\ResponseListener; + +class PersistentRememberMeHandlerTest extends TestCase +{ + private $tokenProvider; + private $userProvider; + private $requestStack; + private $request; + private $handler; + + protected function setUp(): void + { + $this->tokenProvider = $this->createMock(TokenProviderInterface::class); + $this->userProvider = new InMemoryUserProvider(); + $this->userProvider->createUser(new InMemoryUser('wouter', null)); + $this->requestStack = new RequestStack(); + $this->request = Request::create('/login'); + $this->requestStack->push($this->request); + $this->handler = new PersistentRememberMeHandler($this->tokenProvider, 'secret', $this->userProvider, $this->requestStack, []); + } + + public function testCreateRememberMeCookie() + { + $this->tokenProvider->expects($this->once()) + ->method('createNewToken') + ->with($this->callback(function ($persistentToken) { + return $persistentToken instanceof PersistentToken + && $persistentToken->getUserIdentifier() === 'wouter' + && $persistentToken->getClass() === InMemoryUser::class; + })); + + $this->handler->createRememberMeCookie(new InMemoryUser('wouter', null)); + } + + public function testClearRememberMeCookie() + { + $this->tokenProvider->expects($this->once()) + ->method('deleteTokenBySeries') + ->with('series1'); + + $this->request->cookies->set('REMEMBERME', (new RememberMeDetails(InMemoryUser::class, 'wouter', 0, 'series1:tokenvalue'))->toString()); + + $this->handler->clearRememberMeCookie(); + + $this->assertTrue($this->request->attributes->has(ResponseListener::COOKIE_ATTR_NAME)); + + /** @var Cookie $cookie */ + $cookie = $this->request->attributes->get(ResponseListener::COOKIE_ATTR_NAME); + $this->assertEquals(null, $cookie->getValue()); + } + + public function testConsumeRememberMeCookieValid() + { + $this->tokenProvider->expects($this->any()) + ->method('loadTokenBySeries') + ->with('series1') + ->willReturn(new PersistentToken(InMemoryUser::class, 'wouter', 'series1', 'tokenvalue', new \DateTime('-10 min'))) + ; + + $this->tokenProvider->expects($this->once())->method('updateToken')->with('series1'); + + $rememberMeDetails = new RememberMeDetails(InMemoryUser::class, 'wouter', 360, 'series1:tokenvalue'); + $this->handler->consumeRememberMeCookie($rememberMeDetails); + + // assert that the cookie has been updated with a new base64 encoded token value + $this->assertTrue($this->request->attributes->has(ResponseListener::COOKIE_ATTR_NAME)); + + /** @var Cookie $cookie */ + $cookie = $this->request->attributes->get(ResponseListener::COOKIE_ATTR_NAME); + $this->assertNotEquals($rememberMeDetails->toString(), $cookie->getValue()); + $this->assertMatchesRegularExpression('{'.str_replace('\\', '\\\\', base64_decode($rememberMeDetails->withValue('[a-zA-Z0-9/+]+')->toString())).'}', base64_decode($cookie->getValue())); + } + + public function testConsumeRememberMeCookieInvalidToken() + { + $this->expectException(CookieTheftException::class); + + $this->tokenProvider->expects($this->any()) + ->method('loadTokenBySeries') + ->with('series1') + ->willReturn(new PersistentToken(InMemoryUser::class, 'wouter', 'series1', 'tokenvalue1', new \DateTime('-10 min'))); + + $this->tokenProvider->expects($this->never())->method('updateToken')->with('series1'); + + $this->handler->consumeRememberMeCookie(new RememberMeDetails(InMemoryUser::class, 'wouter', 360, 'series1:tokenvalue')); + } + + public function testConsumeRememberMeCookieExpired() + { + $this->expectException(AuthenticationException::class); + $this->expectExceptionMessage('The cookie has expired.'); + + $this->tokenProvider->expects($this->any()) + ->method('loadTokenBySeries') + ->with('series1') + ->willReturn(new PersistentToken(InMemoryUser::class, 'wouter', 'series1', 'tokenvalue', new \DateTime('-'.(31536000 - 1).' years'))); + + $this->tokenProvider->expects($this->never())->method('updateToken')->with('series1'); + + $this->handler->consumeRememberMeCookie(new RememberMeDetails(InMemoryUser::class, 'wouter', 360, 'series1:tokenvalue')); + } +} diff --git a/src/Symfony/Component/Security/Http/Tests/RememberMe/SignatureRememberMeHandlerTest.php b/src/Symfony/Component/Security/Http/Tests/RememberMe/SignatureRememberMeHandlerTest.php new file mode 100644 index 0000000000..ee65843c74 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/RememberMe/SignatureRememberMeHandlerTest.php @@ -0,0 +1,125 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\RememberMe; + +use PHPUnit\Framework\TestCase; +use Symfony\Bridge\PhpUnit\ClockMock; +use Symfony\Component\HttpFoundation\Cookie; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\Security\Core\Exception\AuthenticationException; +use Symfony\Component\Security\Core\Signature\Exception\ExpiredSignatureException; +use Symfony\Component\Security\Core\Signature\Exception\InvalidSignatureException; +use Symfony\Component\Security\Core\Signature\SignatureHasher; +use Symfony\Component\Security\Core\User\InMemoryUserProvider; +use Symfony\Component\Security\Core\User\InMemoryUser; +use Symfony\Component\Security\Http\RememberMe\RememberMeDetails; +use Symfony\Component\Security\Http\RememberMe\ResponseListener; +use Symfony\Component\Security\Http\RememberMe\SignatureRememberMeHandler; + +class SignatureRememberMeHandlerTest extends TestCase +{ + private $signatureHasher; + private $userProvider; + private $request; + private $requestStack; + private $handler; + + protected function setUp(): void + { + $this->signatureHasher = $this->createMock(SignatureHasher::class); + $this->userProvider = new InMemoryUserProvider(); + $user = new InMemoryUser('wouter', null); + $this->userProvider->createUser($user); + $this->requestStack = new RequestStack(); + $this->request = Request::create('/login'); + $this->requestStack->push($this->request); + $this->handler = new SignatureRememberMeHandler($this->signatureHasher, $this->userProvider, $this->requestStack, []); + } + + /** + * @group time-sensitive + */ + public function testCreateRememberMeCookie() + { + ClockMock::register(SignatureRememberMeHandler::class); + + $user = new InMemoryUser('wouter', null); + $this->signatureHasher->expects($this->once())->method('computeSignatureHash')->with($user, $expire = time() + 31536000)->willReturn('abc'); + + $this->handler->createRememberMeCookie($user); + + $this->assertTrue($this->request->attributes->has(ResponseListener::COOKIE_ATTR_NAME)); + + /** @var Cookie $cookie */ + $cookie = $this->request->attributes->get(ResponseListener::COOKIE_ATTR_NAME); + $this->assertEquals(base64_encode(InMemoryUser::class.':d291dGVy:'.$expire.':abc'), $cookie->getValue()); + } + + public function testClearRememberMeCookie() + { + $this->handler->clearRememberMeCookie(); + + $this->assertTrue($this->request->attributes->has(ResponseListener::COOKIE_ATTR_NAME)); + + /** @var Cookie $cookie */ + $cookie = $this->request->attributes->get(ResponseListener::COOKIE_ATTR_NAME); + $this->assertEquals(null, $cookie->getValue()); + } + + /** + * @group time-sensitive + */ + public function testConsumeRememberMeCookieValid() + { + $this->signatureHasher->expects($this->once())->method('verifySignatureHash')->with($user = new InMemoryUser('wouter', null), 360, 'signature'); + $this->signatureHasher->expects($this->any()) + ->method('computeSignatureHash') + ->with($user, $expire = time() + 31536000) + ->willReturn('newsignature'); + + $rememberMeDetails = new RememberMeDetails(InMemoryUser::class, 'wouter', 360, 'signature'); + $this->handler->consumeRememberMeCookie($rememberMeDetails); + + $this->assertTrue($this->request->attributes->has(ResponseListener::COOKIE_ATTR_NAME)); + + /** @var Cookie $cookie */ + $cookie = $this->request->attributes->get(ResponseListener::COOKIE_ATTR_NAME); + $this->assertEquals((new RememberMeDetails(InMemoryUser::class, 'wouter', $expire, 'newsignature'))->toString(), $cookie->getValue()); + } + + public function testConsumeRememberMeCookieInvalidHash() + { + $this->expectException(AuthenticationException::class); + $this->expectExceptionMessage('The cookie\'s hash is invalid.'); + + $this->signatureHasher->expects($this->any()) + ->method('verifySignatureHash') + ->with(new InMemoryUser('wouter', null), 360, 'badsignature') + ->will($this->throwException(new InvalidSignatureException())); + + $this->handler->consumeRememberMeCookie(new RememberMeDetails(InMemoryUser::class, 'wouter', 360, 'badsignature')); + } + + public function testConsumeRememberMeCookieExpired() + { + $this->expectException(AuthenticationException::class); + $this->expectExceptionMessage('The cookie has expired.'); + + $this->signatureHasher->expects($this->any()) + ->method('verifySignatureHash') + ->with(new InMemoryUser('wouter', null), 360, 'signature') + ->will($this->throwException(new ExpiredSignatureException())); + + $this->handler->consumeRememberMeCookie(new RememberMeDetails(InMemoryUser::class, 'wouter', 360, 'signature')); + } +}