diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php index 15ff8246f787..dfac1554d4ba 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php @@ -73,6 +73,7 @@ public function getConfigTreeBuilder() ->booleanNode('hide_user_not_found')->defaultTrue()->end() ->booleanNode('always_authenticate_before_granting')->defaultFalse()->end() ->booleanNode('erase_credentials')->defaultTrue()->end() + ->booleanNode('enable_authenticator_manager')->defaultFalse()->info('Enables the new Symfony Security system based on Authenticators, all used authenticators must support this before enabling this.')->end() ->arrayNode('access_decision_manager') ->addDefaultsIfNotSet() ->children() diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/AbstractFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/AbstractFactory.php index b523467f230b..a5d6f7e45ea6 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/AbstractFactory.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/AbstractFactory.php @@ -30,6 +30,7 @@ abstract class AbstractFactory implements SecurityFactoryInterface 'check_path' => '/login_check', 'use_forward' => false, 'require_previous_session' => false, + 'login_path' => '/login', ]; protected $defaultSuccessHandlerOptions = [ diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/AnonymousFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/AnonymousFactory.php index eb3c930afe37..53a6b503a1e8 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/AnonymousFactory.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/AnonymousFactory.php @@ -19,7 +19,7 @@ /** * @author Wouter de Jong */ -class AnonymousFactory implements SecurityFactoryInterface +class AnonymousFactory implements SecurityFactoryInterface, AuthenticatorFactoryInterface { public function create(ContainerBuilder $container, $id, $config, $userProvider, $defaultEntryPoint) { @@ -42,6 +42,20 @@ public function create(ContainerBuilder $container, $id, $config, $userProvider, return [$providerId, $listenerId, $defaultEntryPoint]; } + public function createAuthenticator(ContainerBuilder $container, string $firewallName, array $config, string $userProviderId): string + { + if (null === $config['secret']) { + $config['secret'] = new Parameter('container.build_hash'); + } + + $authenticatorId = 'security.authenticator.anonymous.'.$firewallName; + $container + ->setDefinition($authenticatorId, new ChildDefinition('security.authenticator.anonymous')) + ->replaceArgument(0, $config['secret']); + + return $authenticatorId; + } + public function getPosition() { return 'anonymous'; diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/AuthenticatorFactoryInterface.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/AuthenticatorFactoryInterface.php new file mode 100644 index 000000000000..cb65f31fe5ef --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/AuthenticatorFactoryInterface.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory; + +use Symfony\Component\DependencyInjection\ContainerBuilder; + +/** + * @author Wouter de Jong + * + * @experimental in 5.1 + */ +interface AuthenticatorFactoryInterface +{ + /** + * Creates the authenticator service(s) for the provided configuration. + * + * @return string|string[] The authenticator service ID(s) to be used by the firewall + */ + public function createAuthenticator(ContainerBuilder $container, string $firewallName, array $config, string $userProviderId); +} diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/CustomAuthenticatorFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/CustomAuthenticatorFactory.php new file mode 100644 index 000000000000..95fa3c050fbb --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/CustomAuthenticatorFactory.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory; + +use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; +use Symfony\Component\Config\Definition\Builder\NodeDefinition; +use Symfony\Component\DependencyInjection\ContainerBuilder; + +class CustomAuthenticatorFactory implements AuthenticatorFactoryInterface, SecurityFactoryInterface +{ + public function create(ContainerBuilder $container, string $id, array $config, string $userProvider, ?string $defaultEntryPoint) + { + throw new \LogicException('Custom authenticators are not supported when "security.enable_authenticator_manager" is not set to true.'); + } + + public function getPosition(): string + { + return 'pre_auth'; + } + + public function getKey(): string + { + return 'custom_authenticator'; + } + + /** + * @param ArrayNodeDefinition $builder + */ + public function addConfiguration(NodeDefinition $builder) + { + $builder + ->fixXmlConfig('service') + ->children() + ->arrayNode('services') + ->info('An array of service ids for all of your "authenticators"') + ->requiresAtLeastOneElement() + ->prototype('scalar')->end() + ->end() + ->end() + ; + } + + public function createAuthenticator(ContainerBuilder $container, string $firewallName, array $config, string $userProviderId): array + { + return $config['services']; + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/EntryPointFactoryInterface.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/EntryPointFactoryInterface.php new file mode 100644 index 000000000000..bf0e625f0ad6 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/EntryPointFactoryInterface.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory; + +use Symfony\Component\DependencyInjection\ContainerBuilder; + +/** + * @author Wouter de Jong + * + * @experimental in 5.1 + */ +interface EntryPointFactoryInterface +{ + /** + * Creates the entry point and returns the service ID. + */ + public function createEntryPoint(ContainerBuilder $container, string $id, array $config, ?string $defaultEntryPointId): string; +} diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/FormLoginFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/FormLoginFactory.php index af200264061e..c5f247c307be 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/FormLoginFactory.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/FormLoginFactory.php @@ -12,6 +12,7 @@ namespace Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory; use Symfony\Component\Config\Definition\Builder\NodeDefinition; +use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; use Symfony\Component\DependencyInjection\ChildDefinition; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Reference; @@ -22,7 +23,7 @@ * @author Fabien Potencier * @author Johannes M. Schmitt */ -class FormLoginFactory extends AbstractFactory +class FormLoginFactory extends AbstractFactory implements AuthenticatorFactoryInterface, EntryPointFactoryInterface { public function __construct() { @@ -30,6 +31,7 @@ public function __construct() $this->addOption('password_parameter', '_password'); $this->addOption('csrf_parameter', '_csrf_token'); $this->addOption('csrf_token_id', 'authenticate'); + $this->addOption('enable_csrf', false); $this->addOption('post_only', true); } @@ -61,6 +63,10 @@ protected function getListenerId() protected function createAuthProvider(ContainerBuilder $container, string $id, array $config, string $userProviderId) { + if ($config['enable_csrf'] ?? false) { + throw new InvalidConfigurationException('The "enable_csrf" option of "form_login" is only available when "security.enable_authenticator_manager" is set to "true", use "csrf_token_generator" instead.'); + } + $provider = 'security.authentication.provider.dao.'.$id; $container ->setDefinition($provider, new ChildDefinition('security.authentication.provider.dao')) @@ -84,7 +90,7 @@ protected function createListener(ContainerBuilder $container, string $id, array return $listenerId; } - protected function createEntryPoint(ContainerBuilder $container, string $id, array $config, ?string $defaultEntryPoint) + public function createEntryPoint(ContainerBuilder $container, string $id, array $config, ?string $defaultEntryPoint): string { $entryPointId = 'security.authentication.form_entry_point.'.$id; $container @@ -96,4 +102,22 @@ protected function createEntryPoint(ContainerBuilder $container, string $id, arr return $entryPointId; } + + public function createAuthenticator(ContainerBuilder $container, string $firewallName, array $config, string $userProviderId): string + { + if (isset($config['csrf_token_generator'])) { + throw new InvalidConfigurationException('The "csrf_token_generator" option of "form_login" is only available when "security.enable_authenticator_manager" is set to "false", use "enable_csrf" instead.'); + } + + $authenticatorId = 'security.authenticator.form_login.'.$firewallName; + $options = array_intersect_key($config, $this->options); + $container + ->setDefinition($authenticatorId, new ChildDefinition('security.authenticator.form_login')) + ->replaceArgument(1, new Reference($userProviderId)) + ->replaceArgument(2, new Reference($this->createAuthenticationSuccessHandler($container, $firewallName, $config))) + ->replaceArgument(3, new Reference($this->createAuthenticationFailureHandler($container, $firewallName, $config))) + ->replaceArgument(4, $options); + + return $authenticatorId; + } } diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/HttpBasicFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/HttpBasicFactory.php index f731469520b4..a698d2a1d1ae 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/HttpBasicFactory.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/HttpBasicFactory.php @@ -21,7 +21,7 @@ * * @author Fabien Potencier */ -class HttpBasicFactory implements SecurityFactoryInterface +class HttpBasicFactory implements SecurityFactoryInterface, AuthenticatorFactoryInterface { public function create(ContainerBuilder $container, string $id, array $config, string $userProvider, ?string $defaultEntryPoint) { @@ -46,6 +46,17 @@ public function create(ContainerBuilder $container, string $id, array $config, s return [$provider, $listenerId, $entryPointId]; } + public function createAuthenticator(ContainerBuilder $container, string $firewallName, array $config, string $userProviderId): string + { + $authenticatorId = 'security.authenticator.http_basic.'.$firewallName; + $container + ->setDefinition($authenticatorId, new ChildDefinition('security.authenticator.http_basic')) + ->replaceArgument(0, $config['realm']) + ->replaceArgument(1, new Reference($userProviderId)); + + return $authenticatorId; + } + public function getPosition() { return 'http'; diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/JsonLoginFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/JsonLoginFactory.php index f4b9adee939f..7aa90405799a 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/JsonLoginFactory.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/JsonLoginFactory.php @@ -20,7 +20,7 @@ * * @author Kévin Dunglas */ -class JsonLoginFactory extends AbstractFactory +class JsonLoginFactory extends AbstractFactory implements AuthenticatorFactoryInterface { public function __construct() { @@ -96,4 +96,18 @@ protected function createListener(ContainerBuilder $container, string $id, array return $listenerId; } + + public function createAuthenticator(ContainerBuilder $container, string $firewallName, array $config, string $userProviderId) + { + $authenticatorId = 'security.authenticator.json_login.'.$firewallName; + $options = array_intersect_key($config, $this->options); + $container + ->setDefinition($authenticatorId, new ChildDefinition('security.authenticator.json_login')) + ->replaceArgument(1, new Reference($userProviderId)) + ->replaceArgument(2, isset($config['success_handler']) ? new Reference($this->createAuthenticationSuccessHandler($container, $firewallName, $config)) : null) + ->replaceArgument(3, isset($config['failure_handler']) ? new Reference($this->createAuthenticationFailureHandler($container, $firewallName, $config)) : null) + ->replaceArgument(4, $options); + + return $authenticatorId; + } } diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/RememberMeFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/RememberMeFactory.php index 06ad4134bd1e..4b29db1a03d3 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/RememberMeFactory.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/RememberMeFactory.php @@ -20,7 +20,7 @@ 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 @@ public function create(ContainerBuilder $container, string $id, array $config, ? ; // 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 @@ public function create(ContainerBuilder $container, string $id, array $config, ? ; } } - 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 @@ public function create(ContainerBuilder $container, string $id, array $config, ? return [$authProviderId, $listenerId, $defaultEntryPoint]; } + public function createAuthenticator(ContainerBuilder $container, string $firewallName, array $config, string $userProviderId): string + { + $templateId = $this->generateRememberMeServicesTemplateId($config, $firewallName); + $rememberMeServicesId = $templateId.'.'.$firewallName; + + // create remember me services (which manage the remember me cookies) + $this->createRememberMeServices($container, $firewallName, $templateId, [new Reference($userProviderId)], $config); + + // create remember me listener (which executes the remember me services for other authenticators and logout) + $this->createRememberMeListener($container, $firewallName, $rememberMeServicesId); + + // 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, 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,62 @@ public function addConfiguration(NodeDefinition $node) } } } + + 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', ['dispatcher' => 'security.event_dispatcher.'.$id]) + ->replaceArgument(0, new Reference($rememberMeServicesId)) + ; + + $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)); + } } diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/RemoteUserFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/RemoteUserFactory.php index b37229d886e3..e25c3c7d07df 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/RemoteUserFactory.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/RemoteUserFactory.php @@ -22,7 +22,7 @@ * @author Fabien Potencier * @author Maxime Douailin */ -class RemoteUserFactory implements SecurityFactoryInterface +class RemoteUserFactory implements SecurityFactoryInterface, AuthenticatorFactoryInterface { public function create(ContainerBuilder $container, string $id, array $config, string $userProvider, ?string $defaultEntryPoint) { @@ -43,6 +43,19 @@ public function create(ContainerBuilder $container, string $id, array $config, s return [$providerId, $listenerId, $defaultEntryPoint]; } + public function createAuthenticator(ContainerBuilder $container, string $firewallName, array $config, string $userProviderId) + { + $authenticatorId = 'security.authenticator.remote_user.'.$firewallName; + $container + ->setDefinition($authenticatorId, new ChildDefinition('security.authenticator.remote_user')) + ->replaceArgument(0, new Reference($userProviderId)) + ->replaceArgument(2, $firewallName) + ->replaceArgument(3, $config['user']) + ; + + return $authenticatorId; + } + public function getPosition() { return 'pre_auth'; diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/X509Factory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/X509Factory.php index e3ba596d933a..f966302a1da6 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/X509Factory.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/X509Factory.php @@ -21,7 +21,7 @@ * * @author Fabien Potencier */ -class X509Factory implements SecurityFactoryInterface +class X509Factory implements SecurityFactoryInterface, AuthenticatorFactoryInterface { public function create(ContainerBuilder $container, string $id, array $config, string $userProvider, ?string $defaultEntryPoint) { @@ -44,6 +44,20 @@ public function create(ContainerBuilder $container, string $id, array $config, s return [$providerId, $listenerId, $defaultEntryPoint]; } + public function createAuthenticator(ContainerBuilder $container, string $firewallName, array $config, string $userProviderId) + { + $authenticatorId = 'security.authenticator.x509.'.$firewallName; + $container + ->setDefinition($authenticatorId, new ChildDefinition('security.authenticator.x509')) + ->replaceArgument(0, new Reference($userProviderId)) + ->replaceArgument(2, $firewallName) + ->replaceArgument(3, $config['user']) + ->replaceArgument(4, $config['credentials']) + ; + + return $authenticatorId; + } + public function getPosition() { return 'pre_auth'; diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php index 924013306522..ac089d1eb2ea 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php @@ -11,6 +11,8 @@ namespace Symfony\Bundle\SecurityBundle\DependencyInjection; +use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\AuthenticatorFactoryInterface; +use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\EntryPointFactoryInterface; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\RememberMeFactory; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\SecurityFactoryInterface; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\UserProvider\UserProviderFactoryInterface; @@ -21,9 +23,11 @@ use Symfony\Component\Console\Application; use Symfony\Component\DependencyInjection\Alias; use Symfony\Component\DependencyInjection\Argument\IteratorArgument; +use Symfony\Component\DependencyInjection\Argument\ServiceClosureArgument; 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; @@ -32,6 +36,7 @@ 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; @@ -52,6 +57,8 @@ class SecurityExtension extends Extension implements PrependExtensionInterface private $userProviderFactories = []; private $statelessFirewallKeys = []; + private $authenticatorManagerEnabled = false; + public function __construct() { foreach ($this->listenerPositions as $position) { @@ -101,6 +108,12 @@ public function load(array $configs, ContainerBuilder $container) $loader->load('security_listeners.xml'); $loader->load('security_rememberme.xml'); + if ($this->authenticatorManagerEnabled = $config['enable_authenticator_manager']) { + $loader->load('security_authenticator.xml'); + } else { + $loader->load('security_legacy.xml'); + } + if (class_exists(AbstractExtension::class)) { $loader->load('templating_twig.xml'); } @@ -142,6 +155,14 @@ public function load(array $configs, ContainerBuilder $container) $container->getDefinition('security.authentication.guard_handler') ->replaceArgument(2, $this->statelessFirewallKeys); + if ($this->authenticatorManagerEnabled) { + foreach ($this->statelessFirewallKeys as $statelessFirewallId) { + $container + ->setDefinition('security.listener.session.'.$statelessFirewallId, new ChildDefinition('security.listener.session')) + ->addTag('kernel.event_subscriber', ['dispatcher' => 'security.event_dispatcher.'.$statelessFirewallId]); + } + } + if ($config['encoders']) { $this->createEncoders($config['encoders'], $container); } @@ -217,9 +238,16 @@ private function createFirewalls(array $config, ContainerBuilder $container) 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)); } @@ -254,14 +282,16 @@ private function createFirewalls(array $config, ContainerBuilder $container) $mapDef->replaceArgument(0, ServiceLocatorTagPass::register($container, $contextRefs)); $mapDef->replaceArgument(1, new IteratorArgument($map)); - // add authentication providers to authentication manager - $authenticationProviders = array_map(function ($id) { - return new Reference($id); - }, array_values(array_unique($authenticationProviders))); - $container - ->getDefinition('security.authentication.manager') - ->replaceArgument(0, new IteratorArgument($authenticationProviders)) - ; + if (!$this->authenticatorManagerEnabled) { + // add authentication providers to authentication manager + $authenticationProviders = array_map(function ($id) { + return new Reference($id); + }, array_values(array_unique($authenticationProviders))); + + $container + ->getDefinition('security.authentication.manager') + ->replaceArgument(0, new IteratorArgument($authenticationProviders)); + } // register an autowire alias for the UserCheckerInterface if no custom user checker service is configured if (!$customUserChecker) { @@ -406,7 +436,35 @@ private function createFirewall(ContainerBuilder $container, string $id, array $ $configuredEntryPoint = isset($firewall['entry_point']) ? $firewall['entry_point'] : null; // Authentication listeners - list($authListeners, $defaultEntryPoint) = $this->createAuthenticationListeners($container, $id, $firewall, $authenticationProviders, $defaultProvider, $providerIds, $configuredEntryPoint, $contextListenerId); + $firewallAuthenticationProviders = []; + list($authListeners, $defaultEntryPoint) = $this->createAuthenticationListeners($container, $id, $firewall, $firewallAuthenticationProviders, $defaultProvider, $providerIds, $configuredEntryPoint, $contextListenerId); + + if (!$this->authenticatorManagerEnabled) { + $authenticationProviders = array_merge($authenticationProviders, $firewallAuthenticationProviders); + } else { + // authenticator manager + $authenticators = array_map(function ($id) { + return new Reference($id); + }, $firewallAuthenticationProviders); + $container + ->setDefinition($managerId = 'security.authenticator.manager.'.$id, new ChildDefinition('security.authenticator.manager')) + ->replaceArgument(0, $authenticators) + ->replaceArgument(2, new Reference($firewallEventDispatcherId)) + ->replaceArgument(3, $id) + ->addTag('monolog.logger', ['channel' => 'security']) + ; + + $managerLocator = $container->getDefinition('security.authenticator.managers_locator'); + $managerLocator->replaceArgument(0, array_merge($managerLocator->getArgument(0), [$id => new ServiceClosureArgument(new Reference($managerId))])); + + // authenticator manager listener + $container + ->setDefinition('security.firewall.authenticator.'.$id, new ChildDefinition('security.firewall.authenticator')) + ->replaceArgument(0, new Reference($managerId)) + ; + + $listeners[] = new Reference('security.firewall.authenticator.'.$id); + } $config->replaceArgument(7, $configuredEntryPoint ?: $defaultEntryPoint); @@ -467,31 +525,31 @@ private function createAuthenticationListeners(ContainerBuilder $container, stri $key = str_replace('-', '_', $factory->getKey()); if (isset($firewall[$key])) { - if (isset($firewall[$key]['provider'])) { - if (!isset($providerIds[$normalizedName = str_replace('-', '_', $firewall[$key]['provider'])])) { - throw new InvalidConfigurationException(sprintf('Invalid firewall "%s": user provider "%s" not found.', $id, $firewall[$key]['provider'])); + $userProvider = $this->getUserProvider($container, $id, $firewall, $key, $defaultProvider, $providerIds, $contextListenerId); + + if ($this->authenticatorManagerEnabled) { + if (!$factory instanceof AuthenticatorFactoryInterface) { + throw new InvalidConfigurationException(sprintf('Cannot configure AuthenticatorManager as "%s" authentication does not support it, set "security.enable_authenticator_manager" to `false`.', $key)); } - $userProvider = $providerIds[$normalizedName]; - } elseif ('remember_me' === $key || 'anonymous' === $key) { - // RememberMeFactory will use the firewall secret when created, AnonymousAuthenticationListener does not load users. - $userProvider = null; - if ('remember_me' === $key && $contextListenerId) { - $container->getDefinition($contextListenerId)->addTag('security.remember_me_aware', ['id' => $id, 'provider' => 'none']); + $authenticators = $factory->createAuthenticator($container, $id, $firewall[$key], $userProvider); + if (\is_array($authenticators)) { + foreach ($authenticators as $i => $authenticator) { + $authenticationProviders[] = $authenticator; + } + } else { + $authenticationProviders[] = $authenticators; } - } elseif ($defaultProvider) { - $userProvider = $defaultProvider; - } elseif (empty($providerIds)) { - $userProvider = sprintf('security.user.provider.missing.%s', $key); - $container->setDefinition($userProvider, (new ChildDefinition('security.user.provider.missing'))->replaceArgument(0, $id)); - } else { - 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.', $key, $id)); - } - list($provider, $listenerId, $defaultEntryPoint) = $factory->create($container, $id, $firewall[$key], $userProvider, $defaultEntryPoint); + if ($factory instanceof EntryPointFactoryInterface) { + $defaultEntryPoint = $factory->createEntryPoint($container, $id, $firewall[$key], $defaultEntryPoint); + } + } else { + list($provider, $listenerId, $defaultEntryPoint) = $factory->create($container, $id, $firewall[$key], $userProvider, $defaultEntryPoint); - $listeners[] = new Reference($listenerId); - $authenticationProviders[] = $provider; + $listeners[] = new Reference($listenerId); + $authenticationProviders[] = $provider; + } $hasListeners = true; } } @@ -504,6 +562,41 @@ private function createAuthenticationListeners(ContainerBuilder $container, stri return [$listeners, $defaultEntryPoint]; } + 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'])])) { + throw new InvalidConfigurationException(sprintf('Invalid firewall "%s": user provider "%s" not found.', $id, $firewall[$factoryKey]['provider'])); + } + + return $providerIds[$normalizedName]; + } + + if ('remember_me' === $factoryKey && $contextListenerId) { + $container->getDefinition($contextListenerId)->addTag('security.remember_me_aware', ['id' => $id, 'provider' => 'none']); + } + + if ($defaultProvider) { + return $defaultProvider; + } + + if (!$providerIds) { + $userProvider = sprintf('security.user.provider.missing.%s', $factoryKey); + $container->setDefinition( + $userProvider, + (new ChildDefinition('security.user.provider.missing'))->replaceArgument(0, $id) + ); + + 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)); + } + private function createEncoders(array $encoders, ContainerBuilder $container) { $encoderMap = []; diff --git a/src/Symfony/Bundle/SecurityBundle/EventListener/FirewallEventBubblingListener.php b/src/Symfony/Bundle/SecurityBundle/EventListener/FirewallEventBubblingListener.php index c3415ccc8c84..38f819c44f9b 100644 --- a/src/Symfony/Bundle/SecurityBundle/EventListener/FirewallEventBubblingListener.php +++ b/src/Symfony/Bundle/SecurityBundle/EventListener/FirewallEventBubblingListener.php @@ -12,7 +12,10 @@ namespace Symfony\Bundle\SecurityBundle\EventListener; use Symfony\Component\EventDispatcher\EventSubscriberInterface; +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\VerifyAuthenticatorCredentialsEvent; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; /** @@ -34,6 +37,9 @@ public static function getSubscribedEvents(): array { return [ LogoutEvent::class => 'bubbleEvent', + LoginFailureEvent::class => 'bubbleEvent', + LoginSuccessEvent::class => 'bubbleEvent', + VerifyAuthenticatorCredentialsEvent::class => 'bubbleEvent', ]; } diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/guard.xml b/src/Symfony/Bundle/SecurityBundle/Resources/config/guard.xml index 7b17aff868c4..c9bb06d17987 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/guard.xml +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/guard.xml @@ -17,7 +17,7 @@ - + diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/security.xml b/src/Symfony/Bundle/SecurityBundle/Resources/config/security.xml index 7219210597ee..26da33731212 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/security.xml +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/security.xml @@ -45,15 +45,6 @@ - - - %security.authentication.manager.erase_credentials% - - - - - - diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator.xml b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator.xml new file mode 100644 index 000000000000..07ca362b0325 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator.xml @@ -0,0 +1,154 @@ + + + + + + + + + authenticators + + + provider key + + %security.authentication.manager.erase_credentials% + + + + + + + + + + + + + + + + + + authenticator manager + + + + + + + + + + + + + + + + + + + + + + stateless firewall keys + + + + + + + + + + remember me services + + + + + + + + realm name + user provider + + + + + + user provider + authentication success handler + authentication failure handler + options + + + + + user provider + authentication success handler + authentication failure handler + options + + + + + secret + + + + + remember me services + %kernel.secret% + + options + + + + + + user provider + + firewall name + user key + credentials key + + + + + + user provider + + firewall name + user key + + + + diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_legacy.xml b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_legacy.xml new file mode 100644 index 000000000000..85d672a078da --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_legacy.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + %security.authentication.manager.erase_credentials% + + + + + + + diff --git a/src/Symfony/Bundle/SecurityBundle/Security/UserAuthenticator.php b/src/Symfony/Bundle/SecurityBundle/Security/UserAuthenticator.php new file mode 100644 index 000000000000..ab2dded7989a --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Security/UserAuthenticator.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\SecurityBundle\Security; + +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; + +/** + * A decorator that delegates all method calls to the authenticator + * manager of the current firewall. + * + * @author Wouter de Jong + * + * @final + * @experimental in Symfony 5.1 + */ +class UserAuthenticator implements UserAuthenticatorInterface +{ + private $firewallMap; + private $userAuthenticators; + private $requestStack; + + public function __construct(FirewallMap $firewallMap, ContainerInterface $userAuthenticators, RequestStack $requestStack) + { + $this->firewallMap = $firewallMap; + $this->userAuthenticators = $userAuthenticators; + $this->requestStack = $requestStack; + } + + public function authenticateUser(UserInterface $user, AuthenticatorInterface $authenticator, Request $request): ?Response + { + return $this->getUserAuthenticator()->authenticateUser($user, $authenticator, $request); + } + + private function getUserAuthenticator(): UserAuthenticatorInterface + { + $firewallConfig = $this->firewallMap->getFirewallConfig($this->requestStack->getMasterRequest()); + 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()); + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php b/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php index b3243c83d7da..d8e6590736a3 100644 --- a/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php +++ b/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php @@ -17,6 +17,7 @@ use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\RegisterCsrfTokenClearingLogoutHandlerPass; use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\RegisterTokenUsageTrackingPass; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\AnonymousFactory; +use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\CustomAuthenticatorFactory; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\FormLoginFactory; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\FormLoginLdapFactory; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\GuardAuthenticationFactory; @@ -63,6 +64,7 @@ public function build(ContainerBuilder $container) $extension->addSecurityListenerFactory(new RemoteUserFactory()); $extension->addSecurityListenerFactory(new GuardAuthenticationFactory()); $extension->addSecurityListenerFactory(new AnonymousFactory()); + $extension->addSecurityListenerFactory(new CustomAuthenticatorFactory()); $extension->addUserProviderFactory(new InMemoryFactory()); $extension->addUserProviderFactory(new LdapFactory()); diff --git a/src/Symfony/Component/Security/CHANGELOG.md b/src/Symfony/Component/Security/CHANGELOG.md index da0d2cb8aa28..adf023eac358 100644 --- a/src/Symfony/Component/Security/CHANGELOG.md +++ b/src/Symfony/Component/Security/CHANGELOG.md @@ -9,6 +9,7 @@ CHANGELOG * Hash the persistent RememberMe token value in database. * Added `LogoutEvent` to allow custom logout listeners. * Deprecated `LogoutSuccessHandlerInterface` and `LogoutHandlerInterface` in favor of listening on the `LogoutEvent`. + * Added experimental new security using `Http\Authenticator\AuthenticatorInterface`, `Http\Authentication\AuthenticatorManager` and `Http\Firewall\AuthenticatorManagerListener`. 5.0.0 ----- diff --git a/src/Symfony/Component/Security/Http/Authentication/AuthenticatorManager.php b/src/Symfony/Component/Security/Http/Authentication/AuthenticatorManager.php new file mode 100644 index 000000000000..1d6e1ff2ac5d --- /dev/null +++ b/src/Symfony/Component/Security/Http/Authentication/AuthenticatorManager.php @@ -0,0 +1,242 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Authentication; + +use Psr\Log\LoggerInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\AuthenticationEvents; +use Symfony\Component\Security\Core\Event\AuthenticationSuccessEvent; +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\Authenticator\InteractiveAuthenticatorInterface; +use Symfony\Component\Security\Http\Authenticator\Passport\AnonymousPassport; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\BadgeInterface; +use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface; +use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; +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\VerifyAuthenticatorCredentialsEvent; +use Symfony\Component\Security\Http\SecurityEvents; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; + +/** + * @author Wouter de Jong + * @author Ryan Weaver + * @author Amaury Leroux de Lens + * + * @experimental in 5.1 + */ +class AuthenticatorManager implements AuthenticatorManagerInterface, UserAuthenticatorInterface +{ + private $authenticators; + private $tokenStorage; + private $eventDispatcher; + private $eraseCredentials; + private $logger; + private $firewallName; + + /** + * @param AuthenticatorInterface[] $authenticators + */ + public function __construct(iterable $authenticators, TokenStorageInterface $tokenStorage, EventDispatcherInterface $eventDispatcher, string $firewallName, ?LoggerInterface $logger = null, bool $eraseCredentials = true) + { + $this->authenticators = $authenticators; + $this->tokenStorage = $tokenStorage; + $this->eventDispatcher = $eventDispatcher; + $this->firewallName = $firewallName; + $this->logger = $logger; + $this->eraseCredentials = $eraseCredentials; + } + + /** + * @param BadgeInterface[] $badges Optionally, pass some Passport badges to use for the manual login + */ + public function authenticateUser(UserInterface $user, AuthenticatorInterface $authenticator, Request $request, array $badges = []): ?Response + { + // create an authenticated token for the User + $token = $authenticator->createAuthenticatedToken($passport = new SelfValidatingPassport($user, $badges), $this->firewallName); + + // authenticate this in the system + return $this->handleAuthenticationSuccess($token, $passport, $request, $authenticator); + } + + public function supports(Request $request): ?bool + { + if (null !== $this->logger) { + $context = ['firewall_key' => $this->firewallName]; + + if ($this->authenticators instanceof \Countable || \is_array($this->authenticators)) { + $context['authenticators'] = \count($this->authenticators); + } + + $this->logger->debug('Checking for authenticator support.', $context); + } + + $authenticators = []; + $lazy = true; + foreach ($this->authenticators as $authenticator) { + if (null !== $this->logger) { + $this->logger->debug('Checking support on authenticator.', ['firewall_key' => $this->firewallName, 'authenticator' => \get_class($authenticator)]); + } + + if (false !== $supports = $authenticator->supports($request)) { + $authenticators[] = $authenticator; + $lazy = $lazy && null === $supports; + } elseif (null !== $this->logger) { + $this->logger->debug('Authenticator does not support the request.', ['firewall_key' => $this->firewallName, 'authenticator' => \get_class($authenticator)]); + } + } + + if (!$authenticators) { + return false; + } + + $request->attributes->set('_security_authenticators', $authenticators); + + return $lazy ? null : true; + } + + public function authenticateRequest(Request $request): ?Response + { + $authenticators = $request->attributes->get('_security_authenticators'); + $request->attributes->remove('_security_authenticators'); + if (!$authenticators) { + return null; + } + + return $this->executeAuthenticators($authenticators, $request); + } + + /** + * @param AuthenticatorInterface[] $authenticators + */ + private function executeAuthenticators(array $authenticators, Request $request): ?Response + { + foreach ($authenticators as $authenticator) { + // recheck if the authenticator still supports the listener. supports() is called + // eagerly (before token storage is initialized), whereas authenticate() is called + // lazily (after initialization). This is important for e.g. the AnonymousAuthenticator + // as its support is relying on the (initialized) token in the TokenStorage. + if (false === $authenticator->supports($request)) { + if (null !== $this->logger) { + $this->logger->debug('Skipping the "{authenticator}" authenticator as it did not support the request.', ['authenticator' => \get_class($authenticator)]); + } + + continue; + } + + $response = $this->executeAuthenticator($authenticator, $request); + if (null !== $response) { + if (null !== $this->logger) { + $this->logger->debug('The "{authenticator}" authenticator set the response. Any later authenticator will not be called', ['authenticator' => \get_class($authenticator)]); + } + + return $response; + } + } + + return null; + } + + private function executeAuthenticator(AuthenticatorInterface $authenticator, Request $request): ?Response + { + try { + // get the passport from the Authenticator + $passport = $authenticator->authenticate($request); + + // check the passport (e.g. password checking) + $event = new VerifyAuthenticatorCredentialsEvent($authenticator, $passport); + $this->eventDispatcher->dispatch($event); + + // check if all badges are resolved + $passport->checkIfCompletelyResolved(); + + // create the authenticated token + $authenticatedToken = $authenticator->createAuthenticatedToken($passport, $this->firewallName); + if (true === $this->eraseCredentials) { + $authenticatedToken->eraseCredentials(); + } + + if (null !== $this->eventDispatcher) { + $this->eventDispatcher->dispatch(new AuthenticationSuccessEvent($authenticatedToken), AuthenticationEvents::AUTHENTICATION_SUCCESS); + } + + if (null !== $this->logger) { + $this->logger->info('Authenticator successful!', ['token' => $authenticatedToken, 'authenticator' => \get_class($authenticator)]); + } + + // success! (sets the token on the token storage, etc) + $response = $this->handleAuthenticationSuccess($authenticatedToken, $passport, $request, $authenticator); + if ($response instanceof Response) { + return $response; + } + + if (null !== $this->logger) { + $this->logger->debug('Authenticator set no success response: request continues.', ['authenticator' => \get_class($authenticator)]); + } + + return null; + } catch (AuthenticationException $e) { + // oh no! Authentication failed! + $response = $this->handleAuthenticationFailure($e, $request, $authenticator); + if ($response instanceof Response) { + return $response; + } + + return null; + } + } + + private function handleAuthenticationSuccess(TokenInterface $authenticatedToken, PassportInterface $passport, Request $request, AuthenticatorInterface $authenticator): ?Response + { + $this->tokenStorage->setToken($authenticatedToken); + + $response = $authenticator->onAuthenticationSuccess($request, $authenticatedToken, $this->firewallName); + if ($authenticator instanceof InteractiveAuthenticatorInterface && $authenticator->isInteractive()) { + $loginEvent = new InteractiveLoginEvent($request, $authenticatedToken); + $this->eventDispatcher->dispatch($loginEvent, SecurityEvents::INTERACTIVE_LOGIN); + } + + if ($passport instanceof AnonymousPassport) { + return $response; + } + + $this->eventDispatcher->dispatch($loginSuccessEvent = new LoginSuccessEvent($authenticator, $passport, $authenticatedToken, $request, $response, $this->firewallName)); + + return $loginSuccessEvent->getResponse(); + } + + /** + * Handles an authentication failure and returns the Response for the authenticator. + */ + private function handleAuthenticationFailure(AuthenticationException $authenticationException, Request $request, AuthenticatorInterface $authenticator): ?Response + { + if (null !== $this->logger) { + $this->logger->info('Authenticator failed.', ['exception' => $authenticationException, 'authenticator' => \get_class($authenticator)]); + } + + $response = $authenticator->onAuthenticationFailure($request, $authenticationException); + if (null !== $response && null !== $this->logger) { + $this->logger->debug('The "{authenticator}" authenticator set the failure response.', ['authenticator' => \get_class($authenticator)]); + } + + $this->eventDispatcher->dispatch($loginFailureEvent = new LoginFailureEvent($authenticationException, $authenticator, $request, $response, $this->firewallName)); + + // returning null is ok, it means they want the request to continue + return $loginFailureEvent->getResponse(); + } +} diff --git a/src/Symfony/Component/Security/Http/Authentication/AuthenticatorManagerInterface.php b/src/Symfony/Component/Security/Http/Authentication/AuthenticatorManagerInterface.php new file mode 100644 index 000000000000..89bcef8b528f --- /dev/null +++ b/src/Symfony/Component/Security/Http/Authentication/AuthenticatorManagerInterface.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Authentication; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Security\Http\Firewall\AbstractListener; + +/** + * @author Wouter de Jong + * @author Ryan Weaver + * + * @experimental in Symfony 5.1 + */ +interface AuthenticatorManagerInterface +{ + /** + * Called to see if authentication should be attempted on this request. + * + * @see AbstractListener::supports() + */ + public function supports(Request $request): ?bool; + + /** + * Tries to authenticate the request and returns a response - if any authenticator set one. + */ + public function authenticateRequest(Request $request): ?Response; +} diff --git a/src/Symfony/Component/Security/Http/Authentication/NoopAuthenticationManager.php b/src/Symfony/Component/Security/Http/Authentication/NoopAuthenticationManager.php new file mode 100644 index 000000000000..1a6efeb37901 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Authentication/NoopAuthenticationManager.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Authentication; + +use Symfony\Component\Security\Core\Authentication\AuthenticationManagerInterface; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; + +/** + * This class is used when the authenticator system is activated. + * + * This is used to not break AuthenticationChecker and ContextListener when + * using the authenticator system. Once the authenticator system is no longer + * experimental, this class can be used trigger deprecation notices. + * + * @internal + * + * @author Wouter de Jong + */ +class NoopAuthenticationManager implements AuthenticationManagerInterface +{ + public function authenticate(TokenInterface $token) + { + } +} diff --git a/src/Symfony/Component/Security/Http/Authentication/UserAuthenticatorInterface.php b/src/Symfony/Component/Security/Http/Authentication/UserAuthenticatorInterface.php new file mode 100644 index 000000000000..76cb57292184 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Authentication/UserAuthenticatorInterface.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Authentication; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface; + +/** + * @author Wouter de Jong + * + * @experimental in Symfony 5.1 + */ +interface UserAuthenticatorInterface +{ + /** + * Convenience method to manually login a user and return a + * Response *if any* for success. + */ + public function authenticateUser(UserInterface $user, AuthenticatorInterface $authenticator, Request $request): ?Response; +} diff --git a/src/Symfony/Component/Security/Http/Authenticator/AbstractAuthenticator.php b/src/Symfony/Component/Security/Http/Authenticator/AbstractAuthenticator.php new file mode 100644 index 000000000000..6a5ec2f1502e --- /dev/null +++ b/src/Symfony/Component/Security/Http/Authenticator/AbstractAuthenticator.php @@ -0,0 +1,43 @@ + + * + * 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; + +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Exception\LogicException; +use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface; +use Symfony\Component\Security\Http\Authenticator\Passport\UserPassportInterface; +use Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken; + +/** + * An optional base class that creates the necessary tokens for you. + * + * @author Ryan Weaver + * + * @experimental in 5.1 + */ +abstract class AbstractAuthenticator implements AuthenticatorInterface +{ + /** + * Shortcut to create a PostAuthenticationToken for you, if you don't really + * care about which authenticated token you're using. + * + * @return PostAuthenticationToken + */ + public function createAuthenticatedToken(PassportInterface $passport, string $firewallName): TokenInterface + { + if (!$passport instanceof UserPassportInterface) { + throw new LogicException(sprintf('Passport does not contain a user, overwrite "createAuthenticatedToken()" in "%s" to create a custom authenticated token.', \get_class($this))); + } + + return new PostAuthenticationToken($passport->getUser(), $firewallName, $passport->getUser()->getRoles()); + } +} diff --git a/src/Symfony/Component/Security/Http/Authenticator/AbstractLoginFormAuthenticator.php b/src/Symfony/Component/Security/Http/Authenticator/AbstractLoginFormAuthenticator.php new file mode 100644 index 000000000000..f45fb3d07462 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Authenticator/AbstractLoginFormAuthenticator.php @@ -0,0 +1,64 @@ + + * + * 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; + +use Symfony\Component\HttpFoundation\RedirectResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Security\Core\Exception\AuthenticationException; +use Symfony\Component\Security\Core\Security; +use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface; + +/** + * A base class to make form login authentication easier! + * + * @author Ryan Weaver + * + * @experimental in 5.1 + */ +abstract class AbstractLoginFormAuthenticator extends AbstractAuthenticator implements AuthenticationEntryPointInterface, InteractiveAuthenticatorInterface +{ + /** + * Return the URL to the login page. + */ + abstract protected function getLoginUrl(Request $request): string; + + /** + * Override to change what happens after a bad username/password is submitted. + */ + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response + { + if ($request->hasSession()) { + $request->getSession()->set(Security::AUTHENTICATION_ERROR, $exception); + } + + $url = $this->getLoginUrl($request); + + return new RedirectResponse($url); + } + + /** + * Override to control what happens when the user hits a secure page + * but isn't logged in yet. + */ + public function start(Request $request, AuthenticationException $authException = null): Response + { + $url = $this->getLoginUrl($request); + + return new RedirectResponse($url); + } + + public function isInteractive(): bool + { + return true; + } +} diff --git a/src/Symfony/Component/Security/Http/Authenticator/AbstractPreAuthenticatedAuthenticator.php b/src/Symfony/Component/Security/Http/Authenticator/AbstractPreAuthenticatedAuthenticator.php new file mode 100644 index 000000000000..85a578d8c679 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Authenticator/AbstractPreAuthenticatedAuthenticator.php @@ -0,0 +1,128 @@ + + * + * 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; + +use Psr\Log\LoggerInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Security\Core\Authentication\Token\PreAuthenticatedToken; +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\BadCredentialsException; +use Symfony\Component\Security\Core\User\UserProviderInterface; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\PreAuthenticatedUserBadge; +use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface; +use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; + +/** + * The base authenticator for authenticators to use pre-authenticated + * requests (e.g. using certificates). + * + * @author Wouter de Jong + * @author Fabien Potencier + * + * @internal + * @experimental in Symfony 5.1 + */ +abstract class AbstractPreAuthenticatedAuthenticator implements InteractiveAuthenticatorInterface +{ + private $userProvider; + private $tokenStorage; + private $firewallName; + private $logger; + + public function __construct(UserProviderInterface $userProvider, TokenStorageInterface $tokenStorage, string $firewallName, ?LoggerInterface $logger = null) + { + $this->userProvider = $userProvider; + $this->tokenStorage = $tokenStorage; + $this->firewallName = $firewallName; + $this->logger = $logger; + } + + /** + * Returns the username of the pre-authenticated user. + * + * This authenticator is skipped if null is returned or a custom + * BadCredentialsException is thrown. + */ + abstract protected function extractUsername(Request $request): ?string; + + public function supports(Request $request): ?bool + { + try { + $username = $this->extractUsername($request); + } catch (BadCredentialsException $e) { + $this->clearToken($e); + + if (null !== $this->logger) { + $this->logger->debug('Skipping pre-authenticated authenticator as a BadCredentialsException is thrown.', ['exception' => $e, 'authenticator' => static::class]); + } + + return false; + } + + if (null === $username) { + if (null !== $this->logger) { + $this->logger->debug('Skipping pre-authenticated authenticator no username could be extracted.', ['authenticator' => static::class]); + } + + return false; + } + + $request->attributes->set('_pre_authenticated_username', $username); + + return true; + } + + public function authenticate(Request $request): PassportInterface + { + $username = $request->attributes->get('_pre_authenticated_username'); + $user = $this->userProvider->loadUserByUsername($username); + + return new SelfValidatingPassport($user, [new PreAuthenticatedUserBadge()]); + } + + public function createAuthenticatedToken(PassportInterface $passport, string $firewallName): TokenInterface + { + return new PreAuthenticatedToken($passport->getUser(), null, $firewallName, $passport->getUser()->getRoles()); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + return null; // let the original request continue + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response + { + $this->clearToken($exception); + + return null; + } + + public function isInteractive(): bool + { + return true; + } + + private function clearToken(AuthenticationException $exception): void + { + $token = $this->tokenStorage->getToken(); + if ($token instanceof PreAuthenticatedToken && $this->firewallName === $token->getProviderKey()) { + $this->tokenStorage->setToken(null); + + if (null !== $this->logger) { + $this->logger->info('Cleared pre-authenticated token due to an exception.', ['exception' => $exception]); + } + } + } +} diff --git a/src/Symfony/Component/Security/Http/Authenticator/AnonymousAuthenticator.php b/src/Symfony/Component/Security/Http/Authenticator/AnonymousAuthenticator.php new file mode 100644 index 000000000000..c0420b5d4cfe --- /dev/null +++ b/src/Symfony/Component/Security/Http/Authenticator/AnonymousAuthenticator.php @@ -0,0 +1,67 @@ + + * + * 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; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Security\Core\Authentication\Token\AnonymousToken; +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\Http\Authenticator\Passport\AnonymousPassport; +use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface; + +/** + * @author Wouter de Jong + * @author Fabien Potencier + * + * @final + * @experimental in 5.1 + */ +class AnonymousAuthenticator implements AuthenticatorInterface +{ + private $secret; + private $tokenStorage; + + public function __construct(string $secret, TokenStorageInterface $tokenStorage) + { + $this->secret = $secret; + $this->tokenStorage = $tokenStorage; + } + + public function supports(Request $request): ?bool + { + // do not overwrite already stored tokens (i.e. from the session) + // the `null` return value indicates that this authenticator supports lazy firewalls + return null === $this->tokenStorage->getToken() ? null : false; + } + + public function authenticate(Request $request): PassportInterface + { + return new AnonymousPassport(); + } + + public function createAuthenticatedToken(PassportInterface $passport, string $firewallName): TokenInterface + { + return new AnonymousToken($this->secret, 'anon.', []); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + return null; // let the original request continue + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response + { + return null; + } +} diff --git a/src/Symfony/Component/Security/Http/Authenticator/AuthenticatorInterface.php b/src/Symfony/Component/Security/Http/Authenticator/AuthenticatorInterface.php new file mode 100644 index 000000000000..e1f2b21f70a0 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Authenticator/AuthenticatorInterface.php @@ -0,0 +1,89 @@ + + * + * 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; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Exception\AuthenticationException; +use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface; + +/** + * The interface for all authenticators. + * + * @author Ryan Weaver + * @author Amaury Leroux de Lens + * @author Wouter de Jong + * + * @experimental in 5.1 + */ +interface AuthenticatorInterface +{ + /** + * Does the authenticator support the given Request? + * + * If this returns false, the authenticator will be skipped. + * + * Returning null means authenticate() can be called lazily when accessing the token storage. + */ + public function supports(Request $request): ?bool; + + /** + * Create a passport for the current request. + * + * The passport contains the user, credentials and any additional information + * that has to be checked by the Symfony Security system. For example, a login + * form authenticator will probably return a passport containing the user, the + * presented password and the CSRF token value. + * + * You may throw any AuthenticationException in this method in case of error (e.g. + * a UsernameNotFoundException when the user cannot be found). + * + * @throws AuthenticationException + */ + public function authenticate(Request $request): PassportInterface; + + /** + * Create an authenticated token for the given user. + * + * If you don't care about which token class is used or don't really + * understand what a "token" is, you can skip this method by extending + * the AbstractAuthenticator class from your authenticator. + * + * @see AbstractAuthenticator + * + * @param PassportInterface $passport The passport returned from authenticate() + */ + public function createAuthenticatedToken(PassportInterface $passport, string $firewallName): TokenInterface; + + /** + * Called when authentication executed and was successful! + * + * This should return the Response sent back to the user, like a + * RedirectResponse to the last page they visited. + * + * If you return null, the current request will continue, and the user + * will be authenticated. This makes sense, for example, with an API. + */ + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response; + + /** + * Called when authentication executed, but failed (e.g. wrong username password). + * + * This should return the Response sent back to the user, like a + * RedirectResponse to the login page or a 403 response. + * + * If you return null, the request will continue, but the user will + * not be authenticated. This is probably not what you want to do. + */ + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response; +} diff --git a/src/Symfony/Component/Security/Http/Authenticator/FormLoginAuthenticator.php b/src/Symfony/Component/Security/Http/Authenticator/FormLoginAuthenticator.php new file mode 100644 index 000000000000..31cab7afcd35 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Authenticator/FormLoginAuthenticator.php @@ -0,0 +1,145 @@ + + * + * 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; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; +use Symfony\Component\Security\Core\Exception\AuthenticationException; +use Symfony\Component\Security\Core\Exception\AuthenticationServiceException; +use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\Security\Core\Security; +use Symfony\Component\Security\Core\User\PasswordUpgraderInterface; +use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\Security\Core\User\UserProviderInterface; +use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface; +use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\PasswordUpgradeBadge; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\RememberMeBadge; +use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials; +use Symfony\Component\Security\Http\Authenticator\Passport\Passport; +use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface; +use Symfony\Component\Security\Http\HttpUtils; +use Symfony\Component\Security\Http\ParameterBagUtils; + +/** + * @author Wouter de Jong + * @author Fabien Potencier + * + * @final + * @experimental in 5.1 + */ +class FormLoginAuthenticator extends AbstractLoginFormAuthenticator +{ + private $httpUtils; + private $userProvider; + private $successHandler; + private $failureHandler; + private $options; + + public function __construct(HttpUtils $httpUtils, UserProviderInterface $userProvider, AuthenticationSuccessHandlerInterface $successHandler, AuthenticationFailureHandlerInterface $failureHandler, array $options) + { + $this->httpUtils = $httpUtils; + $this->userProvider = $userProvider; + $this->successHandler = $successHandler; + $this->failureHandler = $failureHandler; + $this->options = array_merge([ + 'username_parameter' => '_username', + 'password_parameter' => '_password', + 'check_path' => '/login_check', + 'post_only' => true, + 'enable_csrf' => false, + 'csrf_parameter' => '_csrf_token', + 'csrf_token_id' => 'authenticate', + ], $options); + } + + protected function getLoginUrl(Request $request): string + { + return $this->httpUtils->generateUri($request, $this->options['login_path']); + } + + public function supports(Request $request): bool + { + return ($this->options['post_only'] ? $request->isMethod('POST') : true) + && $this->httpUtils->checkRequestPath($request, $this->options['check_path']); + } + + public function authenticate(Request $request): PassportInterface + { + $credentials = $this->getCredentials($request); + $user = $this->userProvider->loadUserByUsername($credentials['username']); + if (!$user instanceof UserInterface) { + throw new AuthenticationServiceException('The user provider must return a UserInterface object.'); + } + + $passport = new Passport($user, new PasswordCredentials($credentials['password']), [new RememberMeBadge()]); + if ($this->options['enable_csrf']) { + $passport->addBadge(new CsrfTokenBadge($this->options['csrf_token_id'], $credentials['csrf_token'])); + } + + if ($this->userProvider instanceof PasswordUpgraderInterface) { + $passport->addBadge(new PasswordUpgradeBadge($credentials['password'], $this->userProvider)); + } + + return $passport; + } + + /** + * @param Passport $passport + */ + public function createAuthenticatedToken(PassportInterface $passport, $firewallName): TokenInterface + { + return new UsernamePasswordToken($passport->getUser(), null, $firewallName, $passport->getUser()->getRoles()); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + return $this->successHandler->onAuthenticationSuccess($request, $token); + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response + { + return $this->failureHandler->onAuthenticationFailure($request, $exception); + } + + private function getCredentials(Request $request): array + { + $credentials = []; + $credentials['csrf_token'] = ParameterBagUtils::getRequestParameterValue($request, $this->options['csrf_parameter']); + + if ($this->options['post_only']) { + $credentials['username'] = ParameterBagUtils::getParameterBagValue($request->request, $this->options['username_parameter']); + $credentials['password'] = ParameterBagUtils::getParameterBagValue($request->request, $this->options['password_parameter']) ?? ''; + } else { + $credentials['username'] = ParameterBagUtils::getRequestParameterValue($request, $this->options['username_parameter']); + $credentials['password'] = ParameterBagUtils::getRequestParameterValue($request, $this->options['password_parameter']) ?? ''; + } + + if (!\is_string($credentials['username']) && (!\is_object($credentials['username']) || !method_exists($credentials['username'], '__toString'))) { + throw new BadRequestHttpException(sprintf('The key "%s" must be a string, "%s" given.', $this->options['username_parameter'], \gettype($credentials['username']))); + } + + $credentials['username'] = trim($credentials['username']); + + if (\strlen($credentials['username']) > Security::MAX_USERNAME_LENGTH) { + throw new BadCredentialsException('Invalid username.'); + } + + $request->getSession()->set(Security::LAST_USERNAME, $credentials['username']); + + return $credentials; + } +} diff --git a/src/Symfony/Component/Security/Http/Authenticator/HttpBasicAuthenticator.php b/src/Symfony/Component/Security/Http/Authenticator/HttpBasicAuthenticator.php new file mode 100644 index 000000000000..e4c7af251e8c --- /dev/null +++ b/src/Symfony/Component/Security/Http/Authenticator/HttpBasicAuthenticator.php @@ -0,0 +1,103 @@ + + * + * 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; + +use Psr\Log\LoggerInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; +use Symfony\Component\Security\Core\Exception\AuthenticationException; +use Symfony\Component\Security\Core\Exception\AuthenticationServiceException; +use Symfony\Component\Security\Core\User\PasswordUpgraderInterface; +use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\Security\Core\User\UserProviderInterface; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\PasswordUpgradeBadge; +use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials; +use Symfony\Component\Security\Http\Authenticator\Passport\Passport; +use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface; +use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface; + +/** + * @author Wouter de Jong + * @author Fabien Potencier + * + * @final + * @experimental in 5.1 + */ +class HttpBasicAuthenticator implements AuthenticatorInterface, AuthenticationEntryPointInterface +{ + private $realmName; + private $userProvider; + private $logger; + + public function __construct(string $realmName, UserProviderInterface $userProvider, ?LoggerInterface $logger = null) + { + $this->realmName = $realmName; + $this->userProvider = $userProvider; + $this->logger = $logger; + } + + public function start(Request $request, AuthenticationException $authException = null) + { + $response = new Response(); + $response->headers->set('WWW-Authenticate', sprintf('Basic realm="%s"', $this->realmName)); + $response->setStatusCode(401); + + return $response; + } + + public function supports(Request $request): ?bool + { + return $request->headers->has('PHP_AUTH_USER'); + } + + public function authenticate(Request $request): PassportInterface + { + $username = $request->headers->get('PHP_AUTH_USER'); + $password = $request->headers->get('PHP_AUTH_PW', ''); + + $user = $this->userProvider->loadUserByUsername($username); + if (!$user instanceof UserInterface) { + throw new AuthenticationServiceException('The user provider must return a UserInterface object.'); + } + + $passport = new Passport($user, new PasswordCredentials($password)); + if ($this->userProvider instanceof PasswordUpgraderInterface) { + $passport->addBadge(new PasswordUpgradeBadge($password, $this->userProvider)); + } + + return $passport; + } + + /** + * @param Passport $passport + */ + public function createAuthenticatedToken(PassportInterface $passport, $firewallName): TokenInterface + { + return new UsernamePasswordToken($passport->getUser(), null, $firewallName, $passport->getUser()->getRoles()); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, $firewallName): ?Response + { + return null; + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response + { + if (null !== $this->logger) { + $this->logger->info('Basic authentication failed for user.', ['username' => $request->headers->get('PHP_AUTH_USER'), 'exception' => $exception]); + } + + return $this->start($request, $exception); + } +} diff --git a/src/Symfony/Component/Security/Http/Authenticator/InteractiveAuthenticatorInterface.php b/src/Symfony/Component/Security/Http/Authenticator/InteractiveAuthenticatorInterface.php new file mode 100644 index 000000000000..7f26d8260683 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Authenticator/InteractiveAuthenticatorInterface.php @@ -0,0 +1,31 @@ + + * + * 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 is an extension of the authenticator interface that must + * be used by interactive authenticators. + * + * Interactive login requires explicit user action (e.g. a login + * form or HTTP basic authentication). Implementing this interface + * will dispatcher the InteractiveLoginEvent upon successful login. + * + * @author Wouter de Jong + */ +interface InteractiveAuthenticatorInterface extends AuthenticatorInterface +{ + /** + * Should return true to make this authenticator perform + * an interactive login. + */ + public function isInteractive(): bool; +} diff --git a/src/Symfony/Component/Security/Http/Authenticator/JsonLoginAuthenticator.php b/src/Symfony/Component/Security/Http/Authenticator/JsonLoginAuthenticator.php new file mode 100644 index 000000000000..d165fbceb191 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Authenticator/JsonLoginAuthenticator.php @@ -0,0 +1,158 @@ + + * + * 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; + +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\PropertyAccess\Exception\AccessException; +use Symfony\Component\PropertyAccess\PropertyAccess; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; +use Symfony\Component\Security\Core\Exception\AuthenticationException; +use Symfony\Component\Security\Core\Exception\AuthenticationServiceException; +use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\Security\Core\Security; +use Symfony\Component\Security\Core\User\PasswordUpgraderInterface; +use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\Security\Core\User\UserProviderInterface; +use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface; +use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\PasswordUpgradeBadge; +use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials; +use Symfony\Component\Security\Http\Authenticator\Passport\Passport; +use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface; +use Symfony\Component\Security\Http\HttpUtils; + +/** + * Provides a stateless implementation of an authentication via + * a JSON document composed of a username and a password. + * + * @author Kévin Dunglas + * @author Wouter de Jong + * + * @final + * @experimental in 5.1 + */ +class JsonLoginAuthenticator implements InteractiveAuthenticatorInterface +{ + private $options; + private $httpUtils; + private $userProvider; + private $propertyAccessor; + private $successHandler; + private $failureHandler; + + public function __construct(HttpUtils $httpUtils, UserProviderInterface $userProvider, ?AuthenticationSuccessHandlerInterface $successHandler = null, ?AuthenticationFailureHandlerInterface $failureHandler = null, array $options = [], ?PropertyAccessorInterface $propertyAccessor = null) + { + $this->options = array_merge(['username_path' => 'username', 'password_path' => 'password'], $options); + $this->httpUtils = $httpUtils; + $this->successHandler = $successHandler; + $this->failureHandler = $failureHandler; + $this->userProvider = $userProvider; + $this->propertyAccessor = $propertyAccessor ?: PropertyAccess::createPropertyAccessor(); + } + + public function supports(Request $request): ?bool + { + if (false === strpos($request->getRequestFormat(), 'json') && false === strpos($request->getContentType(), 'json')) { + return false; + } + + if (isset($this->options['check_path']) && !$this->httpUtils->checkRequestPath($request, $this->options['check_path'])) { + return false; + } + + return true; + } + + public function authenticate(Request $request): PassportInterface + { + $credentials = $this->getCredentials($request); + $user = $this->userProvider->loadUserByUsername($credentials['username']); + if (!$user instanceof UserInterface) { + throw new AuthenticationServiceException('The user provider must return a UserInterface object.'); + } + + $passport = new Passport($user, new PasswordCredentials($credentials['password'])); + if ($this->userProvider instanceof PasswordUpgraderInterface) { + $passport->addBadge(new PasswordUpgradeBadge($credentials['password'], $this->userProvider)); + } + + return $passport; + } + + public function createAuthenticatedToken(PassportInterface $passport, string $firewallName): TokenInterface + { + return new UsernamePasswordToken($passport->getUser(), null, $firewallName, $passport->getUser()->getRoles()); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + if (null === $this->successHandler) { + return null; // let the original request continue + } + + return $this->successHandler->onAuthenticationSuccess($request, $token); + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response + { + if (null === $this->failureHandler) { + return new JsonResponse(['error' => $exception->getMessageKey()], JsonResponse::HTTP_UNAUTHORIZED); + } + + return $this->failureHandler->onAuthenticationFailure($request, $exception); + } + + public function isInteractive(): bool + { + return true; + } + + private function getCredentials(Request $request) + { + $data = json_decode($request->getContent()); + if (!$data instanceof \stdClass) { + throw new BadRequestHttpException('Invalid JSON.'); + } + + $credentials = []; + try { + $credentials['username'] = $this->propertyAccessor->getValue($data, $this->options['username_path']); + + if (!\is_string($credentials['username'])) { + throw new BadRequestHttpException(sprintf('The key "%s" must be a string.', $this->options['username_path'])); + } + + if (\strlen($credentials['username']) > Security::MAX_USERNAME_LENGTH) { + throw new BadCredentialsException('Invalid username.'); + } + } catch (AccessException $e) { + throw new BadRequestHttpException(sprintf('The key "%s" must be provided.', $this->options['username_path']), $e); + } + + try { + $credentials['password'] = $this->propertyAccessor->getValue($data, $this->options['password_path']); + + if (!\is_string($credentials['password'])) { + throw new BadRequestHttpException(sprintf('The key "%s" must be a string.', $this->options['password_path'])); + } + } catch (AccessException $e) { + throw new BadRequestHttpException(sprintf('The key "%s" must be provided.', $this->options['password_path']), $e); + } + + return $credentials; + } +} diff --git a/src/Symfony/Component/Security/Http/Authenticator/Passport/AnonymousPassport.php b/src/Symfony/Component/Security/Http/Authenticator/Passport/AnonymousPassport.php new file mode 100644 index 000000000000..7cbc93e65875 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Authenticator/Passport/AnonymousPassport.php @@ -0,0 +1,25 @@ + + * + * 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\Passport; + +/** + * A passport used during anonymous authentication. + * + * @author Wouter de Jong + * + * @internal + * @experimental in 5.1 + */ +class AnonymousPassport implements PassportInterface +{ + use PassportTrait; +} diff --git a/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/BadgeInterface.php b/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/BadgeInterface.php new file mode 100644 index 000000000000..bc9ba7cbb57b --- /dev/null +++ b/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/BadgeInterface.php @@ -0,0 +1,30 @@ + + * + * 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\Passport\Badge; + +/** + * Passport badges allow to add more information to a passport (e.g. a CSRF token). + * + * @author Wouter de Jong + * + * @experimental in 5.1 + */ +interface BadgeInterface +{ + /** + * Checks if this badge is resolved by the security system. + * + * After authentication, all badges must return `true` in this method in order + * for the authentication to succeed. + */ + public function isResolved(): bool; +} diff --git a/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/CsrfTokenBadge.php b/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/CsrfTokenBadge.php new file mode 100644 index 000000000000..9f0b4e5d8965 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/CsrfTokenBadge.php @@ -0,0 +1,65 @@ + + * + * 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\Passport\Badge; + +use Symfony\Component\Security\Http\EventListener\CsrfProtectionListener; + +/** + * Adds automatic CSRF tokens checking capabilities to this authenticator. + * + * @see CsrfProtectionListener + * + * @author Wouter de Jong + * + * @final + * @experimental in5.1 + */ +class CsrfTokenBadge implements BadgeInterface +{ + private $resolved = false; + private $csrfTokenId; + private $csrfToken; + + /** + * @param string $csrfTokenId An arbitrary string used to generate the value of the CSRF token. + * Using a different string for each authenticator improves its security. + * @param string|null $csrfToken The CSRF token presented in the request, if any + */ + public function __construct(string $csrfTokenId, ?string $csrfToken) + { + $this->csrfTokenId = $csrfTokenId; + $this->csrfToken = $csrfToken; + } + + public function getCsrfTokenId(): string + { + return $this->csrfTokenId; + } + + public function getCsrfToken(): string + { + return $this->csrfToken; + } + + /** + * @internal + */ + public function markResolved(): void + { + $this->resolved = true; + } + + public function isResolved(): bool + { + return $this->resolved; + } +} diff --git a/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/PasswordUpgradeBadge.php b/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/PasswordUpgradeBadge.php new file mode 100644 index 000000000000..3812871da005 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/PasswordUpgradeBadge.php @@ -0,0 +1,63 @@ + + * + * 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\Passport\Badge; + +use Symfony\Component\Security\Core\User\PasswordUpgraderInterface; + +/** + * Adds automatic password migration, if enabled and required in the password encoder. + * + * @see PasswordUpgraderInterface + * + * @author Wouter de Jong + * + * @final + * @experimental in 5.1 + */ +class PasswordUpgradeBadge implements BadgeInterface +{ + private $plaintextPassword; + private $passwordUpgrader; + + /** + * @param string $plaintextPassword The presented password, used in the rehash + * @param PasswordUpgraderInterface $passwordUpgrader The password upgrader, usually the UserProvider + */ + public function __construct(string $plaintextPassword, PasswordUpgraderInterface $passwordUpgrader) + { + $this->plaintextPassword = $plaintextPassword; + $this->passwordUpgrader = $passwordUpgrader; + } + + public function getPlaintextPassword(): string + { + return $this->plaintextPassword; + } + + public function getPasswordUpgrader(): PasswordUpgraderInterface + { + return $this->passwordUpgrader; + } + + /** + * @internal + */ + public function eraseCredentials() + { + $this->plaintextPassword = null; + } + + public function isResolved(): bool + { + return true; + } +} diff --git a/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/PreAuthenticatedUserBadge.php b/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/PreAuthenticatedUserBadge.php new file mode 100644 index 000000000000..7e0f33009180 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/PreAuthenticatedUserBadge.php @@ -0,0 +1,34 @@ + + * + * 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\Passport\Badge; + +use Symfony\Component\Security\Http\Authenticator\AbstractPreAuthenticatedAuthenticator; + +/** + * Marks the authentication as being pre-authenticated. + * + * This disables pre-authentication user checkers. + * + * @see AbstractPreAuthenticatedAuthenticator + * + * @author Wouter de Jong + * + * @final + * @experimental in 5.1 + */ +class PreAuthenticatedUserBadge implements BadgeInterface +{ + public function isResolved(): bool + { + return true; + } +} diff --git a/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/RememberMeBadge.php b/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/RememberMeBadge.php new file mode 100644 index 000000000000..dcee820442ee --- /dev/null +++ b/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/RememberMeBadge.php @@ -0,0 +1,37 @@ + + * + * 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\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 + * + * @author Wouter de Jong + * + * @final + * @experimental in 5.1 + */ +class RememberMeBadge implements BadgeInterface +{ + public function isResolved(): bool + { + return true; // remember me does not need to be explicitly resolved + } +} diff --git a/src/Symfony/Component/Security/Http/Authenticator/Passport/Credentials/CredentialsInterface.php b/src/Symfony/Component/Security/Http/Authenticator/Passport/Credentials/CredentialsInterface.php new file mode 100644 index 000000000000..554fe7aff497 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Authenticator/Passport/Credentials/CredentialsInterface.php @@ -0,0 +1,26 @@ + + * + * 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\Passport\Credentials; + +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\BadgeInterface; + +/** + * Credentials are a special badge used to explicitly mark the + * credential check of an authenticator. + * + * @author Wouter de Jong + * + * @experimental in 5.1 + */ +interface CredentialsInterface extends BadgeInterface +{ +} diff --git a/src/Symfony/Component/Security/Http/Authenticator/Passport/Credentials/CustomCredentials.php b/src/Symfony/Component/Security/Http/Authenticator/Passport/Credentials/CustomCredentials.php new file mode 100644 index 000000000000..1a773f8afb31 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Authenticator/Passport/Credentials/CustomCredentials.php @@ -0,0 +1,58 @@ + + * + * 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\Passport\Credentials; + +use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\Security\Core\User\UserInterface; + +/** + * Implements credentials checking using a custom checker function. + * + * @author Wouter de Jong + * + * @final + * @experimental in 5.1 + */ +class CustomCredentials implements CredentialsInterface +{ + private $customCredentialsChecker; + private $credentials; + private $resolved = false; + + /** + * @param callable $customCredentialsChecker the check function. If this function does not return `true`, a + * BadCredentialsException is thrown. You may also throw a more + * specific exception in the function. + * @param $credentials + */ + public function __construct(callable $customCredentialsChecker, $credentials) + { + $this->customCredentialsChecker = $customCredentialsChecker; + $this->credentials = $credentials; + } + + public function executeCustomChecker(UserInterface $user): void + { + $checker = $this->customCredentialsChecker; + + if (true !== $checker($this->credentials, $user)) { + throw new BadCredentialsException('Credentials check failed as the callable passed to CustomCredentials did not return "true".'); + } + + $this->resolved = true; + } + + public function isResolved(): bool + { + return $this->resolved; + } +} diff --git a/src/Symfony/Component/Security/Http/Authenticator/Passport/Credentials/PasswordCredentials.php b/src/Symfony/Component/Security/Http/Authenticator/Passport/Credentials/PasswordCredentials.php new file mode 100644 index 000000000000..7630a67bd78c --- /dev/null +++ b/src/Symfony/Component/Security/Http/Authenticator/Passport/Credentials/PasswordCredentials.php @@ -0,0 +1,59 @@ + + * + * 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\Passport\Credentials; + +use Symfony\Component\Security\Core\Exception\LogicException; + +/** + * Implements password credentials. + * + * These plaintext passwords are checked by the UserPasswordEncoder during + * authentication. + * + * @author Wouter de Jong + * + * @final + * @experimental in 5.1 + */ +class PasswordCredentials implements CredentialsInterface +{ + private $password; + private $resolved = false; + + public function __construct(string $password) + { + $this->password = $password; + } + + public function getPassword(): string + { + if (null === $this->password) { + throw new LogicException('The credentials are erased as another listener already verified these credentials.'); + } + + return $this->password; + } + + /** + * @internal + */ + public function markResolved(): void + { + $this->resolved = true; + $this->password = null; + } + + public function isResolved(): bool + { + return $this->resolved; + } +} diff --git a/src/Symfony/Component/Security/Http/Authenticator/Passport/Passport.php b/src/Symfony/Component/Security/Http/Authenticator/Passport/Passport.php new file mode 100644 index 000000000000..a4ead01d14cd --- /dev/null +++ b/src/Symfony/Component/Security/Http/Authenticator/Passport/Passport.php @@ -0,0 +1,50 @@ + + * + * 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\Passport; + +use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\BadgeInterface; +use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\CredentialsInterface; + +/** + * The default implementation for passports. + * + * @author Wouter de Jong + * + * @experimental in 5.1 + */ +class Passport implements UserPassportInterface +{ + use PassportTrait; + + protected $user; + + /** + * @param CredentialsInterface $credentials the credentials to check for this authentication, use + * SelfValidatingPassport if no credentials should be checked. + * @param BadgeInterface[] $badges + */ + public function __construct(UserInterface $user, CredentialsInterface $credentials, array $badges = []) + { + $this->user = $user; + + $this->addBadge($credentials); + foreach ($badges as $badge) { + $this->addBadge($badge); + } + } + + public function getUser(): UserInterface + { + return $this->user; + } +} diff --git a/src/Symfony/Component/Security/Http/Authenticator/Passport/PassportInterface.php b/src/Symfony/Component/Security/Http/Authenticator/Passport/PassportInterface.php new file mode 100644 index 000000000000..ac7796912756 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Authenticator/Passport/PassportInterface.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Authenticator\Passport; + +use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\BadgeInterface; + +/** + * A Passport contains all security-related information that needs to be + * validated during authentication. + * + * A passport badge can be used to add any additional information to the + * passport. + * + * @author Wouter de Jong + * + * @experimental in 5.1 + */ +interface PassportInterface +{ + /** + * Adds a new security badge. + * + * A passport can hold only one instance of the same security badge. + * This method replaces the current badge if it is already set on this + * passport. + * + * @return $this + */ + public function addBadge(BadgeInterface $badge): self; + + public function hasBadge(string $badgeFqcn): bool; + + public function getBadge(string $badgeFqcn): ?BadgeInterface; + + /** + * Checks if all badges are marked as resolved. + * + * @throws BadCredentialsException when a badge is not marked as resolved + */ + public function checkIfCompletelyResolved(): void; +} diff --git a/src/Symfony/Component/Security/Http/Authenticator/Passport/PassportTrait.php b/src/Symfony/Component/Security/Http/Authenticator/Passport/PassportTrait.php new file mode 100644 index 000000000000..1cdd75546bb7 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Authenticator/Passport/PassportTrait.php @@ -0,0 +1,55 @@ + + * + * 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\Passport; + +use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\Security\Core\Exception\InvalidArgumentException; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\BadgeInterface; + +/** + * @author Wouter de Jong + * + * @experimental in 5.1 + */ +trait PassportTrait +{ + /** + * @var BadgeInterface[] + */ + private $badges = []; + + public function addBadge(BadgeInterface $badge): PassportInterface + { + $this->badges[\get_class($badge)] = $badge; + + return $this; + } + + public function hasBadge(string $badgeFqcn): bool + { + return isset($this->badges[$badgeFqcn]); + } + + public function getBadge(string $badgeFqcn): ?BadgeInterface + { + return $this->badges[$badgeFqcn] ?? null; + } + + public function checkIfCompletelyResolved(): void + { + foreach ($this->badges as $badge) { + if (!$badge->isResolved()) { + throw new BadCredentialsException(sprintf('Authentication failed security badge "%s" is not resolved, did you forget to register the correct listeners?', \get_class($badge))); + } + } + } +} diff --git a/src/Symfony/Component/Security/Http/Authenticator/Passport/SelfValidatingPassport.php b/src/Symfony/Component/Security/Http/Authenticator/Passport/SelfValidatingPassport.php new file mode 100644 index 000000000000..dd3ef6f96218 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Authenticator/Passport/SelfValidatingPassport.php @@ -0,0 +1,34 @@ + + * + * 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\Passport; + +use Symfony\Component\Security\Core\User\UserInterface; + +/** + * An implementation used when there are no credentials to be checked (e.g. + * API token authentication). + * + * @author Wouter de Jong + * + * @experimental in 5.1 + */ +class SelfValidatingPassport extends Passport +{ + public function __construct(UserInterface $user, array $badges = []) + { + $this->user = $user; + + foreach ($badges as $badge) { + $this->addBadge($badge); + } + } +} diff --git a/src/Symfony/Component/Security/Http/Authenticator/Passport/UserPassportInterface.php b/src/Symfony/Component/Security/Http/Authenticator/Passport/UserPassportInterface.php new file mode 100644 index 000000000000..f308c13252b5 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Authenticator/Passport/UserPassportInterface.php @@ -0,0 +1,26 @@ + + * + * 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\Passport; + +use Symfony\Component\Security\Core\User\UserInterface; + +/** + * Represents a passport for a Security User. + * + * @author Wouter de Jong + * + * @experimental in 5.1 + */ +interface UserPassportInterface extends PassportInterface +{ + public function getUser(): UserInterface; +} diff --git a/src/Symfony/Component/Security/Http/Authenticator/RememberMeAuthenticator.php b/src/Symfony/Component/Security/Http/Authenticator/RememberMeAuthenticator.php new file mode 100644 index 000000000000..f5aa016ad15d --- /dev/null +++ b/src/Symfony/Component/Security/Http/Authenticator/RememberMeAuthenticator.php @@ -0,0 +1,100 @@ + + * + * 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; + +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\Http\Authenticator\Passport\PassportInterface; +use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; +use Symfony\Component\Security\Http\RememberMe\AbstractRememberMeServices; +use Symfony\Component\Security\Http\RememberMe\RememberMeServicesInterface; + +/** + * 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 + * @author Wouter de Jong + * + * @final + */ +class RememberMeAuthenticator implements InteractiveAuthenticatorInterface +{ + private $rememberMeServices; + private $secret; + private $tokenStorage; + private $options = []; + + public function __construct(RememberMeServicesInterface $rememberMeServices, string $secret, TokenStorageInterface $tokenStorage, array $options) + { + $this->rememberMeServices = $rememberMeServices; + $this->secret = $secret; + $this->tokenStorage = $tokenStorage; + $this->options = $options; + } + + 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 (isset($this->options['name']) && !$request->cookies->has($this->options['name'])) { + return false; + } + + // the `null` return value indicates that this authenticator supports lazy firewalls + return null; + } + + public function authenticate(Request $request): PassportInterface + { + $token = $this->rememberMeServices->autoLogin($request); + + return new SelfValidatingPassport($token->getUser()); + } + + public function createAuthenticatedToken(PassportInterface $passport, string $firewallName): TokenInterface + { + return new RememberMeToken($passport->getUser(), $firewallName, $this->secret); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + return null; // let the original request continue + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response + { + $this->rememberMeServices->loginFail($request, $exception); + + return null; + } + + public function isInteractive(): bool + { + return true; + } +} diff --git a/src/Symfony/Component/Security/Http/Authenticator/RemoteUserAuthenticator.php b/src/Symfony/Component/Security/Http/Authenticator/RemoteUserAuthenticator.php new file mode 100644 index 000000000000..140b6c271efb --- /dev/null +++ b/src/Symfony/Component/Security/Http/Authenticator/RemoteUserAuthenticator.php @@ -0,0 +1,50 @@ + + * + * 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; + +use Psr\Log\LoggerInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\Security\Core\User\UserProviderInterface; + +/** + * This authenticator authenticates a remote user. + * + * @author Wouter de Jong + * @author Fabien Potencier + * @author Maxime Douailin + * + * @final + * + * @internal in Symfony 5.1 + */ +class RemoteUserAuthenticator extends AbstractPreAuthenticatedAuthenticator +{ + private $userKey; + + public function __construct(UserProviderInterface $userProvider, TokenStorageInterface $tokenStorage, string $firewallName, string $userKey = 'REMOTE_USER', ?LoggerInterface $logger = null) + { + parent::__construct($userProvider, $tokenStorage, $firewallName, $logger); + + $this->userKey = $userKey; + } + + protected function extractUsername(Request $request): ?string + { + if (!$request->server->has($this->userKey)) { + throw new BadCredentialsException(sprintf('User key was not found: "%s".', $this->userKey)); + } + + return $request->server->get($this->userKey); + } +} diff --git a/src/Symfony/Component/Security/Http/Authenticator/Token/PostAuthenticationToken.php b/src/Symfony/Component/Security/Http/Authenticator/Token/PostAuthenticationToken.php new file mode 100644 index 000000000000..774ba60a8603 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Authenticator/Token/PostAuthenticationToken.php @@ -0,0 +1,65 @@ +setUser($user); + $this->firewallName = $firewallName; + + // this token is meant to be used after authentication success, so it is always authenticated + // you could set it as non authenticated later if you need to + $this->setAuthenticated(true); + } + + /** + * This is meant to be only an authenticated token, where credentials + * have already been used and are thus cleared. + * + * {@inheritdoc} + */ + public function getCredentials() + { + return []; + } + + public function getFirewallName(): string + { + return $this->firewallName; + } + + /** + * {@inheritdoc} + */ + public function __serialize(): array + { + return [$this->firewallName, parent::__serialize()]; + } + + /** + * {@inheritdoc} + */ + public function __unserialize(array $data): void + { + [$this->firewallName, $parentData] = $data; + parent::__unserialize($parentData); + } +} diff --git a/src/Symfony/Component/Security/Http/Authenticator/X509Authenticator.php b/src/Symfony/Component/Security/Http/Authenticator/X509Authenticator.php new file mode 100644 index 000000000000..c76f3f94e5f8 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Authenticator/X509Authenticator.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Authenticator; + +use Psr\Log\LoggerInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\Security\Core\User\UserProviderInterface; + +/** + * This authenticator authenticates pre-authenticated (by the + * webserver) X.509 certificates. + * + * @author Wouter de Jong + * @author Fabien Potencier + * + * @final + * @experimental in Symfony 5.1 + */ +class X509Authenticator extends AbstractPreAuthenticatedAuthenticator +{ + private $userKey; + private $credentialsKey; + + public function __construct(UserProviderInterface $userProvider, TokenStorageInterface $tokenStorage, string $firewallName, string $userKey = 'SSL_CLIENT_S_DN_Email', string $credentialsKey = 'SSL_CLIENT_S_DN', ?LoggerInterface $logger = null) + { + parent::__construct($userProvider, $tokenStorage, $firewallName, $logger); + + $this->userKey = $userKey; + $this->credentialsKey = $credentialsKey; + } + + protected function extractUsername(Request $request): string + { + $username = null; + if ($request->server->has($this->userKey)) { + $username = $request->server->get($this->userKey); + } elseif ( + $request->server->has($this->credentialsKey) + && preg_match('#emailAddress=([^,/@]++@[^,/]++)#', $request->server->get($this->credentialsKey), $matches) + ) { + $username = $matches[1]; + } + + if (null === $username) { + throw new BadCredentialsException(sprintf('SSL credentials not found: %s, %s', $this->userKey, $this->credentialsKey)); + } + + return $username; + } +} diff --git a/src/Symfony/Component/Security/Http/Event/LoginFailureEvent.php b/src/Symfony/Component/Security/Http/Event/LoginFailureEvent.php new file mode 100644 index 000000000000..96da4e35ff73 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Event/LoginFailureEvent.php @@ -0,0 +1,65 @@ + + */ +class LoginFailureEvent extends Event +{ + private $exception; + private $authenticator; + private $request; + private $response; + private $firewallName; + + public function __construct(AuthenticationException $exception, AuthenticatorInterface $authenticator, Request $request, ?Response $response, string $firewallName) + { + $this->exception = $exception; + $this->authenticator = $authenticator; + $this->request = $request; + $this->response = $response; + $this->firewallName = $firewallName; + } + + public function getException(): AuthenticationException + { + return $this->exception; + } + + public function getAuthenticator(): AuthenticatorInterface + { + return $this->authenticator; + } + + public function getFirewallName(): string + { + return $this->firewallName; + } + + public function getRequest(): Request + { + return $this->request; + } + + public function setResponse(?Response $response) + { + $this->response = $response; + } + + public function getResponse(): ?Response + { + return $this->response; + } +} diff --git a/src/Symfony/Component/Security/Http/Event/LoginSuccessEvent.php b/src/Symfony/Component/Security/Http/Event/LoginSuccessEvent.php new file mode 100644 index 000000000000..c7eee3a66e74 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Event/LoginSuccessEvent.php @@ -0,0 +1,87 @@ + + */ +class LoginSuccessEvent extends Event +{ + private $authenticator; + private $passport; + private $authenticatedToken; + private $request; + private $response; + private $providerKey; + + public function __construct(AuthenticatorInterface $authenticator, PassportInterface $passport, TokenInterface $authenticatedToken, Request $request, ?Response $response, string $firewallName) + { + $this->authenticator = $authenticator; + $this->passport = $passport; + $this->authenticatedToken = $authenticatedToken; + $this->request = $request; + $this->response = $response; + $this->providerKey = $firewallName; + } + + public function getAuthenticator(): AuthenticatorInterface + { + return $this->authenticator; + } + + public function getPassport(): PassportInterface + { + return $this->passport; + } + + public function getUser(): UserInterface + { + if (!$this->passport instanceof UserPassportInterface) { + throw new LogicException(sprintf('Cannot call "%s" as the authenticator ("%s") did not set a user.', __METHOD__, \get_class($this->authenticator))); + } + + return $this->passport->getUser(); + } + + public function getAuthenticatedToken(): TokenInterface + { + return $this->authenticatedToken; + } + + public function getRequest(): Request + { + return $this->request; + } + + public function getFirewallName(): string + { + return $this->providerKey; + } + + public function setResponse(?Response $response): void + { + $this->response = $response; + } + + public function getResponse(): ?Response + { + return $this->response; + } +} diff --git a/src/Symfony/Component/Security/Http/Event/VerifyAuthenticatorCredentialsEvent.php b/src/Symfony/Component/Security/Http/Event/VerifyAuthenticatorCredentialsEvent.php new file mode 100644 index 000000000000..eac7f0374126 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Event/VerifyAuthenticatorCredentialsEvent.php @@ -0,0 +1,40 @@ + + */ +class VerifyAuthenticatorCredentialsEvent extends Event +{ + private $authenticator; + private $passport; + + public function __construct(AuthenticatorInterface $authenticator, PassportInterface $passport) + { + $this->authenticator = $authenticator; + $this->passport = $passport; + } + + public function getAuthenticator(): AuthenticatorInterface + { + return $this->authenticator; + } + + public function getPassport(): PassportInterface + { + return $this->passport; + } +} diff --git a/src/Symfony/Component/Security/Http/EventListener/CsrfProtectionListener.php b/src/Symfony/Component/Security/Http/EventListener/CsrfProtectionListener.php new file mode 100644 index 000000000000..65c8ffa3e397 --- /dev/null +++ b/src/Symfony/Component/Security/Http/EventListener/CsrfProtectionListener.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\EventListener; + +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\Security\Core\Exception\InvalidCsrfTokenException; +use Symfony\Component\Security\Csrf\CsrfToken; +use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge; +use Symfony\Component\Security\Http\Event\VerifyAuthenticatorCredentialsEvent; + +/** + * @author Wouter de Jong + * + * @final + * @experimental in 5.1 + */ +class CsrfProtectionListener implements EventSubscriberInterface +{ + private $csrfTokenManager; + + public function __construct(CsrfTokenManagerInterface $csrfTokenManager) + { + $this->csrfTokenManager = $csrfTokenManager; + } + + public function verifyCredentials(VerifyAuthenticatorCredentialsEvent $event): void + { + $passport = $event->getPassport(); + if (!$passport->hasBadge(CsrfTokenBadge::class)) { + return; + } + + /** @var CsrfTokenBadge $badge */ + $badge = $passport->getBadge(CsrfTokenBadge::class); + if ($badge->isResolved()) { + return; + } + + $csrfToken = new CsrfToken($badge->getCsrfTokenId(), $badge->getCsrfToken()); + + if (false === $this->csrfTokenManager->isTokenValid($csrfToken)) { + throw new InvalidCsrfTokenException('Invalid CSRF token.'); + } + + $badge->markResolved(); + } + + public static function getSubscribedEvents(): array + { + return [VerifyAuthenticatorCredentialsEvent::class => ['verifyCredentials', 256]]; + } +} diff --git a/src/Symfony/Component/Security/Http/EventListener/PasswordMigratingListener.php b/src/Symfony/Component/Security/Http/EventListener/PasswordMigratingListener.php new file mode 100644 index 000000000000..0d22bf22ca48 --- /dev/null +++ b/src/Symfony/Component/Security/Http/EventListener/PasswordMigratingListener.php @@ -0,0 +1,55 @@ + + * + * @final + * @experimental in 5.1 + */ +class PasswordMigratingListener implements EventSubscriberInterface +{ + private $encoderFactory; + + public function __construct(EncoderFactoryInterface $encoderFactory) + { + $this->encoderFactory = $encoderFactory; + } + + public function onLoginSuccess(LoginSuccessEvent $event): void + { + $passport = $event->getPassport(); + if (!$passport instanceof UserPassportInterface || !$passport->hasBadge(PasswordUpgradeBadge::class)) { + return; + } + + /** @var PasswordUpgradeBadge $badge */ + $badge = $passport->getBadge(PasswordUpgradeBadge::class); + $plaintextPassword = $badge->getPlaintextPassword(); + $badge->eraseCredentials(); + + if ('' === $plaintextPassword) { + return; + } + + $user = $passport->getUser(); + $passwordEncoder = $this->encoderFactory->getEncoder($user); + if (!$passwordEncoder->needsRehash($user->getPassword())) { + return; + } + + $badge->getPasswordUpgrader()->upgradePassword($user, $passwordEncoder->encodePassword($plaintextPassword, $user->getSalt())); + } + + public static function getSubscribedEvents(): array + { + return [LoginSuccessEvent::class => 'onLoginSuccess']; + } +} diff --git a/src/Symfony/Component/Security/Http/EventListener/RememberMeListener.php b/src/Symfony/Component/Security/Http/EventListener/RememberMeListener.php new file mode 100644 index 000000000000..da582a7cc6a5 --- /dev/null +++ b/src/Symfony/Component/Security/Http/EventListener/RememberMeListener.php @@ -0,0 +1,70 @@ + + * + * @final + * @experimental in 5.1 + */ +class RememberMeListener implements EventSubscriberInterface +{ + private $rememberMeServices; + private $logger; + + public function __construct(RememberMeServicesInterface $rememberMeServices, ?LoggerInterface $logger = null) + { + $this->rememberMeServices = $rememberMeServices; + $this->logger = $logger; + } + + public function onSuccessfulLogin(LoginSuccessEvent $event): void + { + $passport = $event->getPassport(); + if (!$passport->hasBadge(RememberMeBadge::class)) { + if (null !== $this->logger) { + $this->logger->debug('Remember me skipped: your authenticator does not support it.', ['authenticator' => \get_class($event->getAuthenticator())]); + } + + return; + } + + if (null === $event->getResponse()) { + if (null !== $this->logger) { + $this->logger->debug('Remember me skipped: the authenticator did not set a success response.', ['authenticator' => \get_class($event->getAuthenticator())]); + } + + return; + } + + $this->rememberMeServices->loginSuccess($event->getRequest(), $event->getResponse(), $event->getAuthenticatedToken()); + } + + public function onFailedLogin(LoginFailureEvent $event): void + { + $this->rememberMeServices->loginFail($event->getRequest(), $event->getException()); + } + + public static function getSubscribedEvents(): array + { + return [ + LoginSuccessEvent::class => 'onSuccessfulLogin', + LoginFailureEvent::class => 'onFailedLogin', + ]; + } +} diff --git a/src/Symfony/Component/Security/Http/EventListener/SessionStrategyListener.php b/src/Symfony/Component/Security/Http/EventListener/SessionStrategyListener.php new file mode 100644 index 000000000000..492316ec63f2 --- /dev/null +++ b/src/Symfony/Component/Security/Http/EventListener/SessionStrategyListener.php @@ -0,0 +1,53 @@ + + * + * 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 Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\Security\Http\Event\LoginSuccessEvent; +use Symfony\Component\Security\Http\Session\SessionAuthenticationStrategy; +use Symfony\Component\Security\Http\Session\SessionAuthenticationStrategyInterface; + +/** + * Migrates/invalidate the session after successful login. + * + * This should be registered as subscriber to any "stateful" firewalls. + * + * @see SessionAuthenticationStrategy + * + * @author Wouter de Jong + */ +class SessionStrategyListener implements EventSubscriberInterface +{ + private $sessionAuthenticationStrategy; + + public function __construct(SessionAuthenticationStrategyInterface $sessionAuthenticationStrategy) + { + $this->sessionAuthenticationStrategy = $sessionAuthenticationStrategy; + } + + public function onSuccessfulLogin(LoginSuccessEvent $event): void + { + $request = $event->getRequest(); + $token = $event->getAuthenticatedToken(); + + if (!$request->hasSession() || !$request->hasPreviousSession()) { + return; + } + + $this->sessionAuthenticationStrategy->onAuthentication($request, $token); + } + + public static function getSubscribedEvents(): array + { + return [LoginSuccessEvent::class => 'onSuccessfulLogin']; + } +} diff --git a/src/Symfony/Component/Security/Http/EventListener/UserCheckerListener.php b/src/Symfony/Component/Security/Http/EventListener/UserCheckerListener.php new file mode 100644 index 000000000000..fbcc0bd549b9 --- /dev/null +++ b/src/Symfony/Component/Security/Http/EventListener/UserCheckerListener.php @@ -0,0 +1,54 @@ + + * + * @final + * @experimental in 5.1 + */ +class UserCheckerListener implements EventSubscriberInterface +{ + private $userChecker; + + public function __construct(UserCheckerInterface $userChecker) + { + $this->userChecker = $userChecker; + } + + public function preCredentialsVerification(VerifyAuthenticatorCredentialsEvent $event): void + { + $passport = $event->getPassport(); + if (!$passport instanceof UserPassportInterface || $passport->hasBadge(PreAuthenticatedUserBadge::class)) { + return; + } + + $this->userChecker->checkPreAuth($passport->getUser()); + } + + public function postCredentialsVerification(LoginSuccessEvent $event): void + { + $passport = $event->getPassport(); + if (!$passport instanceof UserPassportInterface || null === $passport->getUser()) { + return; + } + + $this->userChecker->checkPostAuth($passport->getUser()); + } + + public static function getSubscribedEvents(): array + { + return [ + VerifyAuthenticatorCredentialsEvent::class => [['preCredentialsVerification', 256]], + LoginSuccessEvent::class => ['postCredentialsVerification', 256], + ]; + } +} diff --git a/src/Symfony/Component/Security/Http/EventListener/VerifyAuthenticatorCredentialsListener.php b/src/Symfony/Component/Security/Http/EventListener/VerifyAuthenticatorCredentialsListener.php new file mode 100644 index 000000000000..0287dc4f5d00 --- /dev/null +++ b/src/Symfony/Component/Security/Http/EventListener/VerifyAuthenticatorCredentialsListener.php @@ -0,0 +1,79 @@ + + * + * @final + * @experimental in 5.1 + */ +class VerifyAuthenticatorCredentialsListener implements EventSubscriberInterface +{ + private $encoderFactory; + + public function __construct(EncoderFactoryInterface $encoderFactory) + { + $this->encoderFactory = $encoderFactory; + } + + public function onAuthenticating(VerifyAuthenticatorCredentialsEvent $event): void + { + $passport = $event->getPassport(); + if ($passport instanceof UserPassportInterface && $passport->hasBadge(PasswordCredentials::class)) { + // Use the password encoder to validate the credentials + $user = $passport->getUser(); + /** @var PasswordCredentials $badge */ + $badge = $passport->getBadge(PasswordCredentials::class); + + if ($badge->isResolved()) { + return; + } + + $presentedPassword = $badge->getPassword(); + if ('' === $presentedPassword) { + throw new BadCredentialsException('The presented password cannot be empty.'); + } + + if (null === $user->getPassword()) { + throw new BadCredentialsException('The presented password is invalid.'); + } + + if (!$this->encoderFactory->getEncoder($user)->isPasswordValid($user->getPassword(), $presentedPassword, $user->getSalt())) { + throw new BadCredentialsException('The presented password is invalid.'); + } + + $badge->markResolved(); + + return; + } + + if ($passport->hasBadge(CustomCredentials::class)) { + /** @var CustomCredentials $badge */ + $badge = $passport->getBadge(CustomCredentials::class); + if ($badge->isResolved()) { + return; + } + + $badge->executeCustomChecker($passport->getUser()); + + return; + } + } + + public static function getSubscribedEvents(): array + { + return [VerifyAuthenticatorCredentialsEvent::class => ['onAuthenticating', 128]]; + } +} diff --git a/src/Symfony/Component/Security/Http/Firewall/AuthenticatorManagerListener.php b/src/Symfony/Component/Security/Http/Firewall/AuthenticatorManagerListener.php new file mode 100644 index 000000000000..f30d9b60049c --- /dev/null +++ b/src/Symfony/Component/Security/Http/Firewall/AuthenticatorManagerListener.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Firewall; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Event\RequestEvent; +use Symfony\Component\Security\Http\Authentication\AuthenticatorManagerInterface; + +/** + * Firewall authentication listener that delegates to the authenticator system. + * + * @author Wouter de Jong + * + * @experimental in 5.1 + */ +class AuthenticatorManagerListener extends AbstractListener +{ + private $authenticatorManager; + + public function __construct(AuthenticatorManagerInterface $authenticationManager) + { + $this->authenticatorManager = $authenticationManager; + } + + public function supports(Request $request): ?bool + { + return $this->authenticatorManager->supports($request); + } + + public function authenticate(RequestEvent $event): void + { + $request = $event->getRequest(); + $response = $this->authenticatorManager->authenticateRequest($request); + if (null === $response) { + return; + } + + $event->setResponse($response); + } +} diff --git a/src/Symfony/Component/Security/Http/Tests/Authentication/AuthenticatorManagerTest.php b/src/Symfony/Component/Security/Http/Tests/Authentication/AuthenticatorManagerTest.php new file mode 100644 index 000000000000..2b21b380d376 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/Authentication/AuthenticatorManagerTest.php @@ -0,0 +1,202 @@ + + * + * 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\Authentication; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\EventDispatcher\EventDispatcher; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\Security\Core\User\User; +use Symfony\Component\Security\Http\Authentication\AuthenticatorManager; +use Symfony\Component\Security\Http\Authenticator\InteractiveAuthenticatorInterface; +use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials; +use Symfony\Component\Security\Http\Authenticator\Passport\Passport; +use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; +use Symfony\Component\Security\Http\Event\VerifyAuthenticatorCredentialsEvent; + +class AuthenticatorManagerTest extends TestCase +{ + private $tokenStorage; + private $eventDispatcher; + private $request; + private $user; + private $token; + private $response; + + protected function setUp(): void + { + $this->tokenStorage = $this->createMock(TokenStorageInterface::class); + $this->eventDispatcher = new EventDispatcher(); + $this->request = new Request(); + $this->user = new User('wouter', null); + $this->token = $this->createMock(TokenInterface::class); + $this->response = $this->createMock(Response::class); + } + + /** + * @dataProvider provideSupportsData + */ + public function testSupports($authenticators, $result) + { + $manager = $this->createManager($authenticators); + + $this->assertEquals($result, $manager->supports($this->request)); + } + + public function provideSupportsData() + { + yield [[$this->createAuthenticator(null), $this->createAuthenticator(null)], null]; + yield [[$this->createAuthenticator(null), $this->createAuthenticator(false)], null]; + + yield [[$this->createAuthenticator(null), $this->createAuthenticator(true)], true]; + yield [[$this->createAuthenticator(true), $this->createAuthenticator(false)], true]; + + yield [[$this->createAuthenticator(false), $this->createAuthenticator(false)], false]; + yield [[], false]; + } + + public function testSupportCheckedUponRequestAuthentication() + { + // the attribute stores the supported authenticators, returning false now + // means support changed between calling supports() and authenticateRequest() + // (which is the case with lazy firewalls and e.g. the AnonymousAuthenticator) + $authenticator = $this->createAuthenticator(false); + $this->request->attributes->set('_security_authenticators', [$authenticator]); + + $authenticator->expects($this->never())->method('authenticate'); + + $manager = $this->createManager([$authenticator]); + $manager->authenticateRequest($this->request); + } + + /** + * @dataProvider provideMatchingAuthenticatorIndex + */ + public function testAuthenticateRequest($matchingAuthenticatorIndex) + { + $authenticators = [$this->createAuthenticator(0 === $matchingAuthenticatorIndex), $this->createAuthenticator(1 === $matchingAuthenticatorIndex)]; + $this->request->attributes->set('_security_authenticators', $authenticators); + $matchingAuthenticator = $authenticators[$matchingAuthenticatorIndex]; + + $authenticators[($matchingAuthenticatorIndex + 1) % 2]->expects($this->never())->method('authenticate'); + + $matchingAuthenticator->expects($this->any())->method('authenticate')->willReturn(new SelfValidatingPassport($this->user)); + + $listenerCalled = false; + $this->eventDispatcher->addListener(VerifyAuthenticatorCredentialsEvent::class, function (VerifyAuthenticatorCredentialsEvent $event) use (&$listenerCalled, $matchingAuthenticator) { + if ($event->getAuthenticator() === $matchingAuthenticator && $event->getPassport()->getUser() === $this->user) { + $listenerCalled = true; + } + }); + $matchingAuthenticator->expects($this->any())->method('createAuthenticatedToken')->willReturn($this->token); + + $this->tokenStorage->expects($this->once())->method('setToken')->with($this->token); + + $manager = $this->createManager($authenticators); + $this->assertNull($manager->authenticateRequest($this->request)); + $this->assertTrue($listenerCalled, 'The VerifyAuthenticatorCredentialsEvent listener is not called'); + } + + public function provideMatchingAuthenticatorIndex() + { + yield [0]; + yield [1]; + } + + public function testNoCredentialsValidated() + { + $authenticator = $this->createAuthenticator(); + $this->request->attributes->set('_security_authenticators', [$authenticator]); + + $authenticator->expects($this->any())->method('authenticate')->willReturn(new Passport($this->user, new PasswordCredentials('pass'))); + + $authenticator->expects($this->once()) + ->method('onAuthenticationFailure') + ->with($this->request, $this->isInstanceOf(BadCredentialsException::class)); + + $manager = $this->createManager([$authenticator]); + $manager->authenticateRequest($this->request); + } + + /** + * @dataProvider provideEraseCredentialsData + */ + public function testEraseCredentials($eraseCredentials) + { + $authenticator = $this->createAuthenticator(); + $this->request->attributes->set('_security_authenticators', [$authenticator]); + + $authenticator->expects($this->any())->method('authenticate')->willReturn(new SelfValidatingPassport($this->user)); + + $authenticator->expects($this->any())->method('createAuthenticatedToken')->willReturn($this->token); + + $this->token->expects($eraseCredentials ? $this->once() : $this->never())->method('eraseCredentials'); + + $manager = $this->createManager([$authenticator], 'main', $eraseCredentials); + $manager->authenticateRequest($this->request); + } + + public function provideEraseCredentialsData() + { + yield [true]; + yield [false]; + } + + public function testAuthenticateUser() + { + $authenticator = $this->createAuthenticator(); + $authenticator->expects($this->any())->method('createAuthenticatedToken')->willReturn($this->token); + $authenticator->expects($this->any())->method('onAuthenticationSuccess')->willReturn($this->response); + + $this->tokenStorage->expects($this->once())->method('setToken')->with($this->token); + + $manager = $this->createManager([$authenticator]); + $manager->authenticateUser($this->user, $authenticator, $this->request); + } + + public function testInteractiveAuthenticator() + { + $authenticator = $this->createMock(InteractiveAuthenticatorInterface::class); + $authenticator->expects($this->any())->method('isInteractive')->willReturn(true); + $this->request->attributes->set('_security_authenticators', [$authenticator]); + + $authenticator->expects($this->any())->method('authenticate')->willReturn(new SelfValidatingPassport($this->user)); + $authenticator->expects($this->any())->method('createAuthenticatedToken')->willReturn($this->token); + + $this->tokenStorage->expects($this->once())->method('setToken')->with($this->token); + + $authenticator->expects($this->any()) + ->method('onAuthenticationSuccess') + ->with($this->anything(), $this->token, 'main') + ->willReturn($this->response); + + $manager = $this->createManager([$authenticator]); + $response = $manager->authenticateRequest($this->request); + $this->assertSame($this->response, $response); + } + + private function createAuthenticator($supports = true) + { + $authenticator = $this->createMock(InteractiveAuthenticatorInterface::class); + $authenticator->expects($this->any())->method('supports')->willReturn($supports); + + return $authenticator; + } + + private function createManager($authenticators, $providerKey = 'main', $eraseCredentials = true) + { + return new AuthenticatorManager($authenticators, $this->tokenStorage, $this->eventDispatcher, $providerKey, null, $eraseCredentials); + } +} diff --git a/src/Symfony/Component/Security/Http/Tests/Authenticator/AnonymousAuthenticatorTest.php b/src/Symfony/Component/Security/Http/Tests/Authenticator/AnonymousAuthenticatorTest.php new file mode 100644 index 000000000000..d5593bb37509 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/Authenticator/AnonymousAuthenticatorTest.php @@ -0,0 +1,55 @@ + + * + * 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\Authenticator; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Symfony\Component\Security\Http\Authenticator\AnonymousAuthenticator; + +class AnonymousAuthenticatorTest extends TestCase +{ + private $tokenStorage; + private $authenticator; + private $request; + + protected function setUp(): void + { + $this->tokenStorage = $this->createMock(TokenStorageInterface::class); + $this->authenticator = new AnonymousAuthenticator('s3cr3t', $this->tokenStorage); + $this->request = new Request(); + } + + /** + * @dataProvider provideSupportsData + */ + public function testSupports($tokenAlreadyAvailable, $result) + { + $this->tokenStorage->expects($this->any())->method('getToken')->willReturn($tokenAlreadyAvailable ? $this->createMock(TokenStorageInterface::class) : null); + + $this->assertEquals($result, $this->authenticator->supports($this->request)); + } + + public function provideSupportsData() + { + yield [true, null]; + yield [false, false]; + } + + public function testAuthenticatedToken() + { + $token = $this->authenticator->createAuthenticatedToken($this->authenticator->authenticate($this->request), 'main'); + + $this->assertTrue($token->isAuthenticated()); + $this->assertEquals('anon.', $token->getUser()); + } +} diff --git a/src/Symfony/Component/Security/Http/Tests/Authenticator/FormLoginAuthenticatorTest.php b/src/Symfony/Component/Security/Http/Tests/Authenticator/FormLoginAuthenticatorTest.php new file mode 100644 index 000000000000..9ab9055455c9 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/Authenticator/FormLoginAuthenticatorTest.php @@ -0,0 +1,178 @@ + + * + * 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\Authenticator; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\Security\Core\Security; +use Symfony\Component\Security\Core\User\PasswordUpgraderInterface; +use Symfony\Component\Security\Core\User\User; +use Symfony\Component\Security\Core\User\UserProviderInterface; +use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface; +use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface; +use Symfony\Component\Security\Http\Authenticator\FormLoginAuthenticator; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\PasswordUpgradeBadge; +use Symfony\Component\Security\Http\HttpUtils; + +class FormLoginAuthenticatorTest extends TestCase +{ + private $userProvider; + private $successHandler; + private $failureHandler; + /** @var FormLoginAuthenticator */ + private $authenticator; + + protected function setUp(): void + { + $this->userProvider = $this->createMock(UserProviderInterface::class); + $this->userProvider->expects($this->any())->method('loadUserByUsername')->willReturn(new User('test', 's$cr$t')); + $this->successHandler = $this->createMock(AuthenticationSuccessHandlerInterface::class); + $this->failureHandler = $this->createMock(AuthenticationFailureHandlerInterface::class); + } + + /** + * @dataProvider provideUsernamesForLength + */ + public function testHandleWhenUsernameLength($username, $ok) + { + if ($ok) { + $this->expectNotToPerformAssertions(); + } else { + $this->expectException(BadCredentialsException::class); + $this->expectExceptionMessage('Invalid username.'); + } + + $request = Request::create('/login_check', 'POST', ['_username' => $username, '_password' => 's$cr$t']); + $request->setSession($this->createSession()); + + $this->setUpAuthenticator(); + $this->authenticator->authenticate($request); + } + + public function provideUsernamesForLength() + { + yield [str_repeat('x', Security::MAX_USERNAME_LENGTH + 1), false]; + yield [str_repeat('x', Security::MAX_USERNAME_LENGTH - 1), true]; + } + + /** + * @dataProvider postOnlyDataProvider + */ + public function testHandleNonStringUsernameWithArray($postOnly) + { + $this->expectException(BadRequestHttpException::class); + $this->expectExceptionMessage('The key "_username" must be a string, "array" given.'); + + $request = Request::create('/login_check', 'POST', ['_username' => []]); + $request->setSession($this->createSession()); + + $this->setUpAuthenticator(['post_only' => $postOnly]); + $this->authenticator->authenticate($request); + } + + /** + * @dataProvider postOnlyDataProvider + */ + public function testHandleNonStringUsernameWithInt($postOnly) + { + $this->expectException(BadRequestHttpException::class); + $this->expectExceptionMessage('The key "_username" must be a string, "integer" given.'); + + $request = Request::create('/login_check', 'POST', ['_username' => 42]); + $request->setSession($this->createSession()); + + $this->setUpAuthenticator(['post_only' => $postOnly]); + $this->authenticator->authenticate($request); + } + + /** + * @dataProvider postOnlyDataProvider + */ + public function testHandleNonStringUsernameWithObject($postOnly) + { + $this->expectException(BadRequestHttpException::class); + $this->expectExceptionMessage('The key "_username" must be a string, "object" given.'); + + $request = Request::create('/login_check', 'POST', ['_username' => new \stdClass()]); + $request->setSession($this->createSession()); + + $this->setUpAuthenticator(['post_only' => $postOnly]); + $this->authenticator->authenticate($request); + } + + /** + * @dataProvider postOnlyDataProvider + */ + public function testHandleNonStringUsernameWithToString($postOnly) + { + $usernameObject = $this->getMockBuilder(DummyUserClass::class)->getMock(); + $usernameObject->expects($this->once())->method('__toString')->willReturn('someUsername'); + + $request = Request::create('/login_check', 'POST', ['_username' => $usernameObject, '_password' => 's$cr$t']); + $request->setSession($this->createSession()); + + $this->setUpAuthenticator(['post_only' => $postOnly]); + $this->authenticator->authenticate($request); + } + + public function postOnlyDataProvider() + { + yield [true]; + yield [false]; + } + + public function testCsrfProtection() + { + $request = Request::create('/login_check', 'POST', ['_username' => 'wouter', '_password' => 's$cr$t']); + $request->setSession($this->createSession()); + + $this->setUpAuthenticator(['enable_csrf' => true]); + $passport = $this->authenticator->authenticate($request); + $this->assertTrue($passport->hasBadge(CsrfTokenBadge::class)); + } + + public function testUpgradePassword() + { + $request = Request::create('/login_check', 'POST', ['_username' => 'wouter', '_password' => 's$cr$t']); + $request->setSession($this->createSession()); + + $this->userProvider = $this->createMock([UserProviderInterface::class, PasswordUpgraderInterface::class]); + $this->userProvider->expects($this->any())->method('loadUserByUsername')->willReturn(new User('test', 's$cr$t')); + + $this->setUpAuthenticator(); + $passport = $this->authenticator->authenticate($request); + $this->assertTrue($passport->hasBadge(PasswordUpgradeBadge::class)); + $badge = $passport->getBadge(PasswordUpgradeBadge::class); + $this->assertEquals('s$cr$t', $badge->getPlaintextPassword()); + } + + private function setUpAuthenticator(array $options = []) + { + $this->authenticator = new FormLoginAuthenticator(new HttpUtils(), $this->userProvider, $this->successHandler, $this->failureHandler, $options); + } + + private function createSession() + { + return $this->createMock('Symfony\Component\HttpFoundation\Session\SessionInterface'); + } +} + +class DummyUserClass +{ + public function __toString(): string + { + return ''; + } +} diff --git a/src/Symfony/Component/Security/Http/Tests/Authenticator/HttpBasicAuthenticatorTest.php b/src/Symfony/Component/Security/Http/Tests/Authenticator/HttpBasicAuthenticatorTest.php new file mode 100644 index 000000000000..693eb320ab2d --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/Authenticator/HttpBasicAuthenticatorTest.php @@ -0,0 +1,89 @@ +userProvider = $this->getMockBuilder(UserProviderInterface::class)->getMock(); + $this->encoderFactory = $this->getMockBuilder(EncoderFactoryInterface::class)->getMock(); + $this->encoder = $this->getMockBuilder(PasswordEncoderInterface::class)->getMock(); + $this->encoderFactory + ->expects($this->any()) + ->method('getEncoder') + ->willReturn($this->encoder); + + $this->authenticator = new HttpBasicAuthenticator('test', $this->userProvider); + } + + public function testExtractCredentialsAndUserFromRequest() + { + $request = new Request([], [], [], [], [], [ + 'PHP_AUTH_USER' => 'TheUsername', + 'PHP_AUTH_PW' => 'ThePassword', + ]); + + $this->userProvider + ->expects($this->any()) + ->method('loadUserByUsername') + ->with('TheUsername') + ->willReturn($user = new User('TheUsername', 'ThePassword')); + + $passport = $this->authenticator->authenticate($request); + $this->assertEquals('ThePassword', $passport->getBadge(PasswordCredentials::class)->getPassword()); + + $this->assertSame($user, $passport->getUser()); + } + + /** + * @dataProvider provideMissingHttpBasicServerParameters + */ + public function testHttpBasicServerParametersMissing(array $serverParameters) + { + $request = new Request([], [], [], [], [], $serverParameters); + + $this->assertFalse($this->authenticator->supports($request)); + } + + public function provideMissingHttpBasicServerParameters() + { + return [ + [[]], + [['PHP_AUTH_PW' => 'ThePassword']], + ]; + } + + public function testUpgradePassword() + { + $request = new Request([], [], [], [], [], [ + 'PHP_AUTH_USER' => 'TheUsername', + 'PHP_AUTH_PW' => 'ThePassword', + ]); + + $this->userProvider = $this->createMock([UserProviderInterface::class, PasswordUpgraderInterface::class]); + $this->userProvider->expects($this->any())->method('loadUserByUsername')->willReturn(new User('test', 's$cr$t')); + $authenticator = new HttpBasicAuthenticator('test', $this->userProvider); + + $passport = $authenticator->authenticate($request); + $this->assertTrue($passport->hasBadge(PasswordUpgradeBadge::class)); + $badge = $passport->getBadge(PasswordUpgradeBadge::class); + $this->assertEquals('ThePassword', $badge->getPlaintextPassword()); + } +} diff --git a/src/Symfony/Component/Security/Http/Tests/Authenticator/JsonLoginAuthenticatorTest.php b/src/Symfony/Component/Security/Http/Tests/Authenticator/JsonLoginAuthenticatorTest.php new file mode 100644 index 000000000000..0f1967600aa4 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/Authenticator/JsonLoginAuthenticatorTest.php @@ -0,0 +1,135 @@ + + * + * 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\Authenticator; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\Security\Core\Security; +use Symfony\Component\Security\Core\User\User; +use Symfony\Component\Security\Core\User\UserProviderInterface; +use Symfony\Component\Security\Http\Authenticator\JsonLoginAuthenticator; +use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials; +use Symfony\Component\Security\Http\HttpUtils; + +class JsonLoginAuthenticatorTest extends TestCase +{ + private $userProvider; + /** @var JsonLoginAuthenticator */ + private $authenticator; + + protected function setUp(): void + { + $this->userProvider = $this->createMock(UserProviderInterface::class); + } + + /** + * @dataProvider provideSupportData + */ + public function testSupport($request) + { + $this->setUpAuthenticator(); + + $this->assertTrue($this->authenticator->supports($request)); + } + + public function provideSupportData() + { + yield [new Request([], [], [], [], [], ['HTTP_CONTENT_TYPE' => 'application/json'], '{"username": "dunglas", "password": "foo"}')]; + + $request = new Request([], [], [], [], [], [], '{"username": "dunglas", "password": "foo"}'); + $request->setRequestFormat('json-ld'); + yield [$request]; + } + + /** + * @dataProvider provideSupportsWithCheckPathData + */ + public function testSupportsWithCheckPath($request, $result) + { + $this->setUpAuthenticator(['check_path' => '/api/login']); + + $this->assertSame($result, $this->authenticator->supports($request)); + } + + public function provideSupportsWithCheckPathData() + { + yield [Request::create('/api/login', 'GET', [], [], [], ['HTTP_CONTENT_TYPE' => 'application/json']), true]; + yield [Request::create('/login', 'GET', [], [], [], ['HTTP_CONTENT_TYPE' => 'application/json']), false]; + } + + public function testAuthenticate() + { + $this->setUpAuthenticator(); + + $this->userProvider->expects($this->once())->method('loadUserByUsername')->with('dunglas')->willReturn(new User('dunglas', 'pa$$')); + + $request = new Request([], [], [], [], [], ['HTTP_CONTENT_TYPE' => 'application/json'], '{"username": "dunglas", "password": "foo"}'); + $passport = $this->authenticator->authenticate($request); + $this->assertEquals('foo', $passport->getBadge(PasswordCredentials::class)->getPassword()); + } + + public function testAuthenticateWithCustomPath() + { + $this->setUpAuthenticator([ + 'username_path' => 'authentication.username', + 'password_path' => 'authentication.password', + ]); + + $this->userProvider->expects($this->once())->method('loadUserByUsername')->with('dunglas')->willReturn(new User('dunglas', 'pa$$')); + + $request = new Request([], [], [], [], [], ['HTTP_CONTENT_TYPE' => 'application/json'], '{"authentication": {"username": "dunglas", "password": "foo"}}'); + $passport = $this->authenticator->authenticate($request); + $this->assertEquals('foo', $passport->getBadge(PasswordCredentials::class)->getPassword()); + } + + /** + * @dataProvider provideInvalidAuthenticateData + */ + public function testAuthenticateInvalid($request, $errorMessage, $exceptionType = BadRequestHttpException::class) + { + $this->expectException($exceptionType); + $this->expectExceptionMessage($errorMessage); + + $this->setUpAuthenticator(); + + $this->authenticator->authenticate($request); + } + + public function provideInvalidAuthenticateData() + { + $request = new Request([], [], [], [], [], ['HTTP_CONTENT_TYPE' => 'application/json']); + yield [$request, 'Invalid JSON.']; + + $request = new Request([], [], [], [], [], ['HTTP_CONTENT_TYPE' => 'application/json'], '{"usr": "dunglas", "password": "foo"}'); + yield [$request, 'The key "username" must be provided']; + + $request = new Request([], [], [], [], [], ['HTTP_CONTENT_TYPE' => 'application/json'], '{"username": "dunglas", "pass": "foo"}'); + yield [$request, 'The key "password" must be provided']; + + $request = new Request([], [], [], [], [], ['HTTP_CONTENT_TYPE' => 'application/json'], '{"username": 1, "password": "foo"}'); + yield [$request, 'The key "username" must be a string.']; + + $request = new Request([], [], [], [], [], ['HTTP_CONTENT_TYPE' => 'application/json'], '{"username": "dunglas", "password": 1}'); + yield [$request, 'The key "password" must be a string.']; + + $username = str_repeat('x', Security::MAX_USERNAME_LENGTH + 1); + $request = new Request([], [], [], [], [], ['HTTP_CONTENT_TYPE' => 'application/json'], sprintf('{"username": "%s", "password": 1}', $username)); + yield [$request, 'Invalid username.', BadCredentialsException::class]; + } + + private function setUpAuthenticator(array $options = []) + { + $this->authenticator = new JsonLoginAuthenticator(new HttpUtils(), $this->userProvider, null, null, $options); + } +} diff --git a/src/Symfony/Component/Security/Http/Tests/Authenticator/RememberMeAuthenticatorTest.php b/src/Symfony/Component/Security/Http/Tests/Authenticator/RememberMeAuthenticatorTest.php new file mode 100644 index 000000000000..d95e68128132 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/Authenticator/RememberMeAuthenticatorTest.php @@ -0,0 +1,86 @@ + + * + * 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\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\User\User; +use Symfony\Component\Security\Http\Authenticator\RememberMeAuthenticator; +use Symfony\Component\Security\Http\RememberMe\AbstractRememberMeServices; +use Symfony\Component\Security\Http\RememberMe\RememberMeServicesInterface; + +class RememberMeAuthenticatorTest extends TestCase +{ + private $rememberMeServices; + 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->request->cookies->set('_remember_me_cookie', $val = $this->generateCookieValue()); + $this->request->attributes->set(AbstractRememberMeServices::COOKIE_ATTR_NAME, new Cookie('_remember_me_cookie', $val)); + } + + public function testSupportsTokenStorageWithToken() + { + $this->tokenStorage->expects($this->any())->method('getToken')->willReturn(TokenInterface::class); + + $this->assertFalse($this->authenticator->supports($this->request)); + } + + public function testSupportsRequestWithoutAttribute() + { + $this->request->attributes->remove(AbstractRememberMeServices::COOKIE_ATTR_NAME); + + $this->assertNull($this->authenticator->supports($this->request)); + } + + public function testSupportsRequestWithoutCookie() + { + $this->request->cookies->remove('_remember_me_cookie'); + + $this->assertFalse($this->authenticator->supports($this->request)); + } + + public function testSupports() + { + $this->assertNull($this->authenticator->supports($this->request)); + } + + public function testAuthenticate() + { + $this->rememberMeServices->expects($this->once()) + ->method('autoLogin') + ->with($this->request) + ->willReturn(new RememberMeToken($user = new User('wouter', 'test'), 'main', 'secret')); + + $passport = $this->authenticator->authenticate($this->request); + + $this->assertSame($user, $passport->getUser()); + } + + private function generateCookieValue() + { + return base64_encode(implode(AbstractRememberMeServices::COOKIE_DELIMITER, ['part1', 'part2'])); + } +} diff --git a/src/Symfony/Component/Security/Http/Tests/Authenticator/RemoteUserAuthenticatorTest.php b/src/Symfony/Component/Security/Http/Tests/Authenticator/RemoteUserAuthenticatorTest.php new file mode 100644 index 000000000000..f55c72abff5e --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/Authenticator/RemoteUserAuthenticatorTest.php @@ -0,0 +1,71 @@ + + * + * 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\Authenticator; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage; +use Symfony\Component\Security\Core\User\User; +use Symfony\Component\Security\Core\User\UserProviderInterface; +use Symfony\Component\Security\Http\Authenticator\RemoteUserAuthenticator; + +class RemoteUserAuthenticatorTest extends TestCase +{ + /** + * @dataProvider provideAuthenticators + */ + public function testSupport(UserProviderInterface $userProvider, RemoteUserAuthenticator $authenticator, $parameterName) + { + $request = $this->createRequest([$parameterName => 'TheUsername']); + + $this->assertTrue($authenticator->supports($request)); + } + + public function testSupportNoUser() + { + $authenticator = new RemoteUserAuthenticator($this->createMock(UserProviderInterface::class), new TokenStorage(), 'main'); + + $this->assertFalse($authenticator->supports($this->createRequest([]))); + } + + /** + * @dataProvider provideAuthenticators + */ + public function testAuthenticate(UserProviderInterface $userProvider, RemoteUserAuthenticator $authenticator, $parameterName) + { + $request = $this->createRequest([$parameterName => 'TheUsername']); + + $authenticator->supports($request); + + $userProvider->expects($this->once()) + ->method('loadUserByUsername') + ->with('TheUsername') + ->willReturn($user = new User('TheUsername', null)); + + $passport = $authenticator->authenticate($request); + $this->assertEquals($user, $passport->getUser()); + } + + public function provideAuthenticators() + { + $userProvider = $this->createMock(UserProviderInterface::class); + yield [$userProvider, new RemoteUserAuthenticator($userProvider, new TokenStorage(), 'main'), 'REMOTE_USER']; + + $userProvider = $this->createMock(UserProviderInterface::class); + yield [$userProvider, new RemoteUserAuthenticator($userProvider, new TokenStorage(), 'main', 'CUSTOM_USER_PARAMETER'), 'CUSTOM_USER_PARAMETER']; + } + + private function createRequest(array $server) + { + return new Request([], [], [], [], [], $server); + } +} diff --git a/src/Symfony/Component/Security/Http/Tests/Authenticator/X509AuthenticatorTest.php b/src/Symfony/Component/Security/Http/Tests/Authenticator/X509AuthenticatorTest.php new file mode 100644 index 000000000000..2490f9d04298 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/Authenticator/X509AuthenticatorTest.php @@ -0,0 +1,135 @@ + + * + * 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\Authenticator; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage; +use Symfony\Component\Security\Core\User\User; +use Symfony\Component\Security\Core\User\UserProviderInterface; +use Symfony\Component\Security\Http\Authenticator\X509Authenticator; + +class X509AuthenticatorTest extends TestCase +{ + private $userProvider; + private $authenticator; + + protected function setUp(): void + { + $this->userProvider = $this->createMock(UserProviderInterface::class); + $this->authenticator = new X509Authenticator($this->userProvider, new TokenStorage(), 'main'); + } + + /** + * @dataProvider provideServerVars + */ + public function testAuthentication($user, $credentials) + { + $serverVars = []; + if ('' !== $user) { + $serverVars['SSL_CLIENT_S_DN_Email'] = $user; + } + if ('' !== $credentials) { + $serverVars['SSL_CLIENT_S_DN'] = $credentials; + } + + $request = $this->createRequest($serverVars); + $this->assertTrue($this->authenticator->supports($request)); + + $this->userProvider->expects($this->once()) + ->method('loadUserByUsername') + ->with($user) + ->willReturn(new User($user, null)); + + $this->authenticator->authenticate($request); + } + + public static function provideServerVars() + { + yield ['TheUser', 'TheCredentials']; + yield ['TheUser', '']; + } + + /** + * @dataProvider provideServerVarsNoUser + */ + public function testAuthenticationNoUser($emailAddress, $credentials) + { + $request = $this->createRequest(['SSL_CLIENT_S_DN' => $credentials]); + + $this->assertTrue($this->authenticator->supports($request)); + + $this->userProvider->expects($this->once()) + ->method('loadUserByUsername') + ->with($emailAddress) + ->willReturn(new User($emailAddress, null)); + + $this->authenticator->authenticate($request); + } + + public static function provideServerVarsNoUser() + { + yield ['cert@example.com', 'CN=Sample certificate DN/emailAddress=cert@example.com']; + yield ['cert+something@example.com', 'CN=Sample certificate DN/emailAddress=cert+something@example.com']; + yield ['cert@example.com', 'CN=Sample certificate DN,emailAddress=cert@example.com']; + yield ['cert+something@example.com', 'CN=Sample certificate DN,emailAddress=cert+something@example.com']; + yield ['cert+something@example.com', 'emailAddress=cert+something@example.com,CN=Sample certificate DN']; + yield ['cert+something@example.com', 'emailAddress=cert+something@example.com']; + yield ['firstname.lastname@mycompany.co.uk', 'emailAddress=firstname.lastname@mycompany.co.uk,CN=Firstname.Lastname,OU=london,OU=company design and engineering,OU=Issuer London,OU=Roaming,OU=Interactive,OU=Users,OU=Standard,OU=Business,DC=england,DC=core,DC=company,DC=co,DC=uk']; + } + + public function testSupportNoData() + { + $request = $this->createRequest([]); + + $this->assertFalse($this->authenticator->supports($request)); + } + + public function testAuthenticationCustomUserKey() + { + $authenticator = new X509Authenticator($this->userProvider, new TokenStorage(), 'main', 'TheUserKey'); + + $request = $this->createRequest([ + 'TheUserKey' => 'TheUser', + ]); + $this->assertTrue($authenticator->supports($request)); + + $this->userProvider->expects($this->once()) + ->method('loadUserByUsername') + ->with('TheUser') + ->willReturn(new User('TheUser', null)); + + $authenticator->authenticate($request); + } + + public function testAuthenticationCustomCredentialsKey() + { + $authenticator = new X509Authenticator($this->userProvider, new TokenStorage(), 'main', 'SSL_CLIENT_S_DN_Email', 'TheCertKey'); + + $request = $this->createRequest([ + 'TheCertKey' => 'CN=Sample certificate DN/emailAddress=cert@example.com', + ]); + $this->assertTrue($authenticator->supports($request)); + + $this->userProvider->expects($this->once()) + ->method('loadUserByUsername') + ->with('cert@example.com') + ->willReturn(new User('cert@example.com', null)); + + $authenticator->authenticate($request); + } + + private function createRequest(array $server) + { + return new Request([], [], [], [], [], $server); + } +} diff --git a/src/Symfony/Component/Security/Http/Tests/EventListener/CsrfProtectionListenerTest.php b/src/Symfony/Component/Security/Http/Tests/EventListener/CsrfProtectionListenerTest.php new file mode 100644 index 000000000000..baca526bfe20 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/EventListener/CsrfProtectionListenerTest.php @@ -0,0 +1,85 @@ + + * + * 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\Security\Core\Exception\InvalidCsrfTokenException; +use Symfony\Component\Security\Core\User\User; +use Symfony\Component\Security\Csrf\CsrfToken; +use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; +use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge; +use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; +use Symfony\Component\Security\Http\Event\VerifyAuthenticatorCredentialsEvent; +use Symfony\Component\Security\Http\EventListener\CsrfProtectionListener; + +class CsrfProtectionListenerTest extends TestCase +{ + private $csrfTokenManager; + private $listener; + + protected function setUp(): void + { + $this->csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class); + $this->listener = new CsrfProtectionListener($this->csrfTokenManager); + } + + public function testNoCsrfTokenBadge() + { + $this->csrfTokenManager->expects($this->never())->method('isTokenValid'); + + $event = $this->createEvent($this->createPassport(null)); + $this->listener->verifyCredentials($event); + } + + public function testValidCsrfToken() + { + $this->csrfTokenManager->expects($this->any()) + ->method('isTokenValid') + ->with(new CsrfToken('authenticator_token_id', 'abc123')) + ->willReturn(true); + + $event = $this->createEvent($this->createPassport(new CsrfTokenBadge('authenticator_token_id', 'abc123'))); + $this->listener->verifyCredentials($event); + + $this->expectNotToPerformAssertions(); + } + + public function testInvalidCsrfToken() + { + $this->expectException(InvalidCsrfTokenException::class); + $this->expectExceptionMessage('Invalid CSRF token.'); + + $this->csrfTokenManager->expects($this->any()) + ->method('isTokenValid') + ->with(new CsrfToken('authenticator_token_id', 'abc123')) + ->willReturn(false); + + $event = $this->createEvent($this->createPassport(new CsrfTokenBadge('authenticator_token_id', 'abc123'))); + $this->listener->verifyCredentials($event); + } + + private function createEvent($passport) + { + return new VerifyAuthenticatorCredentialsEvent($this->createMock(AuthenticatorInterface::class), $passport); + } + + private function createPassport(?CsrfTokenBadge $badge) + { + $passport = new SelfValidatingPassport(new User('wouter', 'pass')); + if ($badge) { + $passport->addBadge($badge); + } + + return $passport; + } +} diff --git a/src/Symfony/Component/Security/Http/Tests/EventListener/PasswordMigratingListenerTest.php b/src/Symfony/Component/Security/Http/Tests/EventListener/PasswordMigratingListenerTest.php new file mode 100644 index 000000000000..5b08721e469c --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/EventListener/PasswordMigratingListenerTest.php @@ -0,0 +1,92 @@ + + * + * 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\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface; +use Symfony\Component\Security\Core\Encoder\PasswordEncoderInterface; +use Symfony\Component\Security\Core\User\PasswordUpgraderInterface; +use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\PasswordUpgradeBadge; +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\PasswordMigratingListener; + +class PasswordMigratingListenerTest extends TestCase +{ + private $encoderFactory; + private $listener; + private $user; + + protected function setUp(): void + { + $this->encoderFactory = $this->createMock(EncoderFactoryInterface::class); + $this->listener = new PasswordMigratingListener($this->encoderFactory); + $this->user = $this->createMock(UserInterface::class); + } + + /** + * @dataProvider provideUnsupportedEvents + */ + public function testUnsupportedEvents($event) + { + $this->encoderFactory->expects($this->never())->method('getEncoder'); + + $this->listener->onLoginSuccess($event); + } + + public function provideUnsupportedEvents() + { + // no password upgrade badge + yield [$this->createEvent(new SelfValidatingPassport($this->createMock(UserInterface::class)))]; + + // blank password + yield [$this->createEvent(new SelfValidatingPassport($this->createMock(UserInterface::class), [new PasswordUpgradeBadge('', $this->createPasswordUpgrader())]))]; + + // no user + yield [$this->createEvent($this->createMock(PassportInterface::class))]; + } + + public function testUpgrade() + { + $encoder = $this->createMock(PasswordEncoderInterface::class); + $encoder->expects($this->any())->method('needsRehash')->willReturn(true); + $encoder->expects($this->any())->method('encodePassword')->with('pa$$word', null)->willReturn('new-encoded-password'); + + $this->encoderFactory->expects($this->any())->method('getEncoder')->with($this->user)->willReturn($encoder); + + $this->user->expects($this->any())->method('getPassword')->willReturn('old-encoded-password'); + + $passwordUpgrader = $this->createPasswordUpgrader(); + $passwordUpgrader->expects($this->once()) + ->method('upgradePassword') + ->with($this->user, 'new-encoded-password') + ; + + $event = $this->createEvent(new SelfValidatingPassport($this->user, [new PasswordUpgradeBadge('pa$$word', $passwordUpgrader)])); + $this->listener->onLoginSuccess($event); + } + + private function createPasswordUpgrader() + { + return $this->createMock(PasswordUpgraderInterface::class); + } + + private function createEvent(PassportInterface $passport) + { + return new LoginSuccessEvent($this->createMock(AuthenticatorInterface::class), $passport, $this->createMock(TokenInterface::class), new Request(), null, 'main'); + } +} diff --git a/src/Symfony/Component/Security/Http/Tests/EventListener/RememberMeListenerTest.php b/src/Symfony/Component/Security/Http/Tests/EventListener/RememberMeListenerTest.php new file mode 100644 index 000000000000..9af16a6a767c --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/EventListener/RememberMeListenerTest.php @@ -0,0 +1,91 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\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\Exception\AuthenticationException; +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\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; + +class RememberMeListenerTest extends TestCase +{ + private $rememberMeServices; + 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->getMockBuilder(Request::class)->disableOriginalConstructor()->getMock(); + $this->response = $this->createMock(Response::class); + $this->token = $this->createMock(TokenInterface::class); + } + + public function testSuccessfulLoginWithoutSupportingAuthenticator() + { + $this->rememberMeServices->expects($this->never())->method('loginSuccess'); + + $event = $this->createLoginSuccessfulEvent('main_firewall', $this->response, new SelfValidatingPassport(new User('wouter', null))); + $this->listener->onSuccessfulLogin($event); + } + + public function testSuccessfulLoginWithoutSuccessResponse() + { + $this->rememberMeServices->expects($this->never())->method('loginSuccess'); + + $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); + $this->listener->onSuccessfulLogin($event); + } + + public function testCredentialsInvalid() + { + $this->rememberMeServices->expects($this->once())->method('loginFail')->with($this->request, $this->isInstanceOf(AuthenticationException::class)); + + $event = $this->createLoginFailureEvent('main_firewall'); + $this->listener->onFailedLogin($event); + } + + private function createLoginSuccessfulEvent($providerKey, $response, PassportInterface $passport = null) + { + if (null === $passport) { + $passport = new SelfValidatingPassport(new User('test', null), [new RememberMeBadge()]); + } + + return new LoginSuccessEvent($this->createMock(AuthenticatorInterface::class), $passport, $this->token, $this->request, $response, $providerKey); + } + + private function createLoginFailureEvent($providerKey) + { + return new LoginFailureEvent(new AuthenticationException(), $this->createMock(AuthenticatorInterface::class), $this->request, null, $providerKey); + } +} diff --git a/src/Symfony/Component/Security/Http/Tests/EventListener/SessionStrategyListenerTest.php b/src/Symfony/Component/Security/Http/Tests/EventListener/SessionStrategyListenerTest.php new file mode 100644 index 000000000000..4d1dd0a5be95 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/EventListener/SessionStrategyListenerTest.php @@ -0,0 +1,77 @@ + + * + * 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\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\SelfValidatingPassport; +use Symfony\Component\Security\Http\Event\LoginSuccessEvent; +use Symfony\Component\Security\Http\EventListener\SessionStrategyListener; +use Symfony\Component\Security\Http\Session\SessionAuthenticationStrategyInterface; + +class SessionStrategyListenerTest extends TestCase +{ + private $sessionAuthenticationStrategy; + private $listener; + private $request; + private $token; + + protected function setUp(): void + { + $this->sessionAuthenticationStrategy = $this->createMock(SessionAuthenticationStrategyInterface::class); + $this->listener = new SessionStrategyListener($this->sessionAuthenticationStrategy); + $this->request = new Request(); + $this->token = $this->createMock(TokenInterface::class); + } + + public function testRequestWithSession() + { + $this->configurePreviousSession(); + + $this->sessionAuthenticationStrategy->expects($this->once())->method('onAuthentication')->with($this->request, $this->token); + + $this->listener->onSuccessfulLogin($this->createEvent('main_firewall')); + } + + public function testRequestWithoutPreviousSession() + { + $this->sessionAuthenticationStrategy->expects($this->never())->method('onAuthentication')->with($this->request, $this->token); + + $this->listener->onSuccessfulLogin($this->createEvent('main_firewall')); + } + + public function testStatelessFirewalls() + { + $this->sessionAuthenticationStrategy->expects($this->never())->method('onAuthentication'); + + $listener = new SessionStrategyListener($this->sessionAuthenticationStrategy, ['api_firewall']); + $listener->onSuccessfulLogin($this->createEvent('api_firewall')); + } + + private function createEvent($providerKey) + { + return new LoginSuccessEvent($this->createMock(AuthenticatorInterface::class), new SelfValidatingPassport(new User('test', null)), $this->token, $this->request, null, $providerKey); + } + + private function configurePreviousSession() + { + $session = $this->getMockBuilder('Symfony\Component\HttpFoundation\Session\SessionInterface')->getMock(); + $session->expects($this->any()) + ->method('getName') + ->willReturn('test_session_name'); + $this->request->setSession($session); + $this->request->cookies->set('test_session_name', 'session_cookie_val'); + } +} diff --git a/src/Symfony/Component/Security/Http/Tests/EventListener/UserCheckerListenerTest.php b/src/Symfony/Component/Security/Http/Tests/EventListener/UserCheckerListenerTest.php new file mode 100644 index 000000000000..dac1fbaf9268 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/EventListener/UserCheckerListenerTest.php @@ -0,0 +1,92 @@ + + * + * 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\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\User\User; +use Symfony\Component\Security\Core\User\UserCheckerInterface; +use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\PreAuthenticatedUserBadge; +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\Event\VerifyAuthenticatorCredentialsEvent; +use Symfony\Component\Security\Http\EventListener\UserCheckerListener; + +class UserCheckerListenerTest extends TestCase +{ + private $userChecker; + private $listener; + private $user; + + protected function setUp(): void + { + $this->userChecker = $this->createMock(UserCheckerInterface::class); + $this->listener = new UserCheckerListener($this->userChecker); + $this->user = new User('test', null); + } + + public function testPreAuth() + { + $this->userChecker->expects($this->once())->method('checkPreAuth')->with($this->user); + + $this->listener->preCredentialsVerification($this->createVerifyAuthenticatorCredentialsEvent()); + } + + public function testPreAuthNoUser() + { + $this->userChecker->expects($this->never())->method('checkPreAuth'); + + $this->listener->preCredentialsVerification($this->createVerifyAuthenticatorCredentialsEvent($this->createMock(PassportInterface::class))); + } + + public function testPreAuthenticatedBadge() + { + $this->userChecker->expects($this->never())->method('checkPreAuth'); + + $this->listener->preCredentialsVerification($this->createVerifyAuthenticatorCredentialsEvent(new SelfValidatingPassport($this->user, [new PreAuthenticatedUserBadge()]))); + } + + public function testPostAuthValidCredentials() + { + $this->userChecker->expects($this->once())->method('checkPostAuth')->with($this->user); + + $this->listener->postCredentialsVerification($this->createLoginSuccessEvent()); + } + + public function testPostAuthNoUser() + { + $this->userChecker->expects($this->never())->method('checkPostAuth'); + + $this->listener->postCredentialsVerification($this->createLoginSuccessEvent($this->createMock(PassportInterface::class))); + } + + private function createVerifyAuthenticatorCredentialsEvent($passport = null) + { + if (null === $passport) { + $passport = new SelfValidatingPassport($this->user); + } + + return new VerifyAuthenticatorCredentialsEvent($this->createMock(AuthenticatorInterface::class), $passport); + } + + private function createLoginSuccessEvent($passport = null) + { + if (null === $passport) { + $passport = new SelfValidatingPassport($this->user); + } + + return new LoginSuccessEvent($this->createMock(AuthenticatorInterface::class), $passport, $this->createMock(TokenInterface::class), new Request(), null, 'main'); + } +} diff --git a/src/Symfony/Component/Security/Http/Tests/EventListener/VerifyAuthenticatorCredentialsListenerTest.php b/src/Symfony/Component/Security/Http/Tests/EventListener/VerifyAuthenticatorCredentialsListenerTest.php new file mode 100644 index 000000000000..a4850ebda7f3 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/EventListener/VerifyAuthenticatorCredentialsListenerTest.php @@ -0,0 +1,119 @@ + + * + * 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\Security\Core\Encoder\EncoderFactoryInterface; +use Symfony\Component\Security\Core\Encoder\PasswordEncoderInterface; +use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\Security\Core\User\User; +use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface; +use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\CustomCredentials; +use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials; +use Symfony\Component\Security\Http\Authenticator\Passport\Passport; +use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; +use Symfony\Component\Security\Http\Event\VerifyAuthenticatorCredentialsEvent; +use Symfony\Component\Security\Http\EventListener\VerifyAuthenticatorCredentialsListener; + +class VerifyAuthenticatorCredentialsListenerTest extends TestCase +{ + private $encoderFactory; + private $listener; + private $user; + + protected function setUp(): void + { + $this->encoderFactory = $this->createMock(EncoderFactoryInterface::class); + $this->listener = new VerifyAuthenticatorCredentialsListener($this->encoderFactory); + $this->user = new User('wouter', 'encoded-password'); + } + + /** + * @dataProvider providePasswords + */ + public function testPasswordAuthenticated($password, $passwordValid, $result) + { + $encoder = $this->createMock(PasswordEncoderInterface::class); + $encoder->expects($this->any())->method('isPasswordValid')->with('encoded-password', $password)->willReturn($passwordValid); + + $this->encoderFactory->expects($this->any())->method('getEncoder')->with($this->identicalTo($this->user))->willReturn($encoder); + + if (false === $result) { + $this->expectException(BadCredentialsException::class); + $this->expectExceptionMessage('The presented password is invalid.'); + } + + $credentials = new PasswordCredentials($password); + $this->listener->onAuthenticating($this->createEvent(new Passport($this->user, $credentials))); + + if (true === $result) { + $this->assertTrue($credentials->isResolved()); + } + } + + public function providePasswords() + { + yield ['ThePa$$word', true, true]; + yield ['Invalid', false, false]; + } + + public function testEmptyPassword() + { + $this->expectException(BadCredentialsException::class); + $this->expectExceptionMessage('The presented password cannot be empty.'); + + $this->encoderFactory->expects($this->never())->method('getEncoder'); + + $event = $this->createEvent(new Passport($this->user, new PasswordCredentials(''))); + $this->listener->onAuthenticating($event); + } + + /** + * @dataProvider provideCustomAuthenticatedResults + */ + public function testCustomAuthenticated($result) + { + $this->encoderFactory->expects($this->never())->method('getEncoder'); + + if (false === $result) { + $this->expectException(BadCredentialsException::class); + } + + $credentials = new CustomCredentials(function () use ($result) { + return $result; + }, ['password' => 'foo']); + $this->listener->onAuthenticating($this->createEvent(new Passport($this->user, $credentials))); + + if (true === $result) { + $this->assertTrue($credentials->isResolved()); + } + } + + public function provideCustomAuthenticatedResults() + { + yield [true]; + yield [false]; + } + + public function testNoCredentialsBadgeProvided() + { + $this->encoderFactory->expects($this->never())->method('getEncoder'); + + $event = $this->createEvent(new SelfValidatingPassport($this->user)); + $this->listener->onAuthenticating($event); + } + + private function createEvent($passport) + { + return new VerifyAuthenticatorCredentialsEvent($this->createMock(AuthenticatorInterface::class), $passport); + } +} diff --git a/src/Symfony/Component/Security/Http/composer.json b/src/Symfony/Component/Security/Http/composer.json index 376ee410facc..77a16c50cebe 100644 --- a/src/Symfony/Component/Security/Http/composer.json +++ b/src/Symfony/Component/Security/Http/composer.json @@ -18,7 +18,7 @@ "require": { "php": "^7.2.5", "symfony/deprecation-contracts": "^2.1", - "symfony/security-core": "^4.4.8|^5.0.8", + "symfony/security-core": "^5.1", "symfony/http-foundation": "^4.4.7|^5.0.7", "symfony/http-kernel": "^4.4|^5.0", "symfony/polyfill-php80": "^1.15",