Added remember me functionality

This commit is contained in:
Wouter de Jong 2020-02-12 23:56:17 +01:00
parent 1c810d5d2a
commit ddf430fc1e
15 changed files with 296 additions and 105 deletions

View File

@ -42,7 +42,7 @@ class AnonymousFactory implements SecurityFactoryInterface, AuthenticatorFactory
return [$providerId, $listenerId, $defaultEntryPoint];
}
public function createAuthenticator(ContainerBuilder $container, string $id, array $config, ?string $userProviderId): string
public function createAuthenticator(ContainerBuilder $container, string $id, array $config, string $userProviderId): string
{
if (null === $config['secret']) {
$config['secret'] = new Parameter('container.build_hash');

View File

@ -25,5 +25,5 @@ interface AuthenticatorFactoryInterface
*
* @return string|string[] The authenticator service ID(s) to be used by the firewall
*/
public function createAuthenticator(ContainerBuilder $container, string $id, array $config, ?string $userProviderId);
public function createAuthenticator(ContainerBuilder $container, string $id, array $config, string $userProviderId);
}

View File

@ -97,7 +97,7 @@ class FormLoginFactory extends AbstractFactory implements AuthenticatorFactoryIn
return $entryPointId;
}
public function createAuthenticator(ContainerBuilder $container, string $id, array $config, ?string $userProviderId): string
public function createAuthenticator(ContainerBuilder $container, string $id, array $config, string $userProviderId): string
{
$authenticatorId = 'security.authenticator.form_login.'.$id;
$defaultOptions = array_merge($this->defaultSuccessHandlerOptions, $this->options);

View File

@ -46,7 +46,7 @@ class HttpBasicFactory implements SecurityFactoryInterface, AuthenticatorFactory
return [$provider, $listenerId, $entryPointId];
}
public function createAuthenticator(ContainerBuilder $container, string $id, array $config, ?string $userProviderId): string
public function createAuthenticator(ContainerBuilder $container, string $id, array $config, string $userProviderId): string
{
$authenticatorId = 'security.authenticator.http_basic.'.$id;
$container

View File

@ -20,7 +20,7 @@ use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\Security\Http\EventListener\RememberMeLogoutListener;
class RememberMeFactory implements SecurityFactoryInterface
class RememberMeFactory implements SecurityFactoryInterface, AuthenticatorFactoryInterface
{
protected $options = [
'name' => 'REMEMBERME',
@ -46,29 +46,8 @@ class RememberMeFactory implements SecurityFactoryInterface
;
// remember me services
if (isset($config['service'])) {
$templateId = $config['service'];
$rememberMeServicesId = $templateId.'.'.$id;
} elseif (isset($config['token_provider'])) {
$templateId = 'security.authentication.rememberme.services.persistent';
$rememberMeServicesId = $templateId.'.'.$id;
} else {
$templateId = 'security.authentication.rememberme.services.simplehash';
$rememberMeServicesId = $templateId.'.'.$id;
}
$rememberMeServices = $container->setDefinition($rememberMeServicesId, new ChildDefinition($templateId));
$rememberMeServices->replaceArgument(1, $config['secret']);
$rememberMeServices->replaceArgument(2, $id);
if (isset($config['token_provider'])) {
$rememberMeServices->addMethodCall('setTokenProvider', [
new Reference($config['token_provider']),
]);
}
// remember-me options
$rememberMeServices->replaceArgument(3, array_intersect_key($config, $this->options));
$templateId = $this->generateRememberMeServicesTemplateId($config, $id);
$rememberMeServicesId = $templateId.'.'.$id;
// attach to remember-me aware listeners
$userProviders = [];
@ -93,17 +72,8 @@ class RememberMeFactory implements SecurityFactoryInterface
;
}
}
if ($config['user_providers']) {
$userProviders = [];
foreach ($config['user_providers'] as $providerName) {
$userProviders[] = new Reference('security.user.provider.concrete.'.$providerName);
}
}
if (0 === \count($userProviders)) {
throw new \RuntimeException('You must configure at least one remember-me aware listener (such as form-login) for each firewall that has remember-me enabled.');
}
$rememberMeServices->replaceArgument(0, new IteratorArgument(array_unique($userProviders)));
$this->createRememberMeServices($container, $id, $templateId, $userProviders, $config);
// remember-me listener
$listenerId = 'security.authentication.listener.rememberme.'.$id;
@ -119,6 +89,42 @@ class RememberMeFactory implements SecurityFactoryInterface
return [$authProviderId, $listenerId, $defaultEntryPoint];
}
public function createAuthenticator(ContainerBuilder $container, string $id, array $config, string $userProviderId): string
{
$templateId = $this->generateRememberMeServicesTemplateId($config, $id);
$rememberMeServicesId = $templateId.'.'.$id;
// create remember me services (which manage the remember me cookies)
$this->createRememberMeServices($container, $id, $templateId, [new Reference($userProviderId)], $config);
// create remember me listener (which executes the remember me services for other authenticators and logout)
$this->createRememberMeListener($container, $id, $rememberMeServicesId);
// create remember me authenticator (which re-authenticates the user based on the remember me cookie)
$authenticatorId = 'security.authenticator.remember_me.'.$id;
$container
->setDefinition($authenticatorId, new ChildDefinition('security.authenticator.remember_me'))
->replaceArgument(0, new Reference($rememberMeServicesId))
->replaceArgument(3, array_intersect_key($config, $this->options))
;
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;
}
throw new \LogicException(sprintf('Symfony Authenticator Security dropped support for the "security.remember_me_aware" tag, service "%s" will no longer work as expected.', $serviceId));
}
return $authenticatorId;
}
public function getPosition()
{
return 'remember_me';
@ -163,4 +169,63 @@ class RememberMeFactory implements SecurityFactoryInterface
}
}
}
private function generateRememberMeServicesTemplateId(array $config, string $id): string
{
if (isset($config['service'])) {
return $config['service'];
}
if (isset($config['token_provider'])) {
return 'security.authentication.rememberme.services.persistent';
}
return 'security.authentication.rememberme.services.simplehash';
}
private function createRememberMeServices(ContainerBuilder $container, string $id, string $templateId, array $userProviders, array $config): void
{
$rememberMeServicesId = $templateId.'.'.$id;
$rememberMeServices = $container->setDefinition($rememberMeServicesId, new ChildDefinition($templateId));
$rememberMeServices->replaceArgument(1, $config['secret']);
$rememberMeServices->replaceArgument(2, $id);
if (isset($config['token_provider'])) {
$rememberMeServices->addMethodCall('setTokenProvider', [
new Reference($config['token_provider']),
]);
}
// remember-me options
$rememberMeServices->replaceArgument(3, array_intersect_key($config, $this->options));
if ($config['user_providers']) {
$userProviders = [];
foreach ($config['user_providers'] as $providerName) {
$userProviders[] = new Reference('security.user.provider.concrete.'.$providerName);
}
}
if (0 === \count($userProviders)) {
throw new \RuntimeException('You must configure at least one remember-me aware listener (such as form-login) for each firewall that has remember-me enabled.');
}
$rememberMeServices->replaceArgument(0, new IteratorArgument(array_unique($userProviders)));
}
private function createRememberMeListener(ContainerBuilder $container, string $id, string $rememberMeServicesId): void
{
$container
->setDefinition('security.listener.remember_me.'.$id, new ChildDefinition('security.listener.remember_me'))
->addTag('kernel.event_subscriber')
->replaceArgument(0, new Reference($rememberMeServicesId))
->replaceArgument(1, $id)
;
$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));
}
}

View File

@ -26,6 +26,7 @@ use Symfony\Component\DependencyInjection\Argument\IteratorArgument;
use Symfony\Component\DependencyInjection\ChildDefinition;
use Symfony\Component\DependencyInjection\Compiler\ServiceLocatorTagPass;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface;
use Symfony\Component\DependencyInjection\Loader\XmlFileLoader;
use Symfony\Component\DependencyInjection\Reference;
@ -34,6 +35,7 @@ use Symfony\Component\HttpKernel\DependencyInjection\Extension;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
use Symfony\Component\Security\Core\Encoder\NativePasswordEncoder;
use Symfony\Component\Security\Core\Encoder\SodiumPasswordEncoder;
use Symfony\Component\Security\Core\User\ChainUserProvider;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Http\Controller\UserValueResolver;
use Twig\Extension\AbstractExtension;
@ -230,9 +232,16 @@ class SecurityExtension extends Extension implements PrependExtensionInterface
foreach ($providerIds as $userProviderId) {
$userProviders[] = new Reference($userProviderId);
}
$arguments[1] = new IteratorArgument($userProviders);
$arguments[1] = $userProviderIteratorsArgument = new IteratorArgument($userProviders);
$contextListenerDefinition->setArguments($arguments);
if (\count($userProviders) > 1) {
$container->setDefinition('security.user_providers', new Definition(ChainUserProvider::class, [$userProviderIteratorsArgument]))
->setPublic(false);
} else {
$container->setAlias('security.user_providers', new Alias(current($providerIds)))->setPublic(false);
}
if (1 === \count($providerIds)) {
$container->setAlias(UserProviderInterface::class, current($providerIds));
}
@ -423,16 +432,6 @@ class SecurityExtension extends Extension implements PrependExtensionInterface
// Determine default entry point
$configuredEntryPoint = isset($firewall['entry_point']) ? $firewall['entry_point'] : null;
if ($this->authenticatorManagerEnabled) {
// Remember me listener (must be before calling createAuthenticationListeners() to inject remember me services)
$container
->setDefinition('security.listener.remember_me.'.$id, new ChildDefinition('security.listener.remember_me'))
->replaceArgument(0, $id)
->addTag('kernel.event_subscriber')
->addTag('security.remember_me_aware', ['id' => $id, 'provider' => 'none'])
;
}
// Authentication listeners
$firewallAuthenticationProviders = [];
list($authListeners, $defaultEntryPoint) = $this->createAuthenticationListeners($container, $id, $firewall, $firewallAuthenticationProviders, $defaultProvider, $providerIds, $configuredEntryPoint, $contextListenerId);
@ -554,7 +553,7 @@ class SecurityExtension extends Extension implements PrependExtensionInterface
return [$listeners, $defaultEntryPoint];
}
private function getUserProvider(ContainerBuilder $container, string $id, array $firewall, string $factoryKey, ?string $defaultProvider, array $providerIds, ?string $contextListenerId): ?string
private function getUserProvider(ContainerBuilder $container, string $id, array $firewall, string $factoryKey, ?string $defaultProvider, array $providerIds, ?string $contextListenerId): string
{
if (isset($firewall[$factoryKey]['provider'])) {
if (!isset($providerIds[$normalizedName = str_replace('-', '_', $firewall[$factoryKey]['provider'])])) {
@ -564,13 +563,8 @@ class SecurityExtension extends Extension implements PrependExtensionInterface
return $providerIds[$normalizedName];
}
if ('remember_me' === $factoryKey || 'anonymous' === $factoryKey) {
if ('remember_me' === $factoryKey && $contextListenerId) {
$container->getDefinition($contextListenerId)->addTag('security.remember_me_aware', ['id' => $id, 'provider' => 'none']);
}
// RememberMeFactory will use the firewall secret when created
return null;
if ('remember_me' === $factoryKey && $contextListenerId) {
$container->getDefinition($contextListenerId)->addTag('security.remember_me_aware', ['id' => $id, 'provider' => 'none']);
}
if ($defaultProvider) {
@ -587,6 +581,10 @@ class SecurityExtension extends Extension implements PrependExtensionInterface
return $userProvider;
}
if ('remember_me' === $factoryKey || 'anonymous' === $factoryKey) {
return 'security.user_providers';
}
throw new InvalidConfigurationException(sprintf('Not configuring explicitly the provider for the "%s" listener on "%s" firewall is ambiguous as there is more than one registered provider.', $factoryKey, $id));
}

View File

@ -52,7 +52,8 @@
class="Symfony\Component\Security\Http\EventListener\RememberMeListener"
abstract="true">
<tag name="monolog.logger" channel="security" />
<argument/> <!-- provider key -->
<argument type="abstract">remember me services</argument>
<argument type="abstract">provider key</argument>
<argument type="service" id="logger" on-invalid="null" />
</service>
@ -82,5 +83,15 @@
<argument type="abstract">secret</argument>
<argument type="service" id="security.untracked_token_storage" />
</service>
<service id="security.authenticator.remember_me"
class="Symfony\Component\Security\Http\Authenticator\RememberMeAuthenticator"
abstract="true">
<argument type="abstract">remember me services</argument>
<argument>%kernel.secret%</argument>
<argument type="service" id="security.token_storage" />
<argument type="abstract">options</argument>
<argument type="service" id="security.authentication.session_strategy" />
</service>
</services>
</container>

View File

@ -25,7 +25,7 @@ use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface
*
* @experimental in 5.1
*/
abstract class AbstractLoginFormAuthenticator extends AbstractAuthenticator implements AuthenticationEntryPointInterface
abstract class AbstractLoginFormAuthenticator extends AbstractAuthenticator implements AuthenticationEntryPointInterface, RememberMeAuthenticatorInterface
{
/**
* Return the URL to the login page.
@ -46,11 +46,6 @@ abstract class AbstractLoginFormAuthenticator extends AbstractAuthenticator impl
return new RedirectResponse($url);
}
public function supportsRememberMe(): bool
{
return true;
}
/**
* Override to control what happens when the user hits a secure page
* but isn't logged in yet.
@ -61,4 +56,9 @@ abstract class AbstractLoginFormAuthenticator extends AbstractAuthenticator impl
return new RedirectResponse($url);
}
public function supportsRememberMe(): bool
{
return true;
}
}

View File

@ -75,9 +75,4 @@ class AnonymousAuthenticator implements AuthenticatorInterface, CustomAuthentica
{
return null;
}
public function supportsRememberMe(): bool
{
return false;
}
}

View File

@ -102,18 +102,4 @@ interface AuthenticatorInterface
* will be authenticated. This makes sense, for example, with an API.
*/
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $providerKey): ?Response;
/**
* Does this method support remember me cookies?
*
* Remember me cookie will be set if *all* of the following are met:
* A) This method returns true
* 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 onAuthenticationSuccess method returns a Response object
*/
public function supportsRememberMe(): bool;
}

View File

@ -94,9 +94,4 @@ class HttpBasicAuthenticator implements AuthenticatorInterface, AuthenticationEn
return $this->start($request, $exception);
}
public function supportsRememberMe(): bool
{
return false;
}
}

View File

@ -0,0 +1,110 @@
<?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\Authenticator\Token;
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\User\UserInterface;
use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface;
use Symfony\Component\Security\Http\RememberMe\AbstractRememberMeServices;
use Symfony\Component\Security\Http\Session\SessionAuthenticationStrategy;
/**
* 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
* then "re-authenticates" the user using the information in the
* cookie.
*
* @author Johannes M. Schmitt <schmittjoh@gmail.com>
* @author Wouter de Jong <wouter@wouterj.nl>
*
* @final
*/
class RememberMeAuthenticator implements AuthenticatorInterface
{
private $rememberMeServices;
private $secret;
private $tokenStorage;
private $options;
private $sessionStrategy;
public function __construct(AbstractRememberMeServices $rememberMeServices, string $secret, TokenStorageInterface $tokenStorage, array $options, ?SessionAuthenticationStrategy $sessionStrategy = null)
{
$this->rememberMeServices = $rememberMeServices;
$this->secret = $secret;
$this->tokenStorage = $tokenStorage;
$this->options = $options;
$this->sessionStrategy = $sessionStrategy;
}
public function supports(Request $request): ?bool
{
// do not overwrite already stored tokens (i.e. from the session)
if (null !== $this->tokenStorage->getToken()) {
return false;
}
if (($cookie = $request->attributes->get(AbstractRememberMeServices::COOKIE_ATTR_NAME)) && null === $cookie->getValue()) {
return false;
}
if (!$request->cookies->has($this->options['name'])) {
return false;
}
// the `null` return value indicates that this authenticator supports lazy firewalls
return null;
}
public function getCredentials(Request $request)
{
return [
'cookie_parts' => explode(AbstractRememberMeServices::COOKIE_DELIMITER, base64_decode($request->cookies->get($this->options['name']))),
'request' => $request,
];
}
/**
* @param array $credentials
*/
public function getUser($credentials): ?UserInterface
{
return $this->rememberMeServices->performLogin($credentials['cookie_parts'], $credentials['request']);
}
public function createAuthenticatedToken(UserInterface $user, string $providerKey): TokenInterface
{
return new RememberMeToken($user, $providerKey, $this->secret);
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
$this->rememberMeServices->loginFail($request, $exception);
return null;
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $providerKey): ?Response
{
if ($request->hasSession() && $request->getSession()->isStarted()) {
$this->sessionStrategy->onAuthentication($request, $token);
}
return null;
}
}

View File

@ -0,0 +1,31 @@
<?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\Authenticator;
/**
* This interface must be extended if the authenticator supports remember me functionality.
*
* Remember me cookie will be set if *all* of the following are met:
* A) SupportsRememberMe() returns true in the successful authenticator
* 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 onAuthenticationSuccess method returns a Response object
*
* @author Wouter de Jong <wouter@wouterj.nl>
*/
interface RememberMeAuthenticatorInterface
{
public function supportsRememberMe(): bool;
}

View File

@ -5,11 +5,19 @@ namespace Symfony\Component\Security\Http\EventListener;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface;
use Symfony\Component\Security\Http\Authenticator\RememberMeAuthenticatorInterface;
use Symfony\Component\Security\Http\Event\LoginFailureEvent;
use Symfony\Component\Security\Http\Event\LoginSuccessEvent;
use Symfony\Component\Security\Http\RememberMe\RememberMeServicesInterface;
/**
* 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.
*
* @author Wouter de Jong <wouter@wouterj.nl>
*
* @final
@ -17,23 +25,18 @@ use Symfony\Component\Security\Http\RememberMe\RememberMeServicesInterface;
*/
class RememberMeListener implements EventSubscriberInterface
{
private $rememberMeServices;
private $providerKey;
private $logger;
/** @var RememberMeServicesInterface|null */
private $rememberMeServices;
public function __construct(string $providerKey, ?LoggerInterface $logger = null)
public function __construct(RememberMeServicesInterface $rememberMeServices, string $providerKey, ?LoggerInterface $logger = null)
{
$this->rememberMeServices = $rememberMeServices;
$this->providerKey = $providerKey;
$this->logger = $logger;
}
public function setRememberMeServices(RememberMeServicesInterface $rememberMeServices): void
{
$this->rememberMeServices = $rememberMeServices;
}
public function onSuccessfulLogin(LoginSuccessEvent $event): void
{
if (!$this->isRememberMeEnabled($event->getAuthenticator(), $event->getProviderKey())) {
@ -59,15 +62,7 @@ class RememberMeListener implements EventSubscriberInterface
return false;
}
if (null === $this->rememberMeServices) {
if (null !== $this->logger) {
$this->logger->debug('Remember me skipped: it is not configured for the firewall.', ['authenticator' => \get_class($authenticator)]);
}
return false;
}
if (!$authenticator->supportsRememberMe()) {
if (!$authenticator instanceof RememberMeAuthenticatorInterface || !$authenticator->supportsRememberMe()) {
if (null !== $this->logger) {
$this->logger->debug('Remember me skipped: your authenticator does not support it.', ['authenticator' => \get_class($authenticator)]);
}

View File

@ -89,6 +89,11 @@ abstract class AbstractRememberMeServices implements RememberMeServicesInterface
return $this->secret;
}
public function performLogin(array $cookieParts, Request $request): UserInterface
{
return $this->processAutoLoginCookie($cookieParts, $request);
}
/**
* Implementation of RememberMeServicesInterface. Detects whether a remember-me
* cookie was set, decodes it, and hands it to subclasses for further processing.