From 15670419d45b9f59f134715ad219d27f16d677a8 Mon Sep 17 00:00:00 2001 From: Wouter de Jong Date: Sun, 17 Jan 2021 20:20:33 +0100 Subject: [PATCH] [Security] Rework the remember me system --- ...eTokenProviderDoctrineSchemaSubscriber.php | 62 +++++++++ .../RememberMe/DoctrineTokenProvider.php | 41 ++++-- .../Compiler/UnusedTagsPass.php | 1 + ...gisterGlobalSecurityEventListenersPass.php | 2 + .../ReplaceDecoratedRememberMeHandlerPass.php | 61 ++++++++ .../Security/Factory/LoginLinkFactory.php | 14 +- .../Security/Factory/RememberMeFactory.php | 125 +++++++++++++---- .../DependencyInjection/SecurityExtension.php | 9 +- .../FirewallAwareLoginLinkHandler.php | 32 +---- .../RememberMe/DecoratedRememberMeHandler.php | 57 ++++++++ .../FirewallAwareRememberMeHandler.php | 56 ++++++++ .../Resources/config/schema/security-1.0.xsd | 21 ++- .../config/security_authenticator.php | 20 --- .../security_authenticator_login_link.php | 15 +- .../security_authenticator_remember_me.php | 91 ++++++++++++ .../Security/FirewallAwareTrait.php | 52 +++++++ .../Security/UserAuthenticator.php | 20 +-- .../Bundle/SecurityBundle/SecurityBundle.php | 2 + .../CompleteConfigurationTest.php | 10 +- .../SecurityExtensionTest.php | 27 +++- .../Controller/ProfileController.php | 23 +++ .../RememberMeBundle/RememberMeBundle.php} | 12 +- .../Security/StaticTokenProvider.php | 66 +++++++++ .../Security/UserChangingUserProvider.php | 52 +++++++ .../Tests/Functional/ClearRememberMeTest.php | 91 ------------ .../Tests/Functional/LogoutTest.php | 23 --- .../Tests/Functional/RememberMeTest.php | 95 +++++++++++++ .../Tests/Functional/app/AppKernel.php | 15 +- .../Functional/app/ClearRememberMe/config.yml | 30 ---- .../app/ClearRememberMe/legacy_config.yml | 7 - .../app/ClearRememberMe/routing.yml | 7 - .../bundles.php | 4 +- .../app/RememberMe/clear_on_change_config.yml | 9 ++ .../config.yml | 16 +-- .../app/RememberMe/config_persistent.yml | 12 ++ .../app/RememberMe/config_session.yml | 6 + .../Functional/app/RememberMe/routing.yml | 9 ++ .../app/RememberMe/stateless_config.yml | 13 ++ .../app/RememberMeLogout/routing.yml | 5 - src/Symfony/Component/Security/CHANGELOG.md | 5 + .../Exception/ExpiredSignatureException.php | 21 +++ .../Exception/InvalidSignatureException.php | 21 +++ .../Signature/ExpiredSignatureStorage.php} | 6 +- .../Core/Signature/SignatureHasher.php | 99 +++++++++++++ .../ExpiredSignatureStorageTest.php} | 8 +- .../Passport/Badge/RememberMeBadge.php | 45 ++++-- .../Authenticator/RememberMeAuthenticator.php | 59 +++++--- .../Http/Event/DeauthenticatedEvent.php | 6 +- .../Http/Event/TokenDeauthenticatedEvent.php | 51 +++++++ .../CheckRememberMeConditionsListener.php | 79 +++++++++++ .../Http/EventListener/RememberMeListener.php | 42 ++++-- .../Http/Firewall/ContextListener.php | 22 ++- .../Exception/ExpiredLoginLinkException.php | 4 +- .../Exception/InvalidLoginLinkException.php | 2 +- .../Http/LoginLink/LoginLinkHandler.php | 59 ++------ .../RememberMe/AbstractRememberMeHandler.php | 131 ++++++++++++++++++ .../PersistentRememberMeHandler.php | 114 +++++++++++++++ .../Http/RememberMe/RememberMeDetails.php | 87 ++++++++++++ .../RememberMe/RememberMeHandlerInterface.php | 56 ++++++++ .../Http/RememberMe/ResponseListener.php | 10 +- .../RememberMe/SignatureRememberMeHandler.php | 73 ++++++++++ .../RememberMeAuthenticatorTest.php | 58 ++++---- .../CheckRememberMeConditionsListenerTest.php | 101 ++++++++++++++ .../EventListener/RememberMeListenerTest.php | 55 ++++---- .../Tests/Firewall/ContextListenerTest.php | 36 ++--- .../Tests/LoginLink/LoginLinkHandlerTest.php | 30 ++-- .../PersistentRememberMeHandlerTest.php | 127 +++++++++++++++++ .../SignatureRememberMeHandlerTest.php | 125 +++++++++++++++++ 68 files changed, 2240 insertions(+), 505 deletions(-) create mode 100644 src/Symfony/Bridge/Doctrine/SchemaListener/RememberMeTokenProviderDoctrineSchemaSubscriber.php create mode 100644 src/Symfony/Bundle/SecurityBundle/DependencyInjection/Compiler/ReplaceDecoratedRememberMeHandlerPass.php create mode 100644 src/Symfony/Bundle/SecurityBundle/RememberMe/DecoratedRememberMeHandler.php create mode 100644 src/Symfony/Bundle/SecurityBundle/RememberMe/FirewallAwareRememberMeHandler.php create mode 100644 src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator_remember_me.php create mode 100644 src/Symfony/Bundle/SecurityBundle/Security/FirewallAwareTrait.php create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/RememberMeBundle/Controller/ProfileController.php rename src/Symfony/Bundle/SecurityBundle/Tests/Functional/{app/ClearRememberMe/bundles.php => Bundle/RememberMeBundle/RememberMeBundle.php} (58%) create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/RememberMeBundle/Security/StaticTokenProvider.php create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/RememberMeBundle/Security/UserChangingUserProvider.php delete mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Functional/ClearRememberMeTest.php create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Functional/RememberMeTest.php delete mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/ClearRememberMe/config.yml delete mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/ClearRememberMe/legacy_config.yml delete mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/ClearRememberMe/routing.yml rename src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/{RememberMeLogout => RememberMe}/bundles.php (77%) create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/RememberMe/clear_on_change_config.yml rename src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/{RememberMeLogout => RememberMe}/config.yml (59%) create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/RememberMe/config_persistent.yml create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/RememberMe/config_session.yml create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/RememberMe/routing.yml create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/RememberMe/stateless_config.yml delete mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/RememberMeLogout/routing.yml create mode 100644 src/Symfony/Component/Security/Core/Signature/Exception/ExpiredSignatureException.php create mode 100644 src/Symfony/Component/Security/Core/Signature/Exception/InvalidSignatureException.php rename src/Symfony/Component/Security/{Http/LoginLink/ExpiredLoginLinkStorage.php => Core/Signature/ExpiredSignatureStorage.php} (88%) create mode 100644 src/Symfony/Component/Security/Core/Signature/SignatureHasher.php rename src/Symfony/Component/Security/{Http/Tests/LoginLink/ExpiredLoginLinkStorageTest.php => Core/Tests/Signature/ExpiredSignatureStorageTest.php} (71%) create mode 100644 src/Symfony/Component/Security/Http/Event/TokenDeauthenticatedEvent.php create mode 100644 src/Symfony/Component/Security/Http/EventListener/CheckRememberMeConditionsListener.php create mode 100644 src/Symfony/Component/Security/Http/RememberMe/AbstractRememberMeHandler.php create mode 100644 src/Symfony/Component/Security/Http/RememberMe/PersistentRememberMeHandler.php create mode 100644 src/Symfony/Component/Security/Http/RememberMe/RememberMeDetails.php create mode 100644 src/Symfony/Component/Security/Http/RememberMe/RememberMeHandlerInterface.php create mode 100644 src/Symfony/Component/Security/Http/RememberMe/SignatureRememberMeHandler.php create mode 100644 src/Symfony/Component/Security/Http/Tests/EventListener/CheckRememberMeConditionsListenerTest.php create mode 100644 src/Symfony/Component/Security/Http/Tests/RememberMe/PersistentRememberMeHandlerTest.php create mode 100644 src/Symfony/Component/Security/Http/Tests/RememberMe/SignatureRememberMeHandlerTest.php diff --git a/src/Symfony/Bridge/Doctrine/SchemaListener/RememberMeTokenProviderDoctrineSchemaSubscriber.php b/src/Symfony/Bridge/Doctrine/SchemaListener/RememberMeTokenProviderDoctrineSchemaSubscriber.php new file mode 100644 index 0000000000000..60a849789ef17 --- /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 d2ac616db7439..4712065e35237 100644 --- a/src/Symfony/Bridge/Doctrine/Security/RememberMe/DoctrineTokenProvider.php +++ b/src/Symfony/Bridge/Doctrine/Security/RememberMe/DoctrineTokenProvider.php @@ -14,6 +14,7 @@ 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\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 @@ public function __construct(Connection $conn) 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 @@ public function deleteTokenBySeries(string $series) */ 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 @@ public function updateToken(string $series, string $tokenValue, \DateTime $lastU */ 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 @@ public function createNewToken(PersistentTokenInterface $token) $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 ded8b90028609..3590d0074ca12 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 9ffbba4ac9af8..20094957cdb65 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\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 0000000000000..5de431c2c04c8 --- /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 7680bffad63ee..05c2f28f97bb7 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 @@ public function createAuthenticator(ContainerBuilder $container, string $firewal ->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 27ec6ff9e0ac6..809f189350f16 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 @@ public function create(ContainerBuilder $container, string $id, array $config, ? 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 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 remember me services (which manage the remember me cookies) - $this->createRememberMeServices($container, $firewallName, $templateId, [new Reference($userProviderId)], $config); + // 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 @@ public function addConfiguration(NodeDefinition $node) $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 @@ public function addConfiguration(NodeDefinition $node) ->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 @@ private function createRememberMeServices(ContainerBuilder $container, string $i $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 @@ private function createRememberMeServices(ContainerBuilder $container, string $i $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 2f5c674fb873e..8da513de141a4 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php @@ -34,6 +34,7 @@ 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 @@ private function createFirewall(ContainerBuilder $container, string $id, array $ // 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 @@ private function createFirewall(ContainerBuilder $container, string $id, array $ 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 @@ private function createContextListener(ContainerBuilder $container, string $cont $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 04b633ff720f8..5c61cfcfabad4 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 @@ */ 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 0000000000000..a060fb5116ffb --- /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 0000000000000..14252662b8400 --- /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 3de6b98b384e0..d960f02351457 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 57c2afeadadd3..ebc9a5fa64f95 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\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 @@ 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 @@ ]) ->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 2248b5e8eeb7d..b3782e471f993 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 @@ ->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 0000000000000..67813c28d1843 --- /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 0000000000000..70d9178f8ab19 --- /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 76f46e80eca51..4ca7e15ddb0a4 100644 --- a/src/Symfony/Bundle/SecurityBundle/Security/UserAuthenticator.php +++ b/src/Symfony/Bundle/SecurityBundle/Security/UserAuthenticator.php @@ -15,11 +15,9 @@ 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 @@ */ 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 @@ public function __construct(FirewallMap $firewallMap, ContainerInterface $userAu */ 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 2b20e3d90dcd8..05a0c5c7a7e2d 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\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 @@ public function build(ContainerBuilder $container) $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 be3e8d5e4307d..317da3930be5f 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/CompleteConfigurationTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/CompleteConfigurationTest.php @@ -51,13 +51,15 @@ public function testAuthenticatorManager() $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 8b1a150262d78..59b65e0db7dd7 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/SecurityExtensionTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/SecurityExtensionTest.php @@ -388,6 +388,27 @@ public function testRememberMeCookieInheritFrameworkSessionCookie($config, $same $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 @@ protected function getRawContainer() $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 0000000000000..7f99d17c90123 --- /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 9a26fb163a77d..191af0057e468 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 0000000000000..43479ca9cfd4d --- /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 0000000000000..e7206f4020726 --- /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 66a6676375436..0000000000000 --- 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 f5bc921042adf..8af5aa7c351c1 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/LogoutTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/LogoutTest.php @@ -20,29 +20,6 @@ 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 0000000000000..9e736f0955845 --- /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 72d23f03f30f7..96670d1322b2d 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 @@ public function __construct($varDir, $testCase, $rootConfig, $environment, $debu $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 @@ public function __construct($varDir, $testCase, $rootConfig, $environment, $debu */ 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 @@ public function getLogDir(): string 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 24c6581f2968e..0000000000000 --- 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 5dfc173869548..0000000000000 --- 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 08975bdcb3832..0000000000000 --- 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 a52ae15f6d9bd..341dac04c2649 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 0000000000000..b01603b3f6aa7 --- /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 542b40ba6bfa2..696a9041e8035 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 0000000000000..a529c217f2255 --- /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 0000000000000..411de7211ebce --- /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 0000000000000..a4f97930a2535 --- /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 0000000000000..69a5586c80ce9 --- /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 1dddfca2f8154..0000000000000 --- 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 adfd240992712..e8c3d88388644 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 0000000000000..8986c62f3d77b --- /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 0000000000000..72102fe86cbc5 --- /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 1a7dbd68fba20..e5b9f9007d4fd 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 0000000000000..ad4028320589f --- /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 d4527c5acc46b..7293d8737d976 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 8ce47fce278b3..5a03f90c52fc6 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 @@ /** * 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 @@ */ 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 b663a275062dc..d47b10189aeb8 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 @@ */ 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 @@ public function supports(Request $request): ?bool 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; + if (($cookie = $request->attributes->get(ResponseListener::COOKIE_ATTR_NAME)) && null === $cookie->getValue()) { + return false; } - $token = $this->rememberMeServices->autoLogin($request); - if (null === $token) { + if (!$request->cookies->has($this->cookieName)) { return false; } - $request->attributes->set('_remember_me_token', $token); + 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 @@ public function supports(Request $request): ?bool 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 @@ public function onAuthenticationSuccess(Request $request, TokenInterface $token, 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 d36fb50e75560..cd4e8e01de8ee 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\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 0000000000000..b09f4ec1fcdc6 --- /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 0000000000000..ccf201d722680 --- /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 70e15aa406d66..08f58e1078cde 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\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 @@ */ 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 @@ public function onSuccessfulLogin(LoginSuccessEvent $event): void 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 ec8482a046ef7..9416777b81ae3 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\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 @@ public function authenticate(RequestEvent $event) $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 @@ public function authenticate(RequestEvent $event) } 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 @@ public function onKernelResponse(ResponseEvent $event) $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 @@ protected function refreshUser(TokenInterface $token): ?TokenInterface $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 adaba2c715af9..b0f338329228b 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 46f91298fff2a..b1c94aa19ee78 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 @@ * @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 ec4cf65f0af5d..3baa73a1370eb 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 @@ public function createLoginLink(UserInterface $user, Request $request = null): L // @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 @@ public function consumeLoginLink(Request $request): UserInterface $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 0000000000000..42a5e05528880 --- /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 0000000000000..24a03861d9725 --- /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 0000000000000..8bf2cdd3f4918 --- /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 0000000000000..9ab2f69df8c65 --- /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 ba2a86c2a163a..82eab6969fc5f 100644 --- a/src/Symfony/Component/Security/Http/RememberMe/ResponseListener.php +++ b/src/Symfony/Component/Security/Http/RememberMe/ResponseListener.php @@ -24,6 +24,12 @@ */ 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 @@ public function onKernelResponse(ResponseEvent $event) $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 0000000000000..79c3814dd6a76 --- /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 c8ccdc80b8691..27adff550d784 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); - - $this->assertSame($user, $passport->getUser()); + $rememberMeDetails = new RememberMeDetails(InMemoryUser::class, 'wouter', 1, 'secret'); + $request = Request::create('/', 'GET', [], ['_remember_me_cookie' => $rememberMeDetails->toString()]); + $passport = $this->authenticator->authenticate($request); + + $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 0000000000000..adc4a51251ded --- /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 d0ca59949e536..a952dc363f4cb 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\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 4f79cd956ddb3..f995d215cabf1 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 @@ public function testOnKernelResponseWithoutSession() $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 @@ public function testInvalidTokenInSession($token) { $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 @@ public function testHandleAddsKernelResponseListener() ->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 @@ public function testHandleAddsKernelResponseListener() 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 @@ public function testOnKernelResponseListenerRemovesItself() 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 @@ protected function runSessionOnKernelResponse($newToken, $original = null) { $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 1392a5214fe81..0e07a0805a821 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 @@ 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 @@ public function testConsumeLoginLink() $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 @@ public function testConsumeLoginLinkExceedsMaxUsage() $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 @@ private function createLinker(array $options = [], array $extraProperties = ['em '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 0000000000000..3eb39598276f6 --- /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 0000000000000..ee65843c7409e --- /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')); + } +} pFad - Phonifier reborn

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

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


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy