[Security] Rework the remember me system

This commit is contained in:
Wouter de Jong 2021-01-17 20:20:33 +01:00 committed by Robin Chalas
parent 0f96ac7484
commit 15670419d4
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\Driver\Result as DriverResult;
use Doctrine\DBAL\Result;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Types\Types;
use Symfony\Component\Security\Core\Authentication\RememberMe\PersistentToken;
use Symfony\Component\Security\Core\Authentication\RememberMe\PersistentTokenInterface;
@ -21,7 +22,7 @@ use Symfony\Component\Security\Core\Authentication\RememberMe\TokenProviderInter
use Symfony\Component\Security\Core\Exception\TokenNotFoundException;
/**
* This class provides storage for the tokens that is set in "remember me"
* This class provides storage for the tokens that is set in "remember-me"
* cookies. This way no password secrets will be stored in the cookies on
* the client machine, and thus the security is improved.
*
@ -53,8 +54,7 @@ class DoctrineTokenProvider implements TokenProviderInterface
public function loadTokenBySeries(string $series)
{
// the alias for lastUsed works around case insensitivity in PostgreSQL
$sql = 'SELECT class, username, value, lastUsed AS last_used'
.' FROM rememberme_token WHERE series=:series';
$sql = 'SELECT class, username, value, lastUsed AS last_used FROM rememberme_token WHERE series=:series';
$paramValues = ['series' => $series];
$paramTypes = ['series' => \PDO::PARAM_STR];
$stmt = $this->conn->executeQuery($sql, $paramValues, $paramTypes);
@ -87,8 +87,7 @@ class DoctrineTokenProvider implements TokenProviderInterface
*/
public function updateToken(string $series, string $tokenValue, \DateTime $lastUsed)
{
$sql = 'UPDATE rememberme_token SET value=:value, lastUsed=:lastUsed'
.' WHERE series=:series';
$sql = 'UPDATE rememberme_token SET value=:value, lastUsed=:lastUsed WHERE series=:series';
$paramValues = [
'value' => $tokenValue,
'lastUsed' => $lastUsed,
@ -114,9 +113,7 @@ class DoctrineTokenProvider implements TokenProviderInterface
*/
public function createNewToken(PersistentTokenInterface $token)
{
$sql = 'INSERT INTO rememberme_token'
.' (class, username, series, value, lastUsed)'
.' VALUES (:class, :username, :series, :value, :lastUsed)';
$sql = 'INSERT INTO rememberme_token (class, username, series, value, lastUsed) VALUES (:class, :username, :series, :value, :lastUsed)';
$paramValues = [
'class' => $token->getClass(),
// @deprecated since 5.3, change to $token->getUserIdentifier() in 6.0
@ -138,4 +135,32 @@ class DoctrineTokenProvider implements TokenProviderInterface
$this->conn->executeUpdate($sql, $paramValues, $paramTypes);
}
}
/**
* Adds the Table to the Schema if "remember me" uses this Connection.
*/
public function configureSchema(Schema $schema, Connection $forConnection): void
{
// only update the schema for this connection
if ($forConnection !== $this->conn) {
return;
}
if ($schema->hasTable('rememberme_token')) {
return;
}
$this->addTableToSchema($schema);
}
private function addTableToSchema(Schema $schema): void
{
$table = $schema->createTable('rememberme_token');
$table->addColumn('series', Types::STRING, ['length' => 88]);
$table->addColumn('value', Types::STRING, ['length' => 88]);
$table->addColumn('lastUsed', Types::DATETIME_MUTABLE);
$table->addColumn('class', Types::STRING, ['length' => 100]);
$table->addColumn('username', Types::STRING, ['length' => 200]);
$table->setPrimaryKey(['series']);
}
}

View File

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

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

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']);
}
$signatureHasherId = 'security.authenticator.login_link_signature_hasher.'.$firewallName;
$container
->setDefinition($signatureHasherId, new ChildDefinition('security.authenticator.abstract_login_link_signature_hasher'))
->replaceArgument(1, $config['signature_properties'])
->replaceArgument(3, $expiredStorageId ? new Reference($expiredStorageId) : null)
->replaceArgument(4, $config['max_uses'] ?? null)
;
$linkerId = 'security.authenticator.login_link_handler.'.$firewallName;
$linkerOptions = [
'route_name' => $config['check_route'],
'lifetime' => $config['lifetime'],
'max_uses' => $config['max_uses'] ?? null,
];
$container
->setDefinition($linkerId, new ChildDefinition('security.authenticator.abstract_login_link_handler'))
->replaceArgument(1, new Reference($userProviderId))
->replaceArgument(3, $config['signature_properties'])
->replaceArgument(5, $linkerOptions)
->replaceArgument(6, $expiredStorageId ? new Reference($expiredStorageId) : null)
->replaceArgument(2, new Reference($signatureHasherId))
->replaceArgument(3, $linkerOptions)
->addTag('security.authenticator.login_linker', ['firewall' => $firewallName])
;

View File

@ -11,11 +11,16 @@
namespace Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory;
use Symfony\Bridge\Doctrine\Security\RememberMe\DoctrineTokenProvider;
use Symfony\Bundle\SecurityBundle\RememberMe\DecoratedRememberMeHandler;
use Symfony\Component\Config\Definition\Builder\NodeDefinition;
use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\Argument\IteratorArgument;
use Symfony\Component\DependencyInjection\ChildDefinition;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\Security\Http\EventListener\RememberMeLogoutListener;
@ -94,31 +99,66 @@ class RememberMeFactory implements SecurityFactoryInterface, AuthenticatorFactor
public function createAuthenticator(ContainerBuilder $container, string $firewallName, array $config, string $userProviderId): string
{
$templateId = $this->generateRememberMeServicesTemplateId($config, $firewallName);
$rememberMeServicesId = $templateId.'.'.$firewallName;
if (!$container->hasDefinition('security.authenticator.remember_me')) {
$loader = new PhpFileLoader($container, new FileLocator(\dirname(__DIR__).'/../../Resources/config'));
$loader->load('security_authenticator_remember_me.php');
}
// create remember me services (which manage the remember me cookies)
$this->createRememberMeServices($container, $firewallName, $templateId, [new Reference($userProviderId)], $config);
// create remember me handler (which manage the remember-me cookies)
$rememberMeHandlerId = 'security.authenticator.remember_me_handler.'.$firewallName;
if (isset($config['service']) && isset($config['token_provider'])) {
throw new InvalidConfigurationException(sprintf('You cannot use both "service" and "token_provider" in "security.firewalls.%s.remember_me".', $firewallName));
}
if (isset($config['service'])) {
$container->register($rememberMeHandlerId, DecoratedRememberMeHandler::class)
->addArgument(new Reference($config['service']))
->addTag('security.remember_me_handler', ['firewall' => $firewallName]);
} elseif (isset($config['token_provider'])) {
$tokenProviderId = $this->createTokenProvider($container, $firewallName, $config['token_provider']);
$container->setDefinition($rememberMeHandlerId, new ChildDefinition('security.authenticator.persistent_remember_me_handler'))
->replaceArgument(0, new Reference($tokenProviderId))
->replaceArgument(2, new Reference($userProviderId))
->replaceArgument(4, $config)
->addTag('security.remember_me_handler', ['firewall' => $firewallName]);
} else {
$signatureHasherId = 'security.authenticator.remember_me_signature_hasher.'.$firewallName;
$container->setDefinition($signatureHasherId, new ChildDefinition('security.authenticator.remember_me_signature_hasher'))
->replaceArgument(1, $config['signature_properties'])
;
$container->setDefinition($rememberMeHandlerId, new ChildDefinition('security.authenticator.signature_remember_me_handler'))
->replaceArgument(0, new Reference($signatureHasherId))
->replaceArgument(1, new Reference($userProviderId))
->replaceArgument(3, $config)
->addTag('security.remember_me_handler', ['firewall' => $firewallName]);
}
// create check remember me conditions listener (which checks if a remember-me cookie is supported and requested)
$rememberMeConditionsListenerId = 'security.listener.check_remember_me_conditions.'.$firewallName;
$container->setDefinition($rememberMeConditionsListenerId, new ChildDefinition('security.listener.check_remember_me_conditions'))
->replaceArgument(0, array_intersect_key($config, ['always_remember_me' => true, 'remember_me_parameter' => true]))
->addTag('kernel.event_subscriber', ['dispatcher' => 'security.event_dispatcher.'.$firewallName])
;
// create remember me listener (which executes the remember me services for other authenticators and logout)
$this->createRememberMeListener($container, $firewallName, $rememberMeServicesId);
$rememberMeListenerId = 'security.listener.remember_me.'.$firewallName;
$container->setDefinition($rememberMeListenerId, new ChildDefinition('security.listener.remember_me'))
->replaceArgument(0, new Reference($rememberMeHandlerId))
->addTag('kernel.event_subscriber', ['dispatcher' => 'security.event_dispatcher.'.$firewallName])
;
// create remember me authenticator (which re-authenticates the user based on the remember me cookie)
// create remember me authenticator (which re-authenticates the user based on the remember-me cookie)
$authenticatorId = 'security.authenticator.remember_me.'.$firewallName;
$container
->setDefinition($authenticatorId, new ChildDefinition('security.authenticator.remember_me'))
->replaceArgument(0, new Reference($rememberMeServicesId))
->replaceArgument(3, $container->getDefinition($rememberMeServicesId)->getArgument(3))
->replaceArgument(0, new Reference($rememberMeHandlerId))
->replaceArgument(3, $config['name'] ?? $this->options['name'])
;
foreach ($container->findTaggedServiceIds('security.remember_me_aware') as $serviceId => $attributes) {
// register ContextListener
if ('security.context_listener' === substr($serviceId, 0, 25)) {
$container
->getDefinition($serviceId)
->addMethodCall('setRememberMeServices', [new Reference($rememberMeServicesId)])
;
continue;
}
@ -148,7 +188,6 @@ class RememberMeFactory implements SecurityFactoryInterface, AuthenticatorFactor
$builder
->scalarNode('secret')->isRequired()->cannotBeEmpty()->end()
->scalarNode('service')->end()
->scalarNode('token_provider')->end()
->arrayNode('user_providers')
->beforeNormalization()
->ifString()->then(function ($v) { return [$v]; })
@ -156,7 +195,26 @@ class RememberMeFactory implements SecurityFactoryInterface, AuthenticatorFactor
->prototype('scalar')->end()
->end()
->booleanNode('catch_exceptions')->defaultTrue()->end()
;
->arrayNode('signature_properties')
->prototype('scalar')->end()
->requiresAtLeastOneElement()
->info('An array of properties on your User that are used to sign the remember-me cookie. If any of these change, all existing cookies will become invalid.')
->example(['email', 'password'])
->end()
->arrayNode('token_provider')
->beforeNormalization()
->ifString()->then(function ($v) { return ['service' => $v]; })
->end()
->children()
->scalarNode('service')->info('The service ID of a custom rememberme token provider.')->end()
->arrayNode('doctrine')
->canBeEnabled()
->children()
->scalarNode('connection')->defaultNull()->end()
->end()
->end()
->end()
->end();
foreach ($this->options as $name => $value) {
if ('secure' === $name) {
@ -195,9 +253,8 @@ class RememberMeFactory implements SecurityFactoryInterface, AuthenticatorFactor
$rememberMeServices->replaceArgument(2, $id);
if (isset($config['token_provider'])) {
$rememberMeServices->addMethodCall('setTokenProvider', [
new Reference($config['token_provider']),
]);
$tokenProviderId = $this->createTokenProvider($container, $id, $config['token_provider']);
$rememberMeServices->addMethodCall('setTokenProvider', [new Reference($tokenProviderId)]);
}
// remember-me options
@ -222,17 +279,29 @@ class RememberMeFactory implements SecurityFactoryInterface, AuthenticatorFactor
$rememberMeServices->replaceArgument(0, new IteratorArgument(array_unique($userProviders)));
}
private function createRememberMeListener(ContainerBuilder $container, string $id, string $rememberMeServicesId): void
private function createTokenProvider(ContainerBuilder $container, string $firewallName, array $config): string
{
$container
->setDefinition('security.listener.remember_me.'.$id, new ChildDefinition('security.listener.remember_me'))
->addTag('kernel.event_subscriber', ['dispatcher' => 'security.event_dispatcher.'.$id])
->replaceArgument(0, new Reference($rememberMeServicesId))
;
$tokenProviderId = $config['service'] ?? false;
if ($config['doctrine']['enabled'] ?? false) {
if (!class_exists(DoctrineTokenProvider::class)) {
throw new InvalidConfigurationException('Cannot use the "doctrine" token provider for "remember_me" because the Doctrine Bridge is not installed. Try running "composer require symfony/doctrine-bridge".');
}
$container
->setDefinition('security.logout.listener.remember_me.'.$id, new Definition(RememberMeLogoutListener::class))
->addTag('kernel.event_subscriber', ['dispatcher' => 'security.event_dispatcher.'.$id])
->addArgument(new Reference($rememberMeServicesId));
if (null === $config['doctrine']['connection']) {
$connectionId = 'database_connection';
} else {
$connectionId = 'doctrine.dbal.'.$config['doctrine']['connection'].'_connection';
}
$tokenProviderId = 'security.remember_me.doctrine_token_provider.'.$firewallName;
$container->register($tokenProviderId, DoctrineTokenProvider::class)
->addArgument(new Reference($connectionId));
}
if (!$tokenProviderId) {
throw new InvalidConfigurationException(sprintf('No token provider was set for firewall "%s". Either configure a service ID or set "remember_me.token_provider.doctrine" to true.', $firewallName));
}
return $tokenProviderId;
}
}

View File

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

View File

@ -12,6 +12,7 @@
namespace Symfony\Bundle\SecurityBundle\LoginLink;
use Psr\Container\ContainerInterface;
use Symfony\Bundle\SecurityBundle\Security\FirewallAwareTrait;
use Symfony\Bundle\SecurityBundle\Security\FirewallMap;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
@ -26,43 +27,24 @@ use Symfony\Component\Security\Http\LoginLink\LoginLinkHandlerInterface;
*/
class FirewallAwareLoginLinkHandler implements LoginLinkHandlerInterface
{
private $firewallMap;
private $loginLinkHandlerLocator;
private $requestStack;
use FirewallAwareTrait;
private const FIREWALL_OPTION = 'login_link';
public function __construct(FirewallMap $firewallMap, ContainerInterface $loginLinkHandlerLocator, RequestStack $requestStack)
{
$this->firewallMap = $firewallMap;
$this->loginLinkHandlerLocator = $loginLinkHandlerLocator;
$this->locator = $loginLinkHandlerLocator;
$this->requestStack = $requestStack;
}
public function createLoginLink(UserInterface $user, Request $request = null): LoginLinkDetails
{
return $this->getLoginLinkHandler()->createLoginLink($user, $request);
return $this->getForFirewall()->createLoginLink($user, $request);
}
public function consumeLoginLink(Request $request): UserInterface
{
return $this->getLoginLinkHandler()->consumeLoginLink($request);
}
private function getLoginLinkHandler(): LoginLinkHandlerInterface
{
if (null === $request = $this->requestStack->getCurrentRequest()) {
throw new \LogicException('Cannot determine the correct LoginLinkHandler to use: there is no active Request and so, the firewall cannot be determined. Try using the specific login link handler service.');
}
$firewall = $this->firewallMap->getFirewallConfig($request);
if (!$firewall) {
throw new \LogicException('No login link handler found as the current route is not covered by a firewall.');
}
$firewallName = $firewall->getName();
if (!$this->loginLinkHandlerLocator->has($firewallName)) {
throw new \LogicException(sprintf('No login link handler found. Did you add a login_link key under your "%s" firewall?', $firewallName));
}
return $this->loginLinkHandlerLocator->get($firewallName);
return $this->getForFirewall()->consumeLoginLink($request);
}
}

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 name="remember_me">
<xsd:choice minOccurs="0" maxOccurs="unbounded">
<xsd:element name="user-provider" type="xsd:string" />
</xsd:choice>
<xsd:sequence minOccurs="0">
<xsd:choice minOccurs="0" maxOccurs="unbounded">
<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="lifetime" type="xsd:integer" />
<xsd:attribute name="path" type="xsd:string" />
@ -352,6 +355,18 @@
<xsd:attribute name="samesite" type="remember_me_samesite" />
</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:restriction base="xsd:string">
<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\HttpBasicAuthenticator;
use Symfony\Component\Security\Http\Authenticator\JsonLoginAuthenticator;
use Symfony\Component\Security\Http\Authenticator\RememberMeAuthenticator;
use Symfony\Component\Security\Http\Authenticator\RemoteUserAuthenticator;
use Symfony\Component\Security\Http\Authenticator\X509Authenticator;
use Symfony\Component\Security\Http\Event\CheckPassportEvent;
use Symfony\Component\Security\Http\EventListener\CheckCredentialsListener;
use Symfony\Component\Security\Http\EventListener\LoginThrottlingListener;
use Symfony\Component\Security\Http\EventListener\PasswordMigratingListener;
use Symfony\Component\Security\Http\EventListener\RememberMeListener;
use Symfony\Component\Security\Http\EventListener\SessionStrategyListener;
use Symfony\Component\Security\Http\EventListener\UserCheckerListener;
use Symfony\Component\Security\Http\EventListener\UserProviderListener;
@ -107,14 +105,6 @@ return static function (ContainerConfigurator $container) {
service('security.authentication.session_strategy'),
])
->set('security.listener.remember_me', RememberMeListener::class)
->abstract()
->args([
abstract_arg('remember me services'),
service('logger')->nullOnInvalid(),
])
->tag('monolog.logger', ['channel' => 'security'])
->set('security.listener.login_throttling', LoginThrottlingListener::class)
->abstract()
->args([
@ -154,16 +144,6 @@ return static function (ContainerConfigurator $container) {
])
->call('setTranslator', [service('translator')->ignoreOnInvalid()])
->set('security.authenticator.remember_me', RememberMeAuthenticator::class)
->abstract()
->args([
abstract_arg('remember me services'),
param('kernel.secret'),
service('security.token_storage'),
abstract_arg('options'),
service('security.authentication.session_strategy'),
])
->set('security.authenticator.x509', X509Authenticator::class)
->abstract()
->args([

View File

@ -12,8 +12,9 @@
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
use Symfony\Bundle\SecurityBundle\LoginLink\FirewallAwareLoginLinkHandler;
use Symfony\Component\Security\Core\Signature\ExpiredSignatureStorage;
use Symfony\Component\Security\Core\Signature\SignatureHasher;
use Symfony\Component\Security\Http\Authenticator\LoginLinkAuthenticator;
use Symfony\Component\Security\Http\LoginLink\ExpiredLoginLinkStorage;
use Symfony\Component\Security\Http\LoginLink\LoginLinkHandler;
use Symfony\Component\Security\Http\LoginLink\LoginLinkHandlerInterface;
@ -34,14 +35,20 @@ return static function (ContainerConfigurator $container) {
->args([
service('router'),
abstract_arg('user provider'),
abstract_arg('signature hasher'),
abstract_arg('options'),
])
->set('security.authenticator.abstract_login_link_signature_hasher', SignatureHasher::class)
->args([
service('property_accessor'),
abstract_arg('signature properties'),
'%kernel.secret%',
abstract_arg('options'),
abstract_arg('expired login link storage'),
abstract_arg('expired signature storage'),
abstract_arg('max signature uses'),
])
->set('security.authenticator.expired_login_link_storage', ExpiredLoginLinkStorage::class)
->set('security.authenticator.expired_login_link_storage', ExpiredSignatureStorage::class)
->abstract()
->args([
abstract_arg('cache pool service'),

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\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Exception\LogicException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Http\Authentication\UserAuthenticatorInterface;
use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\BadgeInterface;
/**
* A decorator that delegates all method calls to the authenticator
@ -32,14 +30,12 @@ use Symfony\Component\Security\Http\Authenticator\Passport\Badge\BadgeInterface;
*/
class UserAuthenticator implements UserAuthenticatorInterface
{
private $firewallMap;
private $userAuthenticators;
private $requestStack;
use FirewallAwareTrait;
public function __construct(FirewallMap $firewallMap, ContainerInterface $userAuthenticators, RequestStack $requestStack)
{
$this->firewallMap = $firewallMap;
$this->userAuthenticators = $userAuthenticators;
$this->locator = $userAuthenticators;
$this->requestStack = $requestStack;
}
@ -48,16 +44,6 @@ class UserAuthenticator implements UserAuthenticatorInterface
*/
public function authenticateUser(UserInterface $user, AuthenticatorInterface $authenticator, Request $request, array $badges = []): ?Response
{
return $this->getUserAuthenticator()->authenticateUser($user, $authenticator, $request, $badges);
}
private function getUserAuthenticator(): UserAuthenticatorInterface
{
$firewallConfig = $this->firewallMap->getFirewallConfig($this->requestStack->getMainRequest());
if (null === $firewallConfig) {
throw new LogicException('Cannot call authenticate on this request, as it is not behind a firewall.');
}
return $this->userAuthenticators->get($firewallConfig->getName());
return $this->getForFirewall()->authenticateUser($user, $authenticator, $request, $badges);
}
}

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

View File

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

View File

@ -388,6 +388,27 @@ class SecurityExtensionTest extends TestCase
$this->assertEquals($secure, $definition->getArgument(3)['secure']);
}
public function testCustomRememberMeHandler()
{
$container = $this->getRawContainer();
$container->register('custom_remember_me', \stdClass::class);
$container->loadFromExtension('security', [
'enable_authenticator_manager' => true,
'firewalls' => [
'default' => [
'remember_me' => ['secret' => 'very', 'service' => 'custom_remember_me'],
],
],
]);
$container->compile();
$handler = $container->getDefinition('security.authenticator.remember_me_handler.default');
$this->assertEquals(\stdClass::class, $handler->getClass());
$this->assertEquals([['firewall' => 'default']], $handler->getTag('security.remember_me_handler'));
}
public function sessionConfigurationProvider()
{
return [
@ -661,13 +682,13 @@ class SecurityExtensionTest extends TestCase
$security = new SecurityExtension();
$container->registerExtension($security);
$bundle = new SecurityBundle();
$bundle->build($container);
$container->getCompilerPassConfig()->setOptimizationPasses([new ResolveChildDefinitionsPass()]);
$container->getCompilerPassConfig()->setRemovingPasses([]);
$container->getCompilerPassConfig()->setAfterRemovingPasses([]);
$bundle = new SecurityBundle();
$bundle->build($container);
return $container;
}

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.
*/
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
use Symfony\Bundle\SecurityBundle\SecurityBundle;
namespace Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\RememberMeBundle;
return [
new FrameworkBundle(),
new SecurityBundle(),
];
use Symfony\Component\HttpKernel\Bundle\Bundle;
class RememberMeBundle extends Bundle
{
}

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
{
/**
* @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
*/

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

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\SecurityBundle\SecurityBundle;
use Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\TestBundle;
use Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\RememberMeBundle\RememberMeBundle;
return [
new FrameworkBundle(),
new SecurityBundle(),
new TestBundle(),
new RememberMeBundle(),
];

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

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
---
* Add `RememberMeConditionsListener` to check if remember me is requested and supported, and set priority of `RememberMeListener` to -63
* Add `RememberMeHandlerInterface` and implementations, used as a replacement of `RememberMeServicesInterface` when using the AuthenticatorManager
* Add `TokenDeauthenticatedEvent` that is dispatched when the current security token is deauthenticated
* [BC break] Change constructor signature of `LoginLinkHandler` to `__construct(UrlGeneratorInterface $urlGenerator, UserProviderInterface $userProvider, SignatureHasher $signatureHashUtil, array $options)`
* Add `Core\Signature\SignatureHasher` and moved `Http\LoginLink\ExpiredLoginLinkStorage` to `Core\Signature\ExpiredLoginLinkStorage`
* Deprecate `PersistentTokenInterface::getUsername()` in favor of `PersistentTokenInterface::getUserIdentifier()`
* Deprecate `UsernameNotFoundException` in favor of `UserNotFoundException` and `getUsername()`/`setUsername()` in favor of `getUserIdentifier()`/`setUserIdentifier()`
* Deprecate `UserProviderInterface::loadUserByUsername()` in favor of `UserProviderInterface::loadUserByIdentifier()`

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.
*/
namespace Symfony\Component\Security\Http\LoginLink;
namespace Symfony\Component\Security\Core\Signature;
use Psr\Cache\CacheItemPoolInterface;
/**
* @author Ryan Weaver <ryan@symfonycasts.com>
*
* @experimental in 5.2
*
* @final
*/
class ExpiredLoginLinkStorage
final class ExpiredSignatureStorage
{
private $cache;
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.
*/
namespace Symfony\Component\Security\Http\Tests\LoginLink;
namespace Symfony\Component\Security\Core\Tests\Signature;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\Security\Http\LoginLink\ExpiredLoginLinkStorage;
use Symfony\Component\Security\Core\Signature\ExpiredSignatureStorage;
class ExpiredLoginLinkStorageTest extends TestCase
class ExpiredSignatureStorageTest extends TestCase
{
public function testUsage()
{
$cache = new ArrayAdapter();
$storage = new ExpiredLoginLinkStorage($cache, 600);
$storage = new ExpiredSignatureStorage($cache, 600);
$this->assertSame(0, $storage->countUsages('hash+more'));
$storage->incrementUsages('hash+more');

View File

@ -14,14 +14,9 @@ namespace Symfony\Component\Security\Http\Authenticator\Passport\Badge;
/**
* Adds support for remember me to this authenticator.
*
* Remember me cookie will be set if *all* of the following are met:
* A) This badge is present in the Passport
* B) The remember_me key under your firewall is configured
* C) The "remember me" functionality is activated. This is usually
* done by having a _remember_me checkbox in your form, but
* can be configured by the "always_remember_me" and "remember_me_parameter"
* parameters under the "remember_me" firewall key
* D) The authentication process returns a success Response object
* The presence of this badge doesn't create the remember-me cookie. The actual
* cookie is only created if this badge is enabled. By default, this is done
* by the {@see RememberMeConditionsListener} if all conditions are met.
*
* @author Wouter de Jong <wouter@wouterj.nl>
*
@ -30,6 +25,40 @@ namespace Symfony\Component\Security\Http\Authenticator\Passport\Badge;
*/
class RememberMeBadge implements BadgeInterface
{
private $enabled = false;
/**
* Enables remember-me cookie creation.
*
* In most cases, {@see RememberMeConditionsListener} enables this
* automatically if always_remember_me is true or the remember_me_parameter
* exists in the request.
*
* @return $this
*/
public function enable(): self
{
$this->enabled = true;
return $this;
}
/**
* Disables remember-me cookie creation.
*
* The default is disabled, this can be called to suppress creation
* after it was enabled.
*/
public function disable(): void
{
$this->enabled = false;
}
public function isEnabled(): bool
{
return $this->enabled;
}
public function isResolved(): bool
{
return true; // remember me does not need to be explicitly resolved

View File

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

View File

@ -15,7 +15,11 @@ use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Contracts\EventDispatcher\Event;
/**
* Deauthentication happens in case the user has changed when trying to refresh the token.
* Deauthentication happens in case the user has changed when trying to
* refresh the token.
*
* Use {@see TokenDeauthenticatedEvent} if you want to cover all cases where
* a session is deauthenticated.
*
* @author Hamza Amrouche <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\Event\LoginFailureEvent;
use Symfony\Component\Security\Http\Event\LoginSuccessEvent;
use Symfony\Component\Security\Http\RememberMe\RememberMeServicesInterface;
use Symfony\Component\Security\Http\Event\LogoutEvent;
use Symfony\Component\Security\Http\Event\TokenDeauthenticatedEvent;
use Symfony\Component\Security\Http\ParameterBagUtils;
use Symfony\Component\Security\Http\RememberMe\RememberMeHandlerInterface;
/**
* The RememberMe *listener* creates and deletes remember me cookies.
* The RememberMe *listener* creates and deletes remember-me cookies.
*
* Upon login success or failure and support for remember me
* in the firewall and authenticator, this listener will create
* a remember me cookie.
* Upon login failure, all remember me cookies are removed.
* a remember-me cookie.
* Upon login failure, all remember-me cookies are removed.
*
* @author Wouter de Jong <wouter@wouterj.nl>
*
@ -33,12 +36,12 @@ use Symfony\Component\Security\Http\RememberMe\RememberMeServicesInterface;
*/
class RememberMeListener implements EventSubscriberInterface
{
private $rememberMeServices;
private $rememberMeHandler;
private $logger;
public function __construct(RememberMeServicesInterface $rememberMeServices, ?LoggerInterface $logger = null)
public function __construct(RememberMeHandlerInterface $rememberMeHandler, ?LoggerInterface $logger = null)
{
$this->rememberMeServices = $rememberMeServices;
$this->rememberMeHandler = $rememberMeHandler;
$this->logger = $logger;
}
@ -53,27 +56,38 @@ class RememberMeListener implements EventSubscriberInterface
return;
}
if (null === $event->getResponse()) {
// Make sure any old remember-me cookies are cancelled
$this->rememberMeHandler->clearRememberMeCookie();
/** @var RememberMeBadge $badge */
$badge = $passport->getBadge(RememberMeBadge::class);
if (!$badge->isEnabled()) {
if (null !== $this->logger) {
$this->logger->debug('Remember me skipped: the authenticator did not set a success response.', ['authenticator' => \get_class($event->getAuthenticator())]);
$this->logger->debug('Remember me skipped: the RememberMeBadge is not enabled.');
}
return;
}
$this->rememberMeServices->loginSuccess($event->getRequest(), $event->getResponse(), $event->getAuthenticatedToken());
if (null !== $this->logger) {
$this->logger->debug('Remember-me was requested; setting cookie.');
}
$this->rememberMeHandler->createRememberMeCookie($event->getUser());
}
public function onFailedLogin(LoginFailureEvent $event): void
public function clearCookie(): void
{
$this->rememberMeServices->loginFail($event->getRequest(), $event->getException());
$this->rememberMeHandler->clearRememberMeCookie();
}
public static function getSubscribedEvents(): array
{
return [
LoginSuccessEvent::class => 'onSuccessfulLogin',
LoginFailureEvent::class => 'onFailedLogin',
LoginSuccessEvent::class => ['onSuccessfulLogin', -64],
LoginFailureEvent::class => 'clearCookie',
LogoutEvent::class => 'clearCookie',
TokenDeauthenticatedEvent::class => 'clearCookie',
];
}
}

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

View File

@ -11,10 +11,12 @@
namespace Symfony\Component\Security\Http\LoginLink\Exception;
use Symfony\Component\Security\Core\Signature\Exception\ExpiredSignatureException;
/**
* @author Ryan Weaver <ryan@symfonycasts.com>
* @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>
* @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;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Routing\RequestContext;
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
use Symfony\Component\Security\Core\Signature\Exception\ExpiredSignatureException;
use Symfony\Component\Security\Core\Signature\Exception\InvalidSignatureException;
use Symfony\Component\Security\Core\Signature\SignatureHasher;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Http\LoginLink\Exception\ExpiredLoginLinkException;
@ -29,25 +31,18 @@ final class LoginLinkHandler implements LoginLinkHandlerInterface
{
private $urlGenerator;
private $userProvider;
private $propertyAccessor;
private $signatureProperties;
private $secret;
private $options;
private $expiredStorage;
private $signatureHashUtil;
public function __construct(UrlGeneratorInterface $urlGenerator, UserProviderInterface $userProvider, PropertyAccessorInterface $propertyAccessor, array $signatureProperties, string $secret, array $options, ?ExpiredLoginLinkStorage $expiredStorage)
public function __construct(UrlGeneratorInterface $urlGenerator, UserProviderInterface $userProvider, SignatureHasher $signatureHashUtil, array $options)
{
$this->urlGenerator = $urlGenerator;
$this->userProvider = $userProvider;
$this->propertyAccessor = $propertyAccessor;
$this->signatureProperties = $signatureProperties;
$this->secret = $secret;
$this->signatureHashUtil = $signatureHashUtil;
$this->options = array_merge([
'route_name' => null,
'lifetime' => 600,
'max_uses' => null,
], $options);
$this->expiredStorage = $expiredStorage;
}
public function createLoginLink(UserInterface $user, Request $request = null): LoginLinkDetails
@ -59,7 +54,7 @@ final class LoginLinkHandler implements LoginLinkHandlerInterface
// @deprecated since 5.3, change to $user->getUserIdentifier() in 6.0
'user' => method_exists($user, 'getUserIdentifier') ? $user->getUserIdentifier() : $user->getUsername(),
'expires' => $expires,
'hash' => $this->computeSignatureHash($user, $expires),
'hash' => $this->signatureHashUtil->computeSignatureHash($user, $expires),
];
if ($request) {
@ -105,43 +100,15 @@ final class LoginLinkHandler implements LoginLinkHandlerInterface
$hash = $request->get('hash');
$expires = $request->get('expires');
if (false === hash_equals($hash, $this->computeSignatureHash($user, $expires))) {
throw new InvalidLoginLinkException('Invalid or expired signature.');
}
if ($expires < time()) {
throw new ExpiredLoginLinkException('Login link has expired.');
}
if ($this->expiredStorage && $this->options['max_uses']) {
$hash = $request->get('hash');
if ($this->expiredStorage->countUsages($hash) >= $this->options['max_uses']) {
throw new ExpiredLoginLinkException(sprintf('Login link can only be used "%d" times.', $this->options['max_uses']));
}
$this->expiredStorage->incrementUsages($hash);
try {
$this->signatureHashUtil->verifySignatureHash($user, $expires, $hash);
} catch (ExpiredSignatureException $e) {
throw new ExpiredLoginLinkException(ucfirst(str_ireplace('signature', 'login link', $e->getMessage())), 0, $e);
} catch (InvalidSignatureException $e) {
throw new InvalidLoginLinkException(ucfirst(str_ireplace('signature', 'login link', $e->getMessage())), 0, $e);
}
return $user;
}
private function computeSignatureHash(UserInterface $user, int $expires): string
{
// @deprecated since 5.3, change to $user->getUserIdentifier() in 6.0
$signatureFields = [base64_encode(method_exists($user, 'getUserIdentifier') ? $user->getUserIdentifier() : $user->getUsername()), $expires];
foreach ($this->signatureProperties as $property) {
$value = $this->propertyAccessor->getValue($user, $property) ?? '';
if ($value instanceof \DateTimeInterface) {
$value = $value->format('c');
}
if (!is_scalar($value) && !(\is_object($value) && method_exists($value, '__toString'))) {
throw new \InvalidArgumentException(sprintf('The property path "%s" on the user object "%s" must return a value that can be cast to a string, but "%s" was returned.', $property, \get_class($user), get_debug_type($value)));
}
$signatureFields[] = base64_encode($value);
}
return base64_encode(hash_hmac('sha256', implode(':', $signatureFields), $this->secret));
}
}

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
{
/**
* This attribute name can be used by the implementation if it needs to set
* a cookie on the Request when there is no actual Response, yet.
*/
public const COOKIE_ATTR_NAME = '_security_remember_me_cookie';
public function onKernelResponse(ResponseEvent $event)
{
if (!$event->isMainRequest()) {
@ -33,8 +39,8 @@ class ResponseListener implements EventSubscriberInterface
$request = $event->getRequest();
$response = $event->getResponse();
if ($request->attributes->has(RememberMeServicesInterface::COOKIE_ATTR_NAME)) {
$response->headers->setCookie($request->attributes->get(RememberMeServicesInterface::COOKIE_ATTR_NAME));
if ($request->attributes->has(self::COOKIE_ATTR_NAME)) {
$response->headers->setCookie($request->attributes->get(self::COOKIE_ATTR_NAME));
}
}

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

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\PassportInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
use Symfony\Component\Security\Http\Event\LoginFailureEvent;
use Symfony\Component\Security\Http\Event\LoginSuccessEvent;
use Symfony\Component\Security\Http\EventListener\RememberMeListener;
use Symfony\Component\Security\Http\RememberMe\RememberMeServicesInterface;
use Symfony\Component\Security\Http\RememberMe\RememberMeHandlerInterface;
class RememberMeListenerTest extends TestCase
{
private $rememberMeServices;
private $rememberMeHandler;
private $listener;
private $request;
private $response;
private $token;
protected function setUp(): void
{
$this->rememberMeServices = $this->createMock(RememberMeServicesInterface::class);
$this->listener = new RememberMeListener($this->rememberMeServices);
$this->request = $this->createMock(Request::class);
$this->response = $this->createMock(Response::class);
$this->token = $this->createMock(TokenInterface::class);
$this->rememberMeHandler = $this->createMock(RememberMeHandlerInterface::class);
$this->listener = new RememberMeListener($this->rememberMeHandler);
$this->request = Request::create('/login');
$this->request->request->set('_remember_me', true);
$this->response = new Response();
}
public function testSuccessfulLoginWithoutSupportingAuthenticator()
{
$this->rememberMeServices->expects($this->never())->method('loginSuccess');
$this->rememberMeHandler->expects($this->never())->method('createRememberMeCookie');
$event = $this->createLoginSuccessfulEvent('main_firewall', $this->response, new SelfValidatingPassport(new UserBadge('wouter', function ($username) { return new InMemoryUser($username, null); })));
$event = $this->createLoginSuccessfulEvent($this->createPassport([]));
$this->listener->onSuccessfulLogin($event);
}
public function testSuccessfulLoginWithoutSuccessResponse()
public function testSuccessfulLoginWithRememberMeDisabled()
{
$this->rememberMeServices->expects($this->never())->method('loginSuccess');
$this->rememberMeHandler->expects($this->never())->method('createRememberMeCookie');
$event = $this->createLoginSuccessfulEvent('main_firewall', null);
$this->listener->onSuccessfulLogin($event);
}
public function testSuccessfulLogin()
{
$this->rememberMeServices->expects($this->once())->method('loginSuccess')->with($this->request, $this->response, $this->token);
$event = $this->createLoginSuccessfulEvent('main_firewall', $this->response);
$event = $this->createLoginSuccessfulEvent($this->createPassport([new RememberMeBadge()]));
$this->listener->onSuccessfulLogin($event);
}
public function testCredentialsInvalid()
{
$this->rememberMeServices->expects($this->once())->method('loginFail')->with($this->request, $this->isInstanceOf(AuthenticationException::class));
$this->rememberMeHandler->expects($this->once())->method('clearRememberMeCookie');
$event = $this->createLoginFailureEvent('main_firewall');
$this->listener->onFailedLogin($event);
$this->listener->clearCookie();
}
private function createLoginSuccessfulEvent($firewallName, $response, PassportInterface $passport = null)
private function createLoginSuccessfulEvent(PassportInterface $passport = null)
{
if (null === $passport) {
$passport = new SelfValidatingPassport(new UserBadge('test', function ($username) { return new InMemoryUser($username, null); }), [new RememberMeBadge()]);
$passport = $this->createPassport();
}
return new LoginSuccessEvent($this->createMock(AuthenticatorInterface::class), $passport, $this->token, $this->request, $response, $firewallName);
return new LoginSuccessEvent($this->createMock(AuthenticatorInterface::class), $passport, $this->createMock(TokenInterface::class), $this->request, $this->response, 'main_firewall');
}
private function createLoginFailureEvent($firewallName)
private function createPassport(array $badges = null)
{
return new LoginFailureEvent(new AuthenticationException(), $this->createMock(AuthenticatorInterface::class), $this->request, null, $firewallName, null);
if (null === $badges) {
$badge = new RememberMeBadge();
$badge->enable();
$badges = [$badge];
}
return new SelfValidatingPassport(new UserBadge('test', function ($username) { return new InMemoryUser($username, null); }), $badges);
}
}

View File

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

View File

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

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