feature #40145 [Security] Rework the remember me system (wouterj)

This PR was squashed before being merged into the 5.3-dev branch.

Discussion
----------

[Security] Rework the remember me system

| Q             | A
| ------------- | ---
| Branch?       | 5.x
| Bug fix?      | no
| New feature?  | yes
| Deprecations? | no
| Tickets       | Fixes part of #39308
| License       | MIT
| Doc PR        | tbd

As I said in #39308, I want to change the remember me system in Symfony 5.3. The remember me system has a couple big "problems":

1. **It's hardwired into some Security classes** like `ContextListener`. The `RememberMeFactory` adds a `setRememberMe()` method call to the DI config and the context listener calls methods on this. This is very coupled, instead of the decoupled nature of the rest of security.
2. **Conditional conditions are combined with cookie creation in one class**. This is especially hard in e.g. 2FA (where setting the cookie should be done after 2FA is completed, which is currently near impossible as it's directly bound to the conditional of being called after logging in).

The changes
---

* The first commits harden the current functional test suite of remember me, to avoid breaking it.
* I discovered a lot of similarity between remember me tokens and login links. That's why I've extracted the shared logic into a generic `SignatureHasher` in the 3rd commit.
* I then remodelled `RememberMeAuthenticator` to the login link system, which I think improves a lot and at least improves problem (2) - as the conditionals (`RememberMeAuthenticator`) is split from the cookie creation (`RememberMeHandlerInterface`).
* Finally, I added a new event (`TokenDeauthenticatedEvent`) to the `ContextListener` to avoid direct coupling - solving problem (1).

This removes any usage of remember me services, which can be deprecated along with the rest of the security system.

Usage
---

As with the authenticator manager: **Nothing changes in the configuration**

Usage of persistent token providers has been improved. First, configuration is provided (setting up services is no longer needed):
```yaml
# before
services:
    Symfony\Bridge\Doctrine\Security\RememberMe\DoctrineTokenProvider:
        autowire: true

security:
    firewalls:
        main:
            remember_me:
                # ...
                token_provider: 'Symfony\Bridge\Doctrine\Security\RememberMe\DoctrineTokenProvider'

# after
security:
    firewalls:
        main:
            remember_me:
                # ...
                token_provider:
                    doctrine: true
```

Furthermore, a schema listener is created. Whenever the doctrine token provider is used, `make:migration`/`doctrine:schema:update` will automatically create the required table.

Some advanced usage of Remember me is also improved a lot (there is no real "before" here, consider looking at scheb/2fa to get an idea of the before). A few use-cases I took into account:

* If you ever need to **programmatically create a remember me cookie**, you can autowire `RememberMeHandlerInterface` and use `createRememberMeCookie($user)`. This will make sure the remember me cookie is set on the final response (using the `ResponseListener`)
* The `RememberMeListener` previously was responsible for both determining if a cookie must be set and setting the cookie. This is now split in 2 listeners (checking is done by `RememberMeConditionsListener`). If `RememberMeBadge` is enabled, the cookie is set and otherwise it isn't. This allows e.g. SchebTwoFactorBundle to create a listener that catches whether remember me was requested, but suppress it until the 2nd factor is completed.

Todo
---

* [x] Update UPGRADE and CHANGELOG
* [x] Show before/after examples
* [x] Investigate the conditional event registering of `ContextListener`. This forces to inject both the firewall and the global event dispatcher at the moment.
* Make sure old remember me tokens still function. As remember me tokens are long lived, we may need to provide backwards compatibility for at least Symfony 6.x. **Update: it was decided to not include this for now: https://github.com/symfony/symfony/pull/40145#issuecomment-785819607**

cc `@scheb` `@weaverryan` as you both initiated this PR by sharing the problems with the current design.

Commits
-------

15670419d4 [Security] Rework the remember me system
This commit is contained in:
Robin Chalas 2021-04-11 14:47:25 +02:00
commit b40eac2e78
68 changed files with 2241 additions and 506 deletions

View File

@ -0,0 +1,62 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\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 <wouter@wouterj.nl>
*/
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,
];
}
}

View File

@ -14,6 +14,7 @@ namespace Symfony\Bridge\Doctrine\Security\RememberMe;
use Doctrine\DBAL\Connection; use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Driver\Result as DriverResult; use Doctrine\DBAL\Driver\Result as DriverResult;
use Doctrine\DBAL\Result; use Doctrine\DBAL\Result;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
use Symfony\Component\Security\Core\Authentication\RememberMe\PersistentToken; use Symfony\Component\Security\Core\Authentication\RememberMe\PersistentToken;
use Symfony\Component\Security\Core\Authentication\RememberMe\PersistentTokenInterface; use Symfony\Component\Security\Core\Authentication\RememberMe\PersistentTokenInterface;
@ -21,7 +22,7 @@ use Symfony\Component\Security\Core\Authentication\RememberMe\TokenProviderInter
use Symfony\Component\Security\Core\Exception\TokenNotFoundException; 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 * cookies. This way no password secrets will be stored in the cookies on
* the client machine, and thus the security is improved. * the client machine, and thus the security is improved.
* *
@ -53,8 +54,7 @@ class DoctrineTokenProvider implements TokenProviderInterface
public function loadTokenBySeries(string $series) public function loadTokenBySeries(string $series)
{ {
// the alias for lastUsed works around case insensitivity in PostgreSQL // the alias for lastUsed works around case insensitivity in PostgreSQL
$sql = 'SELECT class, username, value, lastUsed AS last_used' $sql = 'SELECT class, username, value, lastUsed AS last_used FROM rememberme_token WHERE series=:series';
.' FROM rememberme_token WHERE series=:series';
$paramValues = ['series' => $series]; $paramValues = ['series' => $series];
$paramTypes = ['series' => \PDO::PARAM_STR]; $paramTypes = ['series' => \PDO::PARAM_STR];
$stmt = $this->conn->executeQuery($sql, $paramValues, $paramTypes); $stmt = $this->conn->executeQuery($sql, $paramValues, $paramTypes);
@ -87,8 +87,7 @@ class DoctrineTokenProvider implements TokenProviderInterface
*/ */
public function updateToken(string $series, string $tokenValue, \DateTime $lastUsed) public function updateToken(string $series, string $tokenValue, \DateTime $lastUsed)
{ {
$sql = 'UPDATE rememberme_token SET value=:value, lastUsed=:lastUsed' $sql = 'UPDATE rememberme_token SET value=:value, lastUsed=:lastUsed WHERE series=:series';
.' WHERE series=:series';
$paramValues = [ $paramValues = [
'value' => $tokenValue, 'value' => $tokenValue,
'lastUsed' => $lastUsed, 'lastUsed' => $lastUsed,
@ -114,9 +113,7 @@ class DoctrineTokenProvider implements TokenProviderInterface
*/ */
public function createNewToken(PersistentTokenInterface $token) public function createNewToken(PersistentTokenInterface $token)
{ {
$sql = 'INSERT INTO rememberme_token' $sql = 'INSERT INTO rememberme_token (class, username, series, value, lastUsed) VALUES (:class, :username, :series, :value, :lastUsed)';
.' (class, username, series, value, lastUsed)'
.' VALUES (:class, :username, :series, :value, :lastUsed)';
$paramValues = [ $paramValues = [
'class' => $token->getClass(), 'class' => $token->getClass(),
// @deprecated since 5.3, change to $token->getUserIdentifier() in 6.0 // @deprecated since 5.3, change to $token->getUserIdentifier() in 6.0
@ -138,4 +135,32 @@ class DoctrineTokenProvider implements TokenProviderInterface
$this->conn->executeUpdate($sql, $paramValues, $paramTypes); $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']);
}
} }

View File

@ -77,6 +77,7 @@ class UnusedTagsPass implements CompilerPassInterface
'security.authenticator.login_linker', 'security.authenticator.login_linker',
'security.expression_language_provider', 'security.expression_language_provider',
'security.remember_me_aware', 'security.remember_me_aware',
'security.remember_me_handler',
'security.voter', 'security.voter',
'serializer.encoder', 'serializer.encoder',
'serializer.normalizer', 'serializer.normalizer',

View File

@ -21,6 +21,7 @@ use Symfony\Component\Security\Http\Event\InteractiveLoginEvent;
use Symfony\Component\Security\Http\Event\LoginFailureEvent; use Symfony\Component\Security\Http\Event\LoginFailureEvent;
use Symfony\Component\Security\Http\Event\LoginSuccessEvent; use Symfony\Component\Security\Http\Event\LoginSuccessEvent;
use Symfony\Component\Security\Http\Event\LogoutEvent; use Symfony\Component\Security\Http\Event\LogoutEvent;
use Symfony\Component\Security\Http\Event\TokenDeauthenticatedEvent;
use Symfony\Component\Security\Http\SecurityEvents; use Symfony\Component\Security\Http\SecurityEvents;
/** /**
@ -44,6 +45,7 @@ class RegisterGlobalSecurityEventListenersPass implements CompilerPassInterface
AuthenticationTokenCreatedEvent::class, AuthenticationTokenCreatedEvent::class,
AuthenticationSuccessEvent::class, AuthenticationSuccessEvent::class,
InteractiveLoginEvent::class, InteractiveLoginEvent::class,
TokenDeauthenticatedEvent::class,
// When events are registered by their name // When events are registered by their name
AuthenticationEvents::AUTHENTICATION_SUCCESS, AuthenticationEvents::AUTHENTICATION_SUCCESS,

View File

@ -0,0 +1,61 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\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 <wouter@wouterj.nl>
*
* @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'];
}
}
}
}

View File

@ -113,18 +113,24 @@ class LoginLinkFactory extends AbstractFactory implements AuthenticatorFactoryIn
->replaceArgument(1, $config['lifetime']); ->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; $linkerId = 'security.authenticator.login_link_handler.'.$firewallName;
$linkerOptions = [ $linkerOptions = [
'route_name' => $config['check_route'], 'route_name' => $config['check_route'],
'lifetime' => $config['lifetime'], 'lifetime' => $config['lifetime'],
'max_uses' => $config['max_uses'] ?? null,
]; ];
$container $container
->setDefinition($linkerId, new ChildDefinition('security.authenticator.abstract_login_link_handler')) ->setDefinition($linkerId, new ChildDefinition('security.authenticator.abstract_login_link_handler'))
->replaceArgument(1, new Reference($userProviderId)) ->replaceArgument(1, new Reference($userProviderId))
->replaceArgument(3, $config['signature_properties']) ->replaceArgument(2, new Reference($signatureHasherId))
->replaceArgument(5, $linkerOptions) ->replaceArgument(3, $linkerOptions)
->replaceArgument(6, $expiredStorageId ? new Reference($expiredStorageId) : null)
->addTag('security.authenticator.login_linker', ['firewall' => $firewallName]) ->addTag('security.authenticator.login_linker', ['firewall' => $firewallName])
; ;

View File

@ -11,11 +11,16 @@
namespace Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory; 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\Builder\NodeDefinition;
use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\Argument\IteratorArgument; use Symfony\Component\DependencyInjection\Argument\IteratorArgument;
use Symfony\Component\DependencyInjection\ChildDefinition; use Symfony\Component\DependencyInjection\ChildDefinition;
use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;
use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\HttpFoundation\Cookie; use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\Security\Http\EventListener\RememberMeLogoutListener; use Symfony\Component\Security\Http\EventListener\RememberMeLogoutListener;
@ -94,31 +99,66 @@ class RememberMeFactory implements SecurityFactoryInterface, AuthenticatorFactor
public function createAuthenticator(ContainerBuilder $container, string $firewallName, array $config, string $userProviderId): string public function createAuthenticator(ContainerBuilder $container, string $firewallName, array $config, string $userProviderId): string
{ {
$templateId = $this->generateRememberMeServicesTemplateId($config, $firewallName); if (!$container->hasDefinition('security.authenticator.remember_me')) {
$rememberMeServicesId = $templateId.'.'.$firewallName; $loader = new PhpFileLoader($container, new FileLocator(\dirname(__DIR__).'/../../Resources/config'));
$loader->load('security_authenticator_remember_me.php');
}
// create remember me services (which manage the remember me cookies) // create remember me handler (which manage the remember-me cookies)
$this->createRememberMeServices($container, $firewallName, $templateId, [new Reference($userProviderId)], $config); $rememberMeHandlerId = 'security.authenticator.remember_me_handler.'.$firewallName;
if (isset($config['service']) && isset($config['token_provider'])) {
throw new InvalidConfigurationException(sprintf('You cannot use both "service" and "token_provider" in "security.firewalls.%s.remember_me".', $firewallName));
}
if (isset($config['service'])) {
$container->register($rememberMeHandlerId, DecoratedRememberMeHandler::class)
->addArgument(new Reference($config['service']))
->addTag('security.remember_me_handler', ['firewall' => $firewallName]);
} elseif (isset($config['token_provider'])) {
$tokenProviderId = $this->createTokenProvider($container, $firewallName, $config['token_provider']);
$container->setDefinition($rememberMeHandlerId, new ChildDefinition('security.authenticator.persistent_remember_me_handler'))
->replaceArgument(0, new Reference($tokenProviderId))
->replaceArgument(2, new Reference($userProviderId))
->replaceArgument(4, $config)
->addTag('security.remember_me_handler', ['firewall' => $firewallName]);
} else {
$signatureHasherId = 'security.authenticator.remember_me_signature_hasher.'.$firewallName;
$container->setDefinition($signatureHasherId, new ChildDefinition('security.authenticator.remember_me_signature_hasher'))
->replaceArgument(1, $config['signature_properties'])
;
$container->setDefinition($rememberMeHandlerId, new ChildDefinition('security.authenticator.signature_remember_me_handler'))
->replaceArgument(0, new Reference($signatureHasherId))
->replaceArgument(1, new Reference($userProviderId))
->replaceArgument(3, $config)
->addTag('security.remember_me_handler', ['firewall' => $firewallName]);
}
// create check remember me conditions listener (which checks if a remember-me cookie is supported and requested)
$rememberMeConditionsListenerId = 'security.listener.check_remember_me_conditions.'.$firewallName;
$container->setDefinition($rememberMeConditionsListenerId, new ChildDefinition('security.listener.check_remember_me_conditions'))
->replaceArgument(0, array_intersect_key($config, ['always_remember_me' => true, 'remember_me_parameter' => true]))
->addTag('kernel.event_subscriber', ['dispatcher' => 'security.event_dispatcher.'.$firewallName])
;
// create remember me listener (which executes the remember me services for other authenticators and logout) // 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; $authenticatorId = 'security.authenticator.remember_me.'.$firewallName;
$container $container
->setDefinition($authenticatorId, new ChildDefinition('security.authenticator.remember_me')) ->setDefinition($authenticatorId, new ChildDefinition('security.authenticator.remember_me'))
->replaceArgument(0, new Reference($rememberMeServicesId)) ->replaceArgument(0, new Reference($rememberMeHandlerId))
->replaceArgument(3, $container->getDefinition($rememberMeServicesId)->getArgument(3)) ->replaceArgument(3, $config['name'] ?? $this->options['name'])
; ;
foreach ($container->findTaggedServiceIds('security.remember_me_aware') as $serviceId => $attributes) { foreach ($container->findTaggedServiceIds('security.remember_me_aware') as $serviceId => $attributes) {
// register ContextListener // register ContextListener
if ('security.context_listener' === substr($serviceId, 0, 25)) { if ('security.context_listener' === substr($serviceId, 0, 25)) {
$container
->getDefinition($serviceId)
->addMethodCall('setRememberMeServices', [new Reference($rememberMeServicesId)])
;
continue; continue;
} }
@ -148,7 +188,6 @@ class RememberMeFactory implements SecurityFactoryInterface, AuthenticatorFactor
$builder $builder
->scalarNode('secret')->isRequired()->cannotBeEmpty()->end() ->scalarNode('secret')->isRequired()->cannotBeEmpty()->end()
->scalarNode('service')->end() ->scalarNode('service')->end()
->scalarNode('token_provider')->end()
->arrayNode('user_providers') ->arrayNode('user_providers')
->beforeNormalization() ->beforeNormalization()
->ifString()->then(function ($v) { return [$v]; }) ->ifString()->then(function ($v) { return [$v]; })
@ -156,7 +195,26 @@ class RememberMeFactory implements SecurityFactoryInterface, AuthenticatorFactor
->prototype('scalar')->end() ->prototype('scalar')->end()
->end() ->end()
->booleanNode('catch_exceptions')->defaultTrue()->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) { foreach ($this->options as $name => $value) {
if ('secure' === $name) { if ('secure' === $name) {
@ -195,9 +253,8 @@ class RememberMeFactory implements SecurityFactoryInterface, AuthenticatorFactor
$rememberMeServices->replaceArgument(2, $id); $rememberMeServices->replaceArgument(2, $id);
if (isset($config['token_provider'])) { if (isset($config['token_provider'])) {
$rememberMeServices->addMethodCall('setTokenProvider', [ $tokenProviderId = $this->createTokenProvider($container, $id, $config['token_provider']);
new Reference($config['token_provider']), $rememberMeServices->addMethodCall('setTokenProvider', [new Reference($tokenProviderId)]);
]);
} }
// remember-me options // remember-me options
@ -222,17 +279,29 @@ class RememberMeFactory implements SecurityFactoryInterface, AuthenticatorFactor
$rememberMeServices->replaceArgument(0, new IteratorArgument(array_unique($userProviders))); $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 $tokenProviderId = $config['service'] ?? false;
->setDefinition('security.listener.remember_me.'.$id, new ChildDefinition('security.listener.remember_me')) if ($config['doctrine']['enabled'] ?? false) {
->addTag('kernel.event_subscriber', ['dispatcher' => 'security.event_dispatcher.'.$id]) if (!class_exists(DoctrineTokenProvider::class)) {
->replaceArgument(0, new Reference($rememberMeServicesId)) 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 if (null === $config['doctrine']['connection']) {
->setDefinition('security.logout.listener.remember_me.'.$id, new Definition(RememberMeLogoutListener::class)) $connectionId = 'database_connection';
->addTag('kernel.event_subscriber', ['dispatcher' => 'security.event_dispatcher.'.$id]) } else {
->addArgument(new Reference($rememberMeServicesId)); $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;
} }
} }

View File

@ -34,6 +34,7 @@ use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\ExpressionLanguage\ExpressionLanguage; use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
use Symfony\Component\HttpKernel\DependencyInjection\Extension; use Symfony\Component\HttpKernel\DependencyInjection\Extension;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\PasswordHasher\Hasher\NativePasswordHasher; use Symfony\Component\PasswordHasher\Hasher\NativePasswordHasher;
use Symfony\Component\PasswordHasher\Hasher\Pbkdf2PasswordHasher; use Symfony\Component\PasswordHasher\Hasher\Pbkdf2PasswordHasher;
use Symfony\Component\PasswordHasher\Hasher\PlaintextPasswordHasher; use Symfony\Component\PasswordHasher\Hasher\PlaintextPasswordHasher;
@ -392,7 +393,7 @@ class SecurityExtension extends Extension implements PrependExtensionInterface
// Context serializer listener // Context serializer listener
if (false === $firewall['stateless']) { if (false === $firewall['stateless']) {
$contextKey = $firewall['context'] ?? $id; $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'; $sessionStrategyId = 'security.authentication.session_strategy';
if ($this->authenticatorManagerEnabled) { if ($this->authenticatorManagerEnabled) {
@ -557,7 +558,7 @@ class SecurityExtension extends Extension implements PrependExtensionInterface
return [$matcher, $listeners, $exceptionListener, null !== $logoutListenerId ? new Reference($logoutListenerId) : null]; 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])) { if (isset($this->contextListeners[$contextKey])) {
return $this->contextListeners[$contextKey]; return $this->contextListeners[$contextKey];
@ -566,6 +567,10 @@ class SecurityExtension extends Extension implements PrependExtensionInterface
$listenerId = 'security.context_listener.'.\count($this->contextListeners); $listenerId = 'security.context_listener.'.\count($this->contextListeners);
$listener = $container->setDefinition($listenerId, new ChildDefinition('security.context_listener')); $listener = $container->setDefinition($listenerId, new ChildDefinition('security.context_listener'));
$listener->replaceArgument(2, $contextKey); $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; return $this->contextListeners[$contextKey] = $listenerId;
} }

View File

@ -12,6 +12,7 @@
namespace Symfony\Bundle\SecurityBundle\LoginLink; namespace Symfony\Bundle\SecurityBundle\LoginLink;
use Psr\Container\ContainerInterface; use Psr\Container\ContainerInterface;
use Symfony\Bundle\SecurityBundle\Security\FirewallAwareTrait;
use Symfony\Bundle\SecurityBundle\Security\FirewallMap; use Symfony\Bundle\SecurityBundle\Security\FirewallMap;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\RequestStack;
@ -26,43 +27,24 @@ use Symfony\Component\Security\Http\LoginLink\LoginLinkHandlerInterface;
*/ */
class FirewallAwareLoginLinkHandler implements LoginLinkHandlerInterface class FirewallAwareLoginLinkHandler implements LoginLinkHandlerInterface
{ {
private $firewallMap; use FirewallAwareTrait;
private $loginLinkHandlerLocator;
private $requestStack; private const FIREWALL_OPTION = 'login_link';
public function __construct(FirewallMap $firewallMap, ContainerInterface $loginLinkHandlerLocator, RequestStack $requestStack) public function __construct(FirewallMap $firewallMap, ContainerInterface $loginLinkHandlerLocator, RequestStack $requestStack)
{ {
$this->firewallMap = $firewallMap; $this->firewallMap = $firewallMap;
$this->loginLinkHandlerLocator = $loginLinkHandlerLocator; $this->locator = $loginLinkHandlerLocator;
$this->requestStack = $requestStack; $this->requestStack = $requestStack;
} }
public function createLoginLink(UserInterface $user, Request $request = null): LoginLinkDetails 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 public function consumeLoginLink(Request $request): UserInterface
{ {
return $this->getLoginLinkHandler()->consumeLoginLink($request); return $this->getForFirewall()->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);
} }
} }

View File

@ -0,0 +1,57 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\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 <wouter@wouterj.nl>
*
* @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();
}
}

View File

@ -0,0 +1,56 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\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 <wouter@wouterj.nl>
*
* @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();
}
}

View File

@ -334,9 +334,12 @@
</xsd:complexType> </xsd:complexType>
<xsd:complexType name="remember_me"> <xsd:complexType name="remember_me">
<xsd:choice minOccurs="0" maxOccurs="unbounded"> <xsd:sequence minOccurs="0">
<xsd:element name="user-provider" type="xsd:string" /> <xsd:choice minOccurs="0" maxOccurs="unbounded">
</xsd:choice> <xsd:element name="user-provider" type="xsd:string" />
</xsd:choice>
<xsd:element name="token-provider" type="remember_me_token_provider" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" /> <xsd:attribute name="name" type="xsd:string" />
<xsd:attribute name="lifetime" type="xsd:integer" /> <xsd:attribute name="lifetime" type="xsd:integer" />
<xsd:attribute name="path" type="xsd:string" /> <xsd:attribute name="path" type="xsd:string" />
@ -352,6 +355,18 @@
<xsd:attribute name="samesite" type="remember_me_samesite" /> <xsd:attribute name="samesite" type="remember_me_samesite" />
</xsd:complexType> </xsd:complexType>
<xsd:complexType name="remember_me_token_provider">
<xsd:sequence>
<xsd:element name="doctrine" type="remember_me_token_provider_doctrine" />
</xsd:sequence>
<xsd:attribute name="service" type="xsd:string" />
</xsd:complexType>
<xsd:complexType name="remember_me_token_provider_doctrine">
<xsd:attribute name="enabled" type="xsd:boolean" />
<xsd:attribute name="connection" type="xsd:string" />
</xsd:complexType>
<xsd:simpleType name="remember_me_secure"> <xsd:simpleType name="remember_me_secure">
<xsd:restriction base="xsd:string"> <xsd:restriction base="xsd:string">
<xsd:enumeration value="true" /> <xsd:enumeration value="true" />

View File

@ -20,14 +20,12 @@ use Symfony\Component\Security\Http\Authentication\UserAuthenticatorInterface;
use Symfony\Component\Security\Http\Authenticator\FormLoginAuthenticator; use Symfony\Component\Security\Http\Authenticator\FormLoginAuthenticator;
use Symfony\Component\Security\Http\Authenticator\HttpBasicAuthenticator; use Symfony\Component\Security\Http\Authenticator\HttpBasicAuthenticator;
use Symfony\Component\Security\Http\Authenticator\JsonLoginAuthenticator; 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\RemoteUserAuthenticator;
use Symfony\Component\Security\Http\Authenticator\X509Authenticator; use Symfony\Component\Security\Http\Authenticator\X509Authenticator;
use Symfony\Component\Security\Http\Event\CheckPassportEvent; use Symfony\Component\Security\Http\Event\CheckPassportEvent;
use Symfony\Component\Security\Http\EventListener\CheckCredentialsListener; use Symfony\Component\Security\Http\EventListener\CheckCredentialsListener;
use Symfony\Component\Security\Http\EventListener\LoginThrottlingListener; use Symfony\Component\Security\Http\EventListener\LoginThrottlingListener;
use Symfony\Component\Security\Http\EventListener\PasswordMigratingListener; 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\SessionStrategyListener;
use Symfony\Component\Security\Http\EventListener\UserCheckerListener; use Symfony\Component\Security\Http\EventListener\UserCheckerListener;
use Symfony\Component\Security\Http\EventListener\UserProviderListener; use Symfony\Component\Security\Http\EventListener\UserProviderListener;
@ -107,14 +105,6 @@ return static function (ContainerConfigurator $container) {
service('security.authentication.session_strategy'), 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) ->set('security.listener.login_throttling', LoginThrottlingListener::class)
->abstract() ->abstract()
->args([ ->args([
@ -154,16 +144,6 @@ return static function (ContainerConfigurator $container) {
]) ])
->call('setTranslator', [service('translator')->ignoreOnInvalid()]) ->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) ->set('security.authenticator.x509', X509Authenticator::class)
->abstract() ->abstract()
->args([ ->args([

View File

@ -12,8 +12,9 @@
namespace Symfony\Component\DependencyInjection\Loader\Configurator; namespace Symfony\Component\DependencyInjection\Loader\Configurator;
use Symfony\Bundle\SecurityBundle\LoginLink\FirewallAwareLoginLinkHandler; 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\Authenticator\LoginLinkAuthenticator;
use Symfony\Component\Security\Http\LoginLink\ExpiredLoginLinkStorage;
use Symfony\Component\Security\Http\LoginLink\LoginLinkHandler; use Symfony\Component\Security\Http\LoginLink\LoginLinkHandler;
use Symfony\Component\Security\Http\LoginLink\LoginLinkHandlerInterface; use Symfony\Component\Security\Http\LoginLink\LoginLinkHandlerInterface;
@ -34,14 +35,20 @@ return static function (ContainerConfigurator $container) {
->args([ ->args([
service('router'), service('router'),
abstract_arg('user provider'), 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'), service('property_accessor'),
abstract_arg('signature properties'), abstract_arg('signature properties'),
'%kernel.secret%', '%kernel.secret%',
abstract_arg('options'), abstract_arg('expired signature storage'),
abstract_arg('expired login link 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() ->abstract()
->args([ ->args([
abstract_arg('cache pool service'), abstract_arg('cache pool service'),

View File

@ -0,0 +1,91 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\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'])
;
};

View File

@ -0,0 +1,52 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\SecurityBundle\Security;
/**
* Provides basic functionality for services mapped by the firewall name
* in a container locator.
*
* @author Wouter de Jong <wouter@wouterj.nl>
*
* @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);
}
}

View File

@ -15,11 +15,9 @@ use Psr\Container\ContainerInterface;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Exception\LogicException;
use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Http\Authentication\UserAuthenticatorInterface; use Symfony\Component\Security\Http\Authentication\UserAuthenticatorInterface;
use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface; 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 * A decorator that delegates all method calls to the authenticator
@ -32,14 +30,12 @@ use Symfony\Component\Security\Http\Authenticator\Passport\Badge\BadgeInterface;
*/ */
class UserAuthenticator implements UserAuthenticatorInterface class UserAuthenticator implements UserAuthenticatorInterface
{ {
private $firewallMap; use FirewallAwareTrait;
private $userAuthenticators;
private $requestStack;
public function __construct(FirewallMap $firewallMap, ContainerInterface $userAuthenticators, RequestStack $requestStack) public function __construct(FirewallMap $firewallMap, ContainerInterface $userAuthenticators, RequestStack $requestStack)
{ {
$this->firewallMap = $firewallMap; $this->firewallMap = $firewallMap;
$this->userAuthenticators = $userAuthenticators; $this->locator = $userAuthenticators;
$this->requestStack = $requestStack; $this->requestStack = $requestStack;
} }
@ -48,16 +44,6 @@ class UserAuthenticator implements UserAuthenticatorInterface
*/ */
public function authenticateUser(UserInterface $user, AuthenticatorInterface $authenticator, Request $request, array $badges = []): ?Response public function authenticateUser(UserInterface $user, AuthenticatorInterface $authenticator, Request $request, array $badges = []): ?Response
{ {
return $this->getUserAuthenticator()->authenticateUser($user, $authenticator, $request, $badges); return $this->getForFirewall()->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());
} }
} }

View File

@ -19,6 +19,7 @@ use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\RegisterEntryPoin
use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\RegisterGlobalSecurityEventListenersPass; use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\RegisterGlobalSecurityEventListenersPass;
use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\RegisterLdapLocatorPass; use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\RegisterLdapLocatorPass;
use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\RegisterTokenUsageTrackingPass; 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\Compiler\SortFirewallListenersPass;
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\AnonymousFactory; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\AnonymousFactory;
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\CustomAuthenticatorFactory; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\CustomAuthenticatorFactory;
@ -83,6 +84,7 @@ class SecurityBundle extends Bundle
$container->addCompilerPass(new RegisterGlobalSecurityEventListenersPass(), PassConfig::TYPE_BEFORE_REMOVING, -200); $container->addCompilerPass(new RegisterGlobalSecurityEventListenersPass(), PassConfig::TYPE_BEFORE_REMOVING, -200);
// execute after ResolveChildDefinitionsPass optimization pass, to ensure class names are set // execute after ResolveChildDefinitionsPass optimization pass, to ensure class names are set
$container->addCompilerPass(new SortFirewallListenersPass(), PassConfig::TYPE_BEFORE_REMOVING); $container->addCompilerPass(new SortFirewallListenersPass(), PassConfig::TYPE_BEFORE_REMOVING);
$container->addCompilerPass(new ReplaceDecoratedRememberMeHandlerPass(), PassConfig::TYPE_OPTIMIZE);
$container->addCompilerPass(new AddEventAliasesPass(array_merge( $container->addCompilerPass(new AddEventAliasesPass(array_merge(
AuthenticationEvents::ALIASES, AuthenticationEvents::ALIASES,

View File

@ -51,13 +51,15 @@ abstract class CompleteConfigurationTest extends TestCase
$this->assertEquals(3600, (string) $expiredStorage->getArgument(1)); $this->assertEquals(3600, (string) $expiredStorage->getArgument(1));
$linker = $container->getDefinition($linkerId = 'security.authenticator.login_link_handler.main'); $linker = $container->getDefinition($linkerId = 'security.authenticator.login_link_handler.main');
$this->assertEquals(['id', 'email'], $linker->getArgument(3));
$this->assertEquals([ $this->assertEquals([
'route_name' => 'login_check', 'route_name' => 'login_check',
'lifetime' => 3600, 'lifetime' => 3600,
'max_uses' => 1, ], $linker->getArgument(3));
], $linker->getArgument(5));
$this->assertEquals($expiredStorageId, (string) $linker->getArgument(6)); $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'); $authenticator = $container->getDefinition('security.authenticator.login_link.main');
$this->assertEquals($linkerId, (string) $authenticator->getArgument(0)); $this->assertEquals($linkerId, (string) $authenticator->getArgument(0));

View File

@ -388,6 +388,27 @@ class SecurityExtensionTest extends TestCase
$this->assertEquals($secure, $definition->getArgument(3)['secure']); $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() public function sessionConfigurationProvider()
{ {
return [ return [
@ -661,13 +682,13 @@ class SecurityExtensionTest extends TestCase
$security = new SecurityExtension(); $security = new SecurityExtension();
$container->registerExtension($security); $container->registerExtension($security);
$bundle = new SecurityBundle();
$bundle->build($container);
$container->getCompilerPassConfig()->setOptimizationPasses([new ResolveChildDefinitionsPass()]); $container->getCompilerPassConfig()->setOptimizationPasses([new ResolveChildDefinitionsPass()]);
$container->getCompilerPassConfig()->setRemovingPasses([]); $container->getCompilerPassConfig()->setRemovingPasses([]);
$container->getCompilerPassConfig()->setAfterRemovingPasses([]); $container->getCompilerPassConfig()->setAfterRemovingPasses([]);
$bundle = new SecurityBundle();
$bundle->build($container);
return $container; return $container;
} }

View File

@ -0,0 +1,23 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\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());
}
}

View File

@ -9,10 +9,10 @@
* file that was distributed with this source code. * file that was distributed with this source code.
*/ */
use Symfony\Bundle\FrameworkBundle\FrameworkBundle; namespace Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\RememberMeBundle;
use Symfony\Bundle\SecurityBundle\SecurityBundle;
return [ use Symfony\Component\HttpKernel\Bundle\Bundle;
new FrameworkBundle(),
new SecurityBundle(), class RememberMeBundle extends Bundle
]; {
}

View File

@ -0,0 +1,66 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\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;
}
}

View File

@ -0,0 +1,52 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\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);
}
}

View File

@ -1,91 +0,0 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\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);
}
}

View File

@ -20,29 +20,6 @@ use Symfony\Component\HttpKernel\KernelEvents;
class LogoutTest extends AbstractWebTestCase 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 * @dataProvider provideSecuritySystems
*/ */

View File

@ -0,0 +1,95 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\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'));
}
}

View File

@ -36,10 +36,13 @@ class AppKernel extends Kernel
$this->testCase = $testCase; $this->testCase = $testCase;
$fs = new Filesystem(); $fs = new Filesystem();
if (!$fs->isAbsolutePath($rootConfig) && !is_file($rootConfig = __DIR__.'/'.$testCase.'/'.$rootConfig)) { foreach ((array) $rootConfig as $config) {
throw new \InvalidArgumentException(sprintf('The root config "%s" does not exist.', $rootConfig)); 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; $this->authenticatorManagerEnabled = $authenticatorManagerEnabled;
parent::__construct($environment, $debug); parent::__construct($environment, $debug);
@ -50,7 +53,7 @@ class AppKernel extends Kernel
*/ */
public function getContainerClass(): string 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 public function registerBundles(): iterable
@ -79,7 +82,9 @@ class AppKernel extends Kernel
public function registerContainerConfiguration(LoaderInterface $loader) public function registerContainerConfiguration(LoaderInterface $loader)
{ {
$loader->load($this->rootConfig); foreach ($this->rootConfig as $config) {
$loader->load($config);
}
if ($this->authenticatorManagerEnabled) { if ($this->authenticatorManagerEnabled) {
$loader->load(function ($container) { $loader->load(function ($container) {

View File

@ -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']

View File

@ -1,7 +0,0 @@
imports:
- { resource: ./config.yml }
security:
firewalls:
default:
anonymous: ~

View File

@ -1,7 +0,0 @@
login:
path: /login
foo:
path: /foo
defaults:
_controller: Symfony\Bundle\SecurityBundle\Tests\Functional\RememberMeFooController

View File

@ -11,10 +11,10 @@
use Symfony\Bundle\FrameworkBundle\FrameworkBundle; use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
use Symfony\Bundle\SecurityBundle\SecurityBundle; use Symfony\Bundle\SecurityBundle\SecurityBundle;
use Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\TestBundle; use Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\RememberMeBundle\RememberMeBundle;
return [ return [
new FrameworkBundle(), new FrameworkBundle(),
new SecurityBundle(), new SecurityBundle(),
new TestBundle(), new RememberMeBundle(),
]; ];

View File

@ -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']

View File

@ -1,12 +1,6 @@
imports: imports:
- { resource: ./../config/framework.yml } - { resource: ./../config/framework.yml }
framework:
session:
storage_factory_id: session.storage.factory.mock_file
cookie_secure: auto
cookie_samesite: lax
security: security:
password_hashers: password_hashers:
Symfony\Component\Security\Core\User\InMemoryUser: plaintext Symfony\Component\Security\Core\User\InMemoryUser: plaintext
@ -19,12 +13,10 @@ security:
firewalls: firewalls:
default: default:
logout: ~
form_login: form_login:
check_path: login check_path: login
remember_me: true remember_me: true
require_previous_session: false
remember_me: access_control:
always_remember_me: true - { path: ^/profile, roles: ROLE_USER }
secret: key
logout: ~
stateless: true

View File

@ -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

View File

@ -0,0 +1,6 @@
security:
firewalls:
default:
remember_me:
always_remember_me: true
secret: key

View File

@ -0,0 +1,9 @@
login:
path: /login
logout:
path: /logout
profile:
path: /profile
controller: Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\RememberMeBundle\Controller\ProfileController

View File

@ -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

View File

@ -1,5 +0,0 @@
login:
path: /login
logout:
path: /logout

View File

@ -4,6 +4,11 @@ CHANGELOG
5.3 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 `PersistentTokenInterface::getUsername()` in favor of `PersistentTokenInterface::getUserIdentifier()`
* Deprecate `UsernameNotFoundException` in favor of `UserNotFoundException` and `getUsername()`/`setUsername()` in favor of `getUserIdentifier()`/`setUserIdentifier()` * Deprecate `UsernameNotFoundException` in favor of `UserNotFoundException` and `getUsername()`/`setUsername()` in favor of `getUserIdentifier()`/`setUserIdentifier()`
* Deprecate `UserProviderInterface::loadUserByUsername()` in favor of `UserProviderInterface::loadUserByIdentifier()` * Deprecate `UserProviderInterface::loadUserByUsername()` in favor of `UserProviderInterface::loadUserByIdentifier()`

View File

@ -0,0 +1,21 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Security\Core\Signature\Exception;
use Symfony\Component\Security\Core\Exception\RuntimeException;
/**
* @author Wouter de Jong <wouter@wouterj.nl>
*/
class ExpiredSignatureException extends RuntimeException
{
}

View File

@ -0,0 +1,21 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Security\Core\Signature\Exception;
use Symfony\Component\Security\Core\Exception\RuntimeException;
/**
* @author Wouter de Jong <wouter@wouterj.nl>
*/
class InvalidSignatureException extends RuntimeException
{
}

View File

@ -9,16 +9,18 @@
* file that was distributed with this source code. * file that was distributed with this source code.
*/ */
namespace Symfony\Component\Security\Http\LoginLink; namespace Symfony\Component\Security\Core\Signature;
use Psr\Cache\CacheItemPoolInterface; use Psr\Cache\CacheItemPoolInterface;
/** /**
* @author Ryan Weaver <ryan@symfonycasts.com>
*
* @experimental in 5.2 * @experimental in 5.2
* *
* @final * @final
*/ */
class ExpiredLoginLinkStorage final class ExpiredSignatureStorage
{ {
private $cache; private $cache;
private $lifetime; private $lifetime;

View File

@ -0,0 +1,99 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Security\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 <wouter@wouterj.nl>
* @author Ryan Weaver <ryan@symfonycasts.com>
*/
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));
}
}

View File

@ -9,18 +9,18 @@
* file that was distributed with this source code. * 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 PHPUnit\Framework\TestCase;
use Symfony\Component\Cache\Adapter\ArrayAdapter; 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() public function testUsage()
{ {
$cache = new ArrayAdapter(); $cache = new ArrayAdapter();
$storage = new ExpiredLoginLinkStorage($cache, 600); $storage = new ExpiredSignatureStorage($cache, 600);
$this->assertSame(0, $storage->countUsages('hash+more')); $this->assertSame(0, $storage->countUsages('hash+more'));
$storage->incrementUsages('hash+more'); $storage->incrementUsages('hash+more');

View File

@ -14,14 +14,9 @@ namespace Symfony\Component\Security\Http\Authenticator\Passport\Badge;
/** /**
* Adds support for remember me to this authenticator. * Adds support for remember me to this authenticator.
* *
* Remember me cookie will be set if *all* of the following are met: * The presence of this badge doesn't create the remember-me cookie. The actual
* A) This badge is present in the Passport * cookie is only created if this badge is enabled. By default, this is done
* B) The remember_me key under your firewall is configured * by the {@see RememberMeConditionsListener} if all conditions are met.
* 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
* *
* @author Wouter de Jong <wouter@wouterj.nl> * @author Wouter de Jong <wouter@wouterj.nl>
* *
@ -30,6 +25,40 @@ namespace Symfony\Component\Security\Http\Authenticator\Passport\Badge;
*/ */
class RememberMeBadge implements BadgeInterface 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 public function isResolved(): bool
{ {
return true; // remember me does not need to be explicitly resolved return true; // remember me does not need to be explicitly resolved

View File

@ -11,22 +11,28 @@
namespace Symfony\Component\Security\Http\Authenticator; namespace Symfony\Component\Security\Http\Authenticator;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\RememberMeToken; use Symfony\Component\Security\Core\Authentication\Token\RememberMeToken;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException; 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\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface; use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; 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. * The RememberMe *Authenticator* performs remember me authentication.
* *
* This authenticator is executed whenever a user's session * 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 * then "re-authenticates" the user using the information in the
* cookie. * cookie.
* *
@ -37,17 +43,19 @@ use Symfony\Component\Security\Http\RememberMe\RememberMeServicesInterface;
*/ */
class RememberMeAuthenticator implements InteractiveAuthenticatorInterface class RememberMeAuthenticator implements InteractiveAuthenticatorInterface
{ {
private $rememberMeServices; private $rememberMeHandler;
private $secret; private $secret;
private $tokenStorage; 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->secret = $secret;
$this->tokenStorage = $tokenStorage; $this->tokenStorage = $tokenStorage;
$this->options = $options; $this->cookieName = $cookieName;
$this->logger = $logger;
} }
public function supports(Request $request): ?bool public function supports(Request $request): ?bool
@ -57,19 +65,17 @@ class RememberMeAuthenticator implements InteractiveAuthenticatorInterface
return false; return false;
} }
// if the attribute is set, this is a lazy firewall. The previous if (($cookie = $request->attributes->get(ResponseListener::COOKIE_ATTR_NAME)) && null === $cookie->getValue()) {
// support call already indicated support, so return null and avoid
// recreating the cookie
if ($request->attributes->has('_remember_me_token')) {
return null;
}
$token = $this->rememberMeServices->autoLogin($request);
if (null === $token) {
return false; return false;
} }
$request->attributes->set('_remember_me_token', $token); if (!$request->cookies->has($this->cookieName)) {
return false;
}
if (null !== $this->logger) {
$this->logger->debug('Remember-me cookie detected.');
}
// the `null` return value indicates that this authenticator supports lazy firewalls // the `null` return value indicates that this authenticator supports lazy firewalls
return null; return null;
@ -77,13 +83,16 @@ class RememberMeAuthenticator implements InteractiveAuthenticatorInterface
public function authenticate(Request $request): PassportInterface public function authenticate(Request $request): PassportInterface
{ {
$token = $request->attributes->get('_remember_me_token'); $rawCookie = $request->cookies->get($this->cookieName);
if (null === $token) { if (!$rawCookie) {
throw new \LogicException('No remember me token is set.'); throw new \LogicException('No remember-me cookie is found.');
} }
// @deprecated since 5.3, change to $token->getUserIdentifier() in 6.0 $rememberMeCookie = RememberMeDetails::fromRawCookie($rawCookie);
return new SelfValidatingPassport(new UserBadge(method_exists($token, 'getUserIdentifier') ? $token->getUserIdentifier() : $token->getUsername(), [$token, 'getUser']));
return new SelfValidatingPassport(new UserBadge($rememberMeCookie->getUserIdentifier(), function () use ($rememberMeCookie) {
return $this->rememberMeHandler->consumeRememberMeCookie($rememberMeCookie);
}));
} }
public function createAuthenticatedToken(PassportInterface $passport, string $firewallName): TokenInterface public function createAuthenticatedToken(PassportInterface $passport, string $firewallName): TokenInterface
@ -98,7 +107,15 @@ class RememberMeAuthenticator implements InteractiveAuthenticatorInterface
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response 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; return null;
} }

View File

@ -15,7 +15,11 @@ use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Contracts\EventDispatcher\Event; 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 <hamza.simperfit@gmail.com> * @author Hamza Amrouche <hamza.simperfit@gmail.com>
*/ */

View File

@ -0,0 +1,51 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Security\Http\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 <wouter@wouterj.nl>
*/
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;
}
}

View File

@ -0,0 +1,79 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Security\Http\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 <wouter@wouterj.nl>
*
* @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]];
}
}

View File

@ -16,15 +16,18 @@ use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\RememberMeBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\RememberMeBadge;
use Symfony\Component\Security\Http\Event\LoginFailureEvent; use Symfony\Component\Security\Http\Event\LoginFailureEvent;
use Symfony\Component\Security\Http\Event\LoginSuccessEvent; 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 * Upon login success or failure and support for remember me
* in the firewall and authenticator, this listener will create * in the firewall and authenticator, this listener will create
* a remember me cookie. * a remember-me cookie.
* Upon login failure, all remember me cookies are removed. * Upon login failure, all remember-me cookies are removed.
* *
* @author Wouter de Jong <wouter@wouterj.nl> * @author Wouter de Jong <wouter@wouterj.nl>
* *
@ -33,12 +36,12 @@ use Symfony\Component\Security\Http\RememberMe\RememberMeServicesInterface;
*/ */
class RememberMeListener implements EventSubscriberInterface class RememberMeListener implements EventSubscriberInterface
{ {
private $rememberMeServices; private $rememberMeHandler;
private $logger; 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; $this->logger = $logger;
} }
@ -53,27 +56,38 @@ class RememberMeListener implements EventSubscriberInterface
return; 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) { 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; 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 public static function getSubscribedEvents(): array
{ {
return [ return [
LoginSuccessEvent::class => 'onSuccessfulLogin', LoginSuccessEvent::class => ['onSuccessfulLogin', -64],
LoginFailureEvent::class => 'onFailedLogin', LoginFailureEvent::class => 'clearCookie',
LogoutEvent::class => 'clearCookie',
TokenDeauthenticatedEvent::class => 'clearCookie',
]; ];
} }
} }

View File

@ -31,6 +31,7 @@ use Symfony\Component\Security\Core\Exception\UserNotFoundException;
use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface; use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Http\Event\DeauthenticatedEvent; use Symfony\Component\Security\Http\Event\DeauthenticatedEvent;
use Symfony\Component\Security\Http\Event\TokenDeauthenticatedEvent;
use Symfony\Component\Security\Http\RememberMe\RememberMeServicesInterface; use Symfony\Component\Security\Http\RememberMe\RememberMeServicesInterface;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
@ -94,6 +95,8 @@ class ContextListener extends AbstractListener
$request = $event->getRequest(); $request = $event->getRequest();
$session = $request->hasPreviousSession() && $request->hasSession() ? $request->getSession() : null; $session = $request->hasPreviousSession() && $request->hasSession() ? $request->getSession() : null;
$request->attributes->set('_security_firewall_run', true);
if (null !== $session) { if (null !== $session) {
$usageIndexValue = $session instanceof Session ? $usageIndexReference = &$session->getUsageIndex() : 0; $usageIndexValue = $session instanceof Session ? $usageIndexReference = &$session->getUsageIndex() : 0;
$usageIndexReference = \PHP_INT_MIN; $usageIndexReference = \PHP_INT_MIN;
@ -128,10 +131,17 @@ class ContextListener extends AbstractListener
} }
if ($token instanceof TokenInterface) { if ($token instanceof TokenInterface) {
$originalToken = $token;
$token = $this->refreshUser($token); $token = $this->refreshUser($token);
if (!$token && $this->rememberMeServices) { if (!$token) {
$this->rememberMeServices->loginFail($request); if ($this->dispatcher) {
$this->dispatcher->dispatch(new TokenDeauthenticatedEvent($originalToken, $request));
}
if ($this->rememberMeServices) {
$this->rememberMeServices->loginFail($request);
}
} }
} elseif (null !== $token) { } elseif (null !== $token) {
if (null !== $this->logger) { if (null !== $this->logger) {
@ -159,11 +169,13 @@ class ContextListener extends AbstractListener
$request = $event->getRequest(); $request = $event->getRequest();
if (!$request->hasSession()) { if (!$request->hasSession() || !$request->attributes->get('_security_firewall_run', false)) {
return; return;
} }
$this->dispatcher->removeListener(KernelEvents::RESPONSE, [$this, 'onKernelResponse']); if ($this->dispatcher) {
$this->dispatcher->removeListener(KernelEvents::RESPONSE, [$this, 'onKernelResponse']);
}
$this->registered = false; $this->registered = false;
$session = $request->getSession(); $session = $request->getSession();
$sessionId = $session->getId(); $sessionId = $session->getId();
@ -260,7 +272,7 @@ class ContextListener extends AbstractListener
$this->logger->debug('Token was deauthenticated after trying to refresh it.'); $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); $this->dispatcher->dispatch(new DeauthenticatedEvent($token, $newToken), DeauthenticatedEvent::class);
} }

View File

@ -11,10 +11,12 @@
namespace Symfony\Component\Security\Http\LoginLink\Exception; namespace Symfony\Component\Security\Http\LoginLink\Exception;
use Symfony\Component\Security\Core\Signature\Exception\ExpiredSignatureException;
/** /**
* @author Ryan Weaver <ryan@symfonycasts.com> * @author Ryan Weaver <ryan@symfonycasts.com>
* @experimental in 5.3 * @experimental in 5.3
*/ */
class ExpiredLoginLinkException extends \Exception implements InvalidLoginLinkExceptionInterface class ExpiredLoginLinkException extends ExpiredSignatureException implements InvalidLoginLinkExceptionInterface
{ {
} }

View File

@ -15,6 +15,6 @@ namespace Symfony\Component\Security\Http\LoginLink\Exception;
* @author Ryan Weaver <ryan@symfonycasts.com> * @author Ryan Weaver <ryan@symfonycasts.com>
* @experimental in 5.3 * @experimental in 5.3
*/ */
class InvalidLoginLinkException extends \Exception implements InvalidLoginLinkExceptionInterface class InvalidLoginLinkException extends \RuntimeException implements InvalidLoginLinkExceptionInterface
{ {
} }

View File

@ -12,10 +12,12 @@
namespace Symfony\Component\Security\Http\LoginLink; namespace Symfony\Component\Security\Http\LoginLink;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Routing\RequestContext; use Symfony\Component\Routing\RequestContext;
use Symfony\Component\Security\Core\Exception\UserNotFoundException; 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\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface; use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Http\LoginLink\Exception\ExpiredLoginLinkException; use Symfony\Component\Security\Http\LoginLink\Exception\ExpiredLoginLinkException;
@ -29,25 +31,18 @@ final class LoginLinkHandler implements LoginLinkHandlerInterface
{ {
private $urlGenerator; private $urlGenerator;
private $userProvider; private $userProvider;
private $propertyAccessor;
private $signatureProperties;
private $secret;
private $options; 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->urlGenerator = $urlGenerator;
$this->userProvider = $userProvider; $this->userProvider = $userProvider;
$this->propertyAccessor = $propertyAccessor; $this->signatureHashUtil = $signatureHashUtil;
$this->signatureProperties = $signatureProperties;
$this->secret = $secret;
$this->options = array_merge([ $this->options = array_merge([
'route_name' => null, 'route_name' => null,
'lifetime' => 600, 'lifetime' => 600,
'max_uses' => null,
], $options); ], $options);
$this->expiredStorage = $expiredStorage;
} }
public function createLoginLink(UserInterface $user, Request $request = null): LoginLinkDetails public function createLoginLink(UserInterface $user, Request $request = null): LoginLinkDetails
@ -59,7 +54,7 @@ final class LoginLinkHandler implements LoginLinkHandlerInterface
// @deprecated since 5.3, change to $user->getUserIdentifier() in 6.0 // @deprecated since 5.3, change to $user->getUserIdentifier() in 6.0
'user' => method_exists($user, 'getUserIdentifier') ? $user->getUserIdentifier() : $user->getUsername(), 'user' => method_exists($user, 'getUserIdentifier') ? $user->getUserIdentifier() : $user->getUsername(),
'expires' => $expires, 'expires' => $expires,
'hash' => $this->computeSignatureHash($user, $expires), 'hash' => $this->signatureHashUtil->computeSignatureHash($user, $expires),
]; ];
if ($request) { if ($request) {
@ -105,43 +100,15 @@ final class LoginLinkHandler implements LoginLinkHandlerInterface
$hash = $request->get('hash'); $hash = $request->get('hash');
$expires = $request->get('expires'); $expires = $request->get('expires');
if (false === hash_equals($hash, $this->computeSignatureHash($user, $expires))) {
throw new InvalidLoginLinkException('Invalid or expired signature.');
}
if ($expires < time()) { try {
throw new ExpiredLoginLinkException('Login link has expired.'); $this->signatureHashUtil->verifySignatureHash($user, $expires, $hash);
} } catch (ExpiredSignatureException $e) {
throw new ExpiredLoginLinkException(ucfirst(str_ireplace('signature', 'login link', $e->getMessage())), 0, $e);
if ($this->expiredStorage && $this->options['max_uses']) { } catch (InvalidSignatureException $e) {
$hash = $request->get('hash'); throw new InvalidLoginLinkException(ucfirst(str_ireplace('signature', 'login link', $e->getMessage())), 0, $e);
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);
} }
return $user; 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));
}
} }

View File

@ -0,0 +1,131 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Security\Http\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 <wouter@wouterj.nl>
*
* @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']
));
}
}

View File

@ -0,0 +1,114 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Security\Http\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 <wouter@wouterj.nl>
*
* @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);
}
}

View File

@ -0,0 +1,87 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Security\Http\RememberMe;
use Symfony\Component\Security\Core\Authentication\RememberMe\PersistentToken;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
/**
* @author Wouter de Jong <wouter@wouterj.nl>
*
* @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]));
}
}

View File

@ -0,0 +1,56 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Security\Http\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 <wouter@wouterj.nl>
*
* @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;
}

View File

@ -24,6 +24,12 @@ use Symfony\Component\HttpKernel\KernelEvents;
*/ */
class ResponseListener implements EventSubscriberInterface 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) public function onKernelResponse(ResponseEvent $event)
{ {
if (!$event->isMainRequest()) { if (!$event->isMainRequest()) {
@ -33,8 +39,8 @@ class ResponseListener implements EventSubscriberInterface
$request = $event->getRequest(); $request = $event->getRequest();
$response = $event->getResponse(); $response = $event->getResponse();
if ($request->attributes->has(RememberMeServicesInterface::COOKIE_ATTR_NAME)) { if ($request->attributes->has(self::COOKIE_ATTR_NAME)) {
$response->headers->setCookie($request->attributes->get(RememberMeServicesInterface::COOKIE_ATTR_NAME)); $response->headers->setCookie($request->attributes->get(self::COOKIE_ATTR_NAME));
} }
} }

View File

@ -0,0 +1,73 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Security\Http\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 <wouter@wouterj.nl>
*
* @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);
}
}

View File

@ -12,74 +12,72 @@
namespace Symfony\Component\Security\Http\Tests\Authenticator; namespace Symfony\Component\Security\Http\Tests\Authenticator;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\Request; 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\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\Core\User\InMemoryUser;
use Symfony\Component\Security\Http\Authenticator\RememberMeAuthenticator; 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 class RememberMeAuthenticatorTest extends TestCase
{ {
private $rememberMeServices; private $rememberMeHandler;
private $tokenStorage; private $tokenStorage;
private $authenticator; private $authenticator;
private $request;
protected function setUp(): void protected function setUp(): void
{ {
$this->rememberMeServices = $this->createMock(RememberMeServicesInterface::class); $this->rememberMeHandler = $this->createMock(RememberMeHandlerInterface::class);
$this->tokenStorage = $this->createMock(TokenStorage::class); $this->tokenStorage = new TokenStorage();
$this->authenticator = new RememberMeAuthenticator($this->rememberMeServices, 's3cr3t', $this->tokenStorage, [ $this->authenticator = new RememberMeAuthenticator($this->rememberMeHandler, 's3cr3t', $this->tokenStorage, '_remember_me_cookie');
'name' => '_remember_me_cookie',
]);
$this->request = new Request();
} }
public function testSupportsTokenStorageWithToken() 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 * @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($request));
$this->assertSame($support, $this->authenticator->supports($this->request));
} }
public function provideSupportsData() public function provideSupportsData()
{ {
yield [null, false]; yield [Request::create('/'), false];
yield [$this->createMock(TokenInterface::class), null];
}
public function testConsecutiveSupportsCalls() $request = Request::create('/', 'GET', [], ['_remember_me_cookie' => 'rememberme']);
{ yield [$request, null];
$this->rememberMeServices->expects($this->once())->method('autoLogin')->with($this->request)->willReturn($this->createMock(TokenInterface::class));
$this->assertNull($this->authenticator->supports($this->request)); $request = Request::create('/', 'GET', [], ['_remember_me_cookie' => 'rememberme']);
$this->assertNull($this->authenticator->supports($this->request)); $request->attributes->set(ResponseListener::COOKIE_ATTR_NAME, new Cookie('_remember_me_cookie', null));
yield [$request, false];
} }
public function testAuthenticate() public function testAuthenticate()
{ {
$this->request->attributes->set('_remember_me_token', new RememberMeToken($user = new InMemoryUser('wouter', 'test'), 'main', 'secret')); $rememberMeDetails = new RememberMeDetails(InMemoryUser::class, 'wouter', 1, 'secret');
$passport = $this->authenticator->authenticate($this->request); $request = Request::create('/', 'GET', [], ['_remember_me_cookie' => $rememberMeDetails->toString()]);
$passport = $this->authenticator->authenticate($request);
$this->assertSame($user, $passport->getUser()); $this->rememberMeHandler->expects($this->once())->method('consumeRememberMeCookie')->with($this->callback(function ($arg) use ($rememberMeDetails) {
return $rememberMeDetails == $arg;
}));
$passport->getUser(); // trigger the user loader
} }
public function testAuthenticateWithoutToken() public function testAuthenticateWithoutToken()
{ {
$this->expectException(\LogicException::class); $this->expectException(\LogicException::class);
$this->authenticator->authenticate($this->request); $this->authenticator->authenticate(Request::create('/'));
} }
} }

View File

@ -0,0 +1,101 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Security\Http\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()]);
}
}

View File

@ -22,71 +22,66 @@ use Symfony\Component\Security\Http\Authenticator\Passport\Badge\RememberMeBadge
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface; use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; 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\Event\LoginSuccessEvent;
use Symfony\Component\Security\Http\EventListener\RememberMeListener; 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 class RememberMeListenerTest extends TestCase
{ {
private $rememberMeServices; private $rememberMeHandler;
private $listener; private $listener;
private $request; private $request;
private $response; private $response;
private $token;
protected function setUp(): void protected function setUp(): void
{ {
$this->rememberMeServices = $this->createMock(RememberMeServicesInterface::class); $this->rememberMeHandler = $this->createMock(RememberMeHandlerInterface::class);
$this->listener = new RememberMeListener($this->rememberMeServices); $this->listener = new RememberMeListener($this->rememberMeHandler);
$this->request = $this->createMock(Request::class); $this->request = Request::create('/login');
$this->response = $this->createMock(Response::class); $this->request->request->set('_remember_me', true);
$this->token = $this->createMock(TokenInterface::class); $this->response = new Response();
} }
public function testSuccessfulLoginWithoutSupportingAuthenticator() 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); $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); $event = $this->createLoginSuccessfulEvent($this->createPassport([new RememberMeBadge()]));
$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);
$this->listener->onSuccessfulLogin($event); $this->listener->onSuccessfulLogin($event);
} }
public function testCredentialsInvalid() 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->clearCookie();
$this->listener->onFailedLogin($event);
} }
private function createLoginSuccessfulEvent($firewallName, $response, PassportInterface $passport = null) private function createLoginSuccessfulEvent(PassportInterface $passport = null)
{ {
if (null === $passport) { 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);
} }
} }

View File

@ -106,6 +106,7 @@ class ContextListenerTest extends TestCase
$tokenStorage = new TokenStorage(); $tokenStorage = new TokenStorage();
$tokenStorage->setToken(new UsernamePasswordToken('test1', 'pass1', 'phpunit')); $tokenStorage->setToken(new UsernamePasswordToken('test1', 'pass1', 'phpunit'));
$request = new Request(); $request = new Request();
$request->attributes->set('_security_firewall_run', true);
$session = new Session(new MockArraySessionStorage()); $session = new Session(new MockArraySessionStorage());
$request->setSession($session); $request->setSession($session);
@ -148,22 +149,18 @@ class ContextListenerTest extends TestCase
{ {
$tokenStorage = $this->createMock(TokenStorageInterface::class); $tokenStorage = $this->createMock(TokenStorageInterface::class);
$event = $this->createMock(RequestEvent::class); $event = $this->createMock(RequestEvent::class);
$request = $this->createMock(Request::class);
$session = $this->createMock(SessionInterface::class); $session = $this->createMock(SessionInterface::class);
$session->expects($this->any())->method('getName')->willReturn('SESSIONNAME');
$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()) $session->expects($this->any())
->method('get') ->method('get')
->with('_security_key123') ->with('_security_key123')
->willReturn($token); ->willReturn($token);
$request = new Request([], [], [], ['SESSIONNAME' => true]);
$request->setSession($session);
$event->expects($this->any())
->method('getRequest')
->willReturn($request);
$tokenStorage->expects($this->once()) $tokenStorage->expects($this->once())
->method('setToken') ->method('setToken')
->with(null); ->with(null);
@ -196,7 +193,7 @@ class ContextListenerTest extends TestCase
->willReturn(true); ->willReturn(true);
$event->expects($this->any()) $event->expects($this->any())
->method('getRequest') ->method('getRequest')
->willReturn($this->createMock(Request::class)); ->willReturn(new Request());
$dispatcher->expects($this->once()) $dispatcher->expects($this->once())
->method('addListener') ->method('addListener')
@ -208,18 +205,15 @@ class ContextListenerTest extends TestCase
public function testOnKernelResponseListenerRemovesItself() public function testOnKernelResponseListenerRemovesItself()
{ {
$session = $this->createMock(SessionInterface::class); $session = $this->createMock(SessionInterface::class);
$session->expects($this->any())->method('getName')->willReturn('SESSIONNAME');
$tokenStorage = $this->createMock(TokenStorageInterface::class); $tokenStorage = $this->createMock(TokenStorageInterface::class);
$dispatcher = $this->createMock(EventDispatcherInterface::class); $dispatcher = $this->createMock(EventDispatcherInterface::class);
$listener = new ContextListener($tokenStorage, [], 'key123', null, $dispatcher); $listener = new ContextListener($tokenStorage, [], 'key123', null, $dispatcher);
$request = $this->createMock(Request::class); $request = new Request();
$request->expects($this->any()) $request->attributes->set('_security_firewall_run', true);
->method('hasSession') $request->setSession($session);
->willReturn(true);
$request->expects($this->any())
->method('getSession')
->willReturn($session);
$event = new ResponseEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST, new Response()); $event = new ResponseEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST, new Response());
@ -232,8 +226,7 @@ class ContextListenerTest extends TestCase
public function testHandleRemovesTokenIfNoPreviousSessionWasFound() public function testHandleRemovesTokenIfNoPreviousSessionWasFound()
{ {
$request = $this->createMock(Request::class); $request = new Request();
$request->expects($this->any())->method('hasPreviousSession')->willReturn(false);
$event = $this->createMock(RequestEvent::class); $event = $this->createMock(RequestEvent::class);
$event->expects($this->any())->method('getRequest')->willReturn($request); $event->expects($this->any())->method('getRequest')->willReturn($request);
@ -377,6 +370,7 @@ class ContextListenerTest extends TestCase
{ {
$session = new Session(new MockArraySessionStorage()); $session = new Session(new MockArraySessionStorage());
$request = new Request(); $request = new Request();
$request->attributes->set('_security_firewall_run', true);
$request->setSession($session); $request->setSession($session);
$requestStack = new RequestStack(); $requestStack = new RequestStack();
$requestStack->push($request); $requestStack->push($request);

View File

@ -13,17 +13,20 @@ namespace Symfony\Component\Security\Http\Tests\LoginLink;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyAccess\PropertyAccess;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Routing\RequestContext; use Symfony\Component\Routing\RequestContext;
use Symfony\Component\Security\Core\Exception\UserNotFoundException; 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\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface; use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Http\LoginLink\Exception\ExpiredLoginLinkException; use Symfony\Component\Security\Http\LoginLink\Exception\ExpiredLoginLinkException;
use Symfony\Component\Security\Http\LoginLink\Exception\InvalidLoginLinkException; use Symfony\Component\Security\Http\LoginLink\Exception\InvalidLoginLinkException;
use Symfony\Component\Security\Http\LoginLink\ExpiredLoginLinkStorage;
use Symfony\Component\Security\Http\LoginLink\LoginLinkHandler; use Symfony\Component\Security\Http\LoginLink\LoginLinkHandler;
class LoginLinkHandlerTest extends TestCase class LoginLinkHandlerTest extends TestCase
@ -34,15 +37,18 @@ class LoginLinkHandlerTest extends TestCase
private $userProvider; private $userProvider;
/** @var PropertyAccessorInterface */ /** @var PropertyAccessorInterface */
private $propertyAccessor; private $propertyAccessor;
/** @var MockObject|ExpiredLoginLinkStorage */ /** @var MockObject|ExpiredSignatureStorage */
private $expiredLinkStorage; private $expiredLinkStorage;
/** @var CacheItemPoolInterface */
private $expiredLinkCache;
protected function setUp(): void protected function setUp(): void
{ {
$this->router = $this->createMock(UrlGeneratorInterface::class); $this->router = $this->createMock(UrlGeneratorInterface::class);
$this->userProvider = new TestLoginLinkHandlerUserProvider(); $this->userProvider = new TestLoginLinkHandlerUserProvider();
$this->propertyAccessor = PropertyAccess::createPropertyAccessor(); $this->propertyAccessor = PropertyAccess::createPropertyAccessor();
$this->expiredLinkStorage = $this->createMock(ExpiredLoginLinkStorage::class); $this->expiredLinkCache = new ArrayAdapter();
$this->expiredLinkStorage = new ExpiredSignatureStorage($this->expiredLinkCache, 360);
} }
/** /**
@ -118,13 +124,12 @@ class LoginLinkHandlerTest extends TestCase
$user = new TestLoginLinkHandlerUser('weaverryan', 'ryan@symfonycasts.com', 'pwhash'); $user = new TestLoginLinkHandlerUser('weaverryan', 'ryan@symfonycasts.com', 'pwhash');
$this->userProvider->createUser($user); $this->userProvider->createUser($user);
$this->expiredLinkStorage->expects($this->once())
->method('incrementUsages')
->with($signature);
$linker = $this->createLinker(['max_uses' => 3]); $linker = $this->createLinker(['max_uses' => 3]);
$actualUser = $linker->consumeLoginLink($request); $actualUser = $linker->consumeLoginLink($request);
$this->assertEquals($user, $actualUser); $this->assertEquals($user, $actualUser);
$item = $this->expiredLinkCache->getItem(rawurlencode($signature));
$this->assertSame(1, $item->get());
} }
public function testConsumeLoginLinkWithExpired() public function testConsumeLoginLinkWithExpired()
@ -172,10 +177,9 @@ class LoginLinkHandlerTest extends TestCase
$user = new TestLoginLinkHandlerUser('weaverryan', 'ryan@symfonycasts.com', 'pwhash'); $user = new TestLoginLinkHandlerUser('weaverryan', 'ryan@symfonycasts.com', 'pwhash');
$this->userProvider->createUser($user); $this->userProvider->createUser($user);
$this->expiredLinkStorage->expects($this->once()) $item = $this->expiredLinkCache->getItem(rawurlencode($signature));
->method('countUsages') $item->set(3);
->with($signature) $this->expiredLinkCache->save($item);
->willReturn(3);
$linker = $this->createLinker(['max_uses' => 3]); $linker = $this->createLinker(['max_uses' => 3]);
$linker->consumeLoginLink($request); $linker->consumeLoginLink($request);
@ -199,7 +203,7 @@ class LoginLinkHandlerTest extends TestCase
'route_name' => 'app_check_login_link_route', 'route_name' => 'app_check_login_link_route',
], $options); ], $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 public function createUser(TestLoginLinkHandlerUser $user): void
{ {
$this->users[$user->getUsername()] = $user; $this->users[$user->getUserIdentifier()] = $user;
} }
public function loadUserByIdentifier(string $userIdentifier): TestLoginLinkHandlerUser public function loadUserByIdentifier(string $userIdentifier): TestLoginLinkHandlerUser

View File

@ -0,0 +1,127 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Security\Http\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'));
}
}

View File

@ -0,0 +1,125 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Security\Http\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'));
}
}