diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php index ea75fdfebfd55..b4268a4607f9a 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php @@ -1736,15 +1736,37 @@ private function addRateLimiterSection(ArrayNodeDefinition $rootNode) ->useAttributeAsKey('name') ->arrayPrototype() ->children() - ->scalarNode('lock')->defaultValue('lock.factory')->end() - ->scalarNode('storage')->defaultValue('cache.app')->end() - ->scalarNode('strategy')->isRequired()->end() - ->integerNode('limit')->isRequired()->end() - ->scalarNode('interval')->end() + ->scalarNode('lock_factory') + ->info('The service ID of the lock factory used by this limiter') + ->defaultValue('lock.factory') + ->end() + ->scalarNode('cache_pool') + ->info('The cache pool to use for storing the current limiter state') + ->defaultValue('cache.app') + ->end() + ->scalarNode('storage_service') + ->info('The service ID of a custom storage implementation, this precedes any configured "cache_pool"') + ->defaultNull() + ->end() + ->enumNode('strategy') + ->info('The rate limiting algorithm to use for this rate') + ->isRequired() + ->values(['fixed_window', 'token_bucket']) + ->end() + ->integerNode('limit') + ->info('The maximum allowed hits in a fixed interval or burst') + ->isRequired() + ->end() + ->scalarNode('interval') + ->info('Configures the fixed interval if "strategy" is set to "fixed_window". The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent).') + ->end() ->arrayNode('rate') + ->info('Configures the fill rate if "strategy" is set to "token_bucket"') ->children() - ->scalarNode('interval')->isRequired()->end() - ->integerNode('amount')->defaultValue(1)->end() + ->scalarNode('interval') + ->info('Configures the rate interval. The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent).') + ->end() + ->integerNode('amount')->info('Amount of tokens to add each interval')->defaultValue(1)->end() ->end() ->end() ->end() diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index acffba00fe7e6..fb56df99e9433 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -2190,38 +2190,31 @@ private function registerRateLimiterConfiguration(array $config, ContainerBuilde $loader->load('rate_limiter.php'); - $locks = []; - $storages = []; foreach ($config['limiters'] as $name => $limiterConfig) { - $limiter = $container->setDefinition($limiterId = 'limiter.'.$name, new ChildDefinition('limiter')); - - if (!isset($locks[$limiterConfig['lock']])) { - $locks[$limiterConfig['lock']] = new Reference($limiterConfig['lock']); - } - $limiter->addArgument($locks[$limiterConfig['lock']]); - unset($limiterConfig['lock']); - - if (!isset($storages[$limiterConfig['storage']])) { - $storageId = $limiterConfig['storage']; - // cache pools are configured by the FrameworkBundle, so they - // exists in the scoped ContainerBuilder provided to this method - if ($container->has($storageId)) { - if ($container->findDefinition($storageId)->hasTag('cache.pool')) { - $container->register('limiter.storage.'.$storageId, CacheStorage::class)->addArgument(new Reference($storageId)); - $storageId = 'limiter.storage.'.$storageId; - } - } + self::registerRateLimiter($container, $name, $limiterConfig); + } + } - $storages[$limiterConfig['storage']] = new Reference($storageId); - } - $limiter->replaceArgument(1, $storages[$limiterConfig['storage']]); - unset($limiterConfig['storage']); + public static function registerRateLimiter(ContainerBuilder $container, string $name, array $limiterConfig) + { + $limiter = $container->setDefinition($limiterId = 'limiter.'.$name, new ChildDefinition('limiter')); - $limiterConfig['id'] = $name; - $limiter->replaceArgument(0, $limiterConfig); + $limiter->addArgument(new Reference($limiterConfig['lock_factory'])); + unset($limiterConfig['lock_factory']); - $container->registerAliasForArgument($limiterId, Limiter::class, $name.'.limiter'); + $storageId = $limiterConfig['storage_service'] ?? null; + if (null === $storageId) { + $container->register($storageId = 'limiter.storage.'.$name, CacheStorage::class)->addArgument(new Reference($limiterConfig['cache_pool'])); } + + $limiter->replaceArgument(1, new Reference($storageId)); + unset($limiterConfig['storage']); + unset($limiterConfig['cache_pool']); + + $limiterConfig['id'] = $name; + $limiter->replaceArgument(0, $limiterConfig); + + $container->registerAliasForArgument($limiterId, Limiter::class, $name.'.limiter'); } private function resolveTrustedHeaders(array $headers): int diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd b/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd index cdc57ea30e852..17da05d39ff25 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd @@ -650,8 +650,9 @@ - - + + + diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/LoginThrottlingFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/LoginThrottlingFactory.php new file mode 100644 index 0000000000000..260b24ad4da20 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/LoginThrottlingFactory.php @@ -0,0 +1,84 @@ + + * + * 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\Bundle\FrameworkBundle\DependencyInjection\FrameworkExtension; +use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; +use Symfony\Component\Config\Definition\Builder\NodeDefinition; +use Symfony\Component\DependencyInjection\ChildDefinition; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\Security\Http\EventListener\LoginThrottlingListener; + +/** + * @author Wouter de Jong + * + * @internal + */ +class LoginThrottlingFactory implements AuthenticatorFactoryInterface, SecurityFactoryInterface +{ + public function create(ContainerBuilder $container, string $id, array $config, string $userProvider, ?string $defaultEntryPoint) + { + throw new \LogicException('Login throttling is not supported when "security.enable_authenticator_manager" is not set to true.'); + } + + public function getPosition(): string + { + // this factory doesn't register any authenticators, this position doesn't matter + return 'pre_auth'; + } + + public function getKey(): string + { + return 'login_throttling'; + } + + /** + * @param ArrayNodeDefinition $builder + */ + public function addConfiguration(NodeDefinition $builder) + { + $builder + ->children() + ->scalarNode('limiter')->info('The name of the limiter that you defined under "framework.rate_limiter".')->end() + ->integerNode('max_attempts')->defaultValue(5)->end() + ->end(); + } + + public function createAuthenticator(ContainerBuilder $container, string $firewallName, array $config, string $userProviderId): array + { + if (!class_exists(LoginThrottlingListener::class)) { + throw new \LogicException('Login throttling requires symfony/security-http:^5.2.'); + } + + if (!isset($config['limiter'])) { + if (!class_exists(FrameworkExtension::class) || !method_exists(FrameworkExtension::class, 'registerRateLimiter')) { + throw new \LogicException('You must either configure a rate limiter for "security.firewalls.'.$firewallName.'.login_throttling" or install symfony/framework-bundle:^5.2'); + } + + FrameworkExtension::registerRateLimiter($container, $config['limiter'] = '_login_'.$firewallName, [ + 'strategy' => 'fixed_window', + 'limit' => $config['max_attempts'], + 'interval' => '1 minute', + 'lock_factory' => 'lock.factory', + 'cache_pool' => 'cache.app', + ]); + } + + $container + ->setDefinition('security.listener.login_throttling.'.$firewallName, new ChildDefinition('security.listener.login_throttling')) + ->replaceArgument(1, new Reference('limiter.'.$config['limiter'])) + ->addTag('kernel.event_subscriber', ['dispatcher' => 'security.event_dispatcher.'.$firewallName]); + + return []; + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator.php b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator.php index 158da4babb74e..4ba3735153561 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator.php +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator.php @@ -25,6 +25,7 @@ use Symfony\Component\Security\Http\Authenticator\X509Authenticator; use Symfony\Component\Security\Http\Event\CheckPassportEvent; use Symfony\Component\Security\Http\EventListener\CheckCredentialsListener; +use Symfony\Component\Security\Http\EventListener\LoginThrottlingListener; use Symfony\Component\Security\Http\EventListener\PasswordMigratingListener; use Symfony\Component\Security\Http\EventListener\RememberMeListener; use Symfony\Component\Security\Http\EventListener\SessionStrategyListener; @@ -113,6 +114,13 @@ ]) ->tag('monolog.logger', ['channel' => 'security']) + ->set('security.listener.login_throttling', LoginThrottlingListener::class) + ->abstract() + ->args([ + service('request_stack'), + abstract_arg('rate limiter'), + ]) + // Authenticators ->set('security.authenticator.http_basic', HttpBasicAuthenticator::class) ->abstract() diff --git a/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php b/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php index c06c30ede30a9..7db301d447412 100644 --- a/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php +++ b/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php @@ -28,6 +28,7 @@ use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\HttpBasicLdapFactory; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\JsonLoginFactory; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\JsonLoginLdapFactory; +use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\LoginThrottlingFactory; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\RememberMeFactory; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\RemoteUserFactory; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\X509Factory; @@ -64,6 +65,7 @@ public function build(ContainerBuilder $container) $extension->addSecurityListenerFactory(new GuardAuthenticationFactory()); $extension->addSecurityListenerFactory(new AnonymousFactory()); $extension->addSecurityListenerFactory(new CustomAuthenticatorFactory()); + $extension->addSecurityListenerFactory(new LoginThrottlingFactory()); $extension->addUserProviderFactory(new InMemoryFactory()); $extension->addUserProviderFactory(new LdapFactory()); diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/FormLoginBundle/Resources/views/Login/login.html.twig b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/FormLoginBundle/Resources/views/Login/login.html.twig index 059f5f2bca1d2..a137e0cb849ad 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/FormLoginBundle/Resources/views/Login/login.html.twig +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/FormLoginBundle/Resources/views/Login/login.html.twig @@ -3,7 +3,7 @@ {% block body %} {% if error %} -
{{ error.message }}
+
{{ error.messageKey }}
{% endif %}
diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/FormLoginTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/FormLoginTest.php index 45d74fc72261f..c527935e00cba 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/FormLoginTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/FormLoginTest.php @@ -11,6 +11,8 @@ namespace Symfony\Bundle\SecurityBundle\Tests\Functional; +use Symfony\Component\Security\Http\EventListener\LoginThrottlingListener; + class FormLoginTest extends AbstractWebTestCase { /** @@ -106,6 +108,28 @@ public function testFormLoginRedirectsToProtectedResourceAfterLogin(array $optio $this->assertStringContainsString('You\'re browsing to path "/protected_resource".', $text); } + public function testLoginThrottling() + { + if (!class_exists(LoginThrottlingListener::class)) { + $this->markTestSkipped('Login throttling requires symfony/security-http:^5.2'); + } + + $client = $this->createClient(['test_case' => 'StandardFormLogin', 'root_config' => 'login_throttling.yml', 'enable_authenticator_manager' => true]); + + $form = $client->request('GET', '/login')->selectButton('login')->form(); + $form['_username'] = 'johannes'; + $form['_password'] = 'wrong'; + $client->submit($form); + + $client->followRedirect()->selectButton('login')->form(); + $form['_username'] = 'johannes'; + $form['_password'] = 'wrong'; + $client->submit($form); + + $text = $client->followRedirect()->text(null, true); + $this->assertStringContainsString('Too many failed login attempts, please try again later.', $text); + } + public function provideClientOptions() { yield [['test_case' => 'StandardFormLogin', 'root_config' => 'config.yml', 'enable_authenticator_manager' => true]]; diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/login_throttling.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/login_throttling.yml new file mode 100644 index 0000000000000..4848567cf3360 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/login_throttling.yml @@ -0,0 +1,12 @@ +imports: + - { resource: ./config.yml } + +framework: + lock: ~ + rate_limiter: ~ + +security: + firewalls: + default: + login_throttling: + max_attempts: 1 diff --git a/src/Symfony/Bundle/SecurityBundle/composer.json b/src/Symfony/Bundle/SecurityBundle/composer.json index 892d847936d46..7402d7656cfd7 100644 --- a/src/Symfony/Bundle/SecurityBundle/composer.json +++ b/src/Symfony/Bundle/SecurityBundle/composer.json @@ -39,6 +39,7 @@ "symfony/form": "^4.4|^5.0", "symfony/framework-bundle": "^5.2", "symfony/process": "^4.4|^5.0", + "symfony/rate-limiter": "^5.2", "symfony/serializer": "^4.4|^5.0", "symfony/translation": "^4.4|^5.0", "symfony/twig-bundle": "^4.4|^5.0", diff --git a/src/Symfony/Component/Security/CHANGELOG.md b/src/Symfony/Component/Security/CHANGELOG.md index 9f8cd877c09f5..615463525beaf 100644 --- a/src/Symfony/Component/Security/CHANGELOG.md +++ b/src/Symfony/Component/Security/CHANGELOG.md @@ -12,6 +12,7 @@ CHANGELOG * Added `FirewallListenerInterface` to make the execution order of firewall listeners configurable * Added translator to `\Symfony\Component\Security\Http\Authenticator\JsonLoginAuthenticator` and `\Symfony\Component\Security\Http\Firewall\UsernamePasswordJsonAuthenticationListener` to translate authentication failure messages * Added a CurrentUser attribute to force the UserValueResolver to resolve an argument to the current user. + * Added `LoginThrottlingListener`. 5.1.0 ----- diff --git a/src/Symfony/Component/Security/Core/Exception/TooManyLoginAttemptsAuthenticationException.php b/src/Symfony/Component/Security/Core/Exception/TooManyLoginAttemptsAuthenticationException.php new file mode 100644 index 0000000000000..297db1580b2b3 --- /dev/null +++ b/src/Symfony/Component/Security/Core/Exception/TooManyLoginAttemptsAuthenticationException.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\Component\Security\Core\Exception; + +/** + * This exception is thrown if there where too many failed login attempts in + * this session. + * + * @author Wouter de Jong + */ +class TooManyLoginAttemptsAuthenticationException extends AuthenticationException +{ + /** + * {@inheritdoc} + */ + public function getMessageKey(): string + { + return 'Too many failed login attempts, please try again later.'; + } +} diff --git a/src/Symfony/Component/Security/Core/Resources/translations/security.en.xlf b/src/Symfony/Component/Security/Core/Resources/translations/security.en.xlf index 3c89e44f9380e..4f19400d15cbd 100644 --- a/src/Symfony/Component/Security/Core/Resources/translations/security.en.xlf +++ b/src/Symfony/Component/Security/Core/Resources/translations/security.en.xlf @@ -62,6 +62,10 @@ Account is locked. Account is locked. + + Too many failed login attempts, please try again later. + Too many failed login attempts, please try again later. + diff --git a/src/Symfony/Component/Security/Core/Resources/translations/security.nl.xlf b/src/Symfony/Component/Security/Core/Resources/translations/security.nl.xlf index 5160143ab7380..1df208bfafbe0 100644 --- a/src/Symfony/Component/Security/Core/Resources/translations/security.nl.xlf +++ b/src/Symfony/Component/Security/Core/Resources/translations/security.nl.xlf @@ -62,6 +62,10 @@ Account is locked. Account is geblokkeerd. + + Too many failed login attempts, please try again later. + Er waren teveel mislukte inlogpogingen, probeer het later opnieuw. + diff --git a/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/UserBadge.php b/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/UserBadge.php index c8235a872ab1c..10856e4bfe870 100644 --- a/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/UserBadge.php +++ b/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/UserBadge.php @@ -50,6 +50,11 @@ public function __construct(string $userIdentifier, ?callable $userLoader = null $this->userLoader = $userLoader; } + public function getUserIdentifier(): string + { + return $this->userIdentifier; + } + public function getUser(): UserInterface { if (null === $this->user) { diff --git a/src/Symfony/Component/Security/Http/EventListener/LoginThrottlingListener.php b/src/Symfony/Component/Security/Http/EventListener/LoginThrottlingListener.php new file mode 100644 index 0000000000000..d45d879469d1f --- /dev/null +++ b/src/Symfony/Component/Security/Http/EventListener/LoginThrottlingListener.php @@ -0,0 +1,76 @@ + + * + * 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\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\RateLimiter\Limiter; +use Symfony\Component\Security\Core\Exception\TooManyLoginAttemptsAuthenticationException; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; +use Symfony\Component\Security\Http\Event\CheckPassportEvent; +use Symfony\Component\Security\Http\Event\LoginSuccessEvent; + +/** + * @author Wouter de Jong + * + * @experimental in 5.2 + */ +final class LoginThrottlingListener implements EventSubscriberInterface +{ + private $requestStack; + private $limiter; + + public function __construct(RequestStack $requestStack, Limiter $limiter) + { + $this->requestStack = $requestStack; + $this->limiter = $limiter; + } + + public function checkPassport(CheckPassportEvent $event): void + { + $passport = $event->getPassport(); + if (!$passport->hasBadge(UserBadge::class)) { + return; + } + + $request = $this->requestStack->getMasterRequest(); + $username = $passport->getBadge(UserBadge::class)->getUserIdentifier(); + $limiterKey = $this->createLimiterKey($username, $request); + + $limiter = $this->limiter->create($limiterKey); + if (!$limiter->consume()) { + throw new TooManyLoginAttemptsAuthenticationException(); + } + } + + public function onSuccessfulLogin(LoginSuccessEvent $event): void + { + $limiterKey = $this->createLimiterKey($event->getAuthenticatedToken()->getUsername(), $event->getRequest()); + $limiter = $this->limiter->create($limiterKey); + + $limiter->reset(); + } + + public static function getSubscribedEvents(): array + { + return [ + CheckPassportEvent::class => ['checkPassport', 64], + LoginSuccessEvent::class => 'onSuccessfulLogin', + ]; + } + + private function createLimiterKey($username, Request $request): string + { + return $username.$request->getClientIp(); + } +} diff --git a/src/Symfony/Component/Security/Http/Tests/EventListener/LoginThrottlingListenerTest.php b/src/Symfony/Component/Security/Http/Tests/EventListener/LoginThrottlingListenerTest.php new file mode 100644 index 0000000000000..19fa57f04394c --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/EventListener/LoginThrottlingListenerTest.php @@ -0,0 +1,104 @@ + + * + * 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\RequestStack; +use Symfony\Component\RateLimiter\Limiter; +use Symfony\Component\RateLimiter\Storage\InMemoryStorage; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Exception\TooManyLoginAttemptsAuthenticationException; +use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; +use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; +use Symfony\Component\Security\Http\Event\CheckPassportEvent; +use Symfony\Component\Security\Http\Event\LoginSuccessEvent; +use Symfony\Component\Security\Http\EventListener\LoginThrottlingListener; + +class LoginThrottlingListenerTest extends TestCase +{ + private $requestStack; + private $listener; + + protected function setUp(): void + { + $this->requestStack = new RequestStack(); + + $limiter = new Limiter([ + 'id' => 'login', + 'strategy' => 'fixed_window', + 'limit' => 3, + 'interval' => '1 minute', + ], new InMemoryStorage()); + + $this->listener = new LoginThrottlingListener($this->requestStack, $limiter); + } + + public function testPreventsLoginWhenOverThreshold() + { + $request = $this->createRequest(); + $passport = $this->createPassport('wouter'); + + $this->requestStack->push($request); + + for ($i = 0; $i < 3; ++$i) { + $this->listener->checkPassport($this->createCheckPassportEvent($passport)); + } + + $this->expectException(TooManyLoginAttemptsAuthenticationException::class); + $this->listener->checkPassport($this->createCheckPassportEvent($passport)); + } + + public function testSuccessfulLoginResetsCount() + { + $this->expectNotToPerformAssertions(); + + $request = $this->createRequest(); + $passport = $this->createPassport('wouter'); + + $this->requestStack->push($request); + + for ($i = 0; $i < 3; ++$i) { + $this->listener->checkPassport($this->createCheckPassportEvent($passport)); + } + + $this->listener->onSuccessfulLogin($this->createLoginSuccessfulEvent($passport)); + $this->listener->checkPassport($this->createCheckPassportEvent($passport)); + } + + private function createPassport($username) + { + return new SelfValidatingPassport(new UserBadge($username)); + } + + private function createLoginSuccessfulEvent($passport, $username = 'wouter') + { + $token = $this->createMock(TokenInterface::class); + $token->expects($this->any())->method('getUsername')->willReturn($username); + + return new LoginSuccessEvent($this->createMock(AuthenticatorInterface::class), $passport, $token, $this->requestStack->getCurrentRequest(), null, 'main'); + } + + private function createCheckPassportEvent($passport) + { + return new CheckPassportEvent($this->createMock(AuthenticatorInterface::class), $passport); + } + + private function createRequest($ip = '192.168.1.0') + { + $request = new Request(); + $request->server->set('REMOTE_ADDR', $ip); + + return $request; + } +} diff --git a/src/Symfony/Component/Security/Http/composer.json b/src/Symfony/Component/Security/Http/composer.json index c5738118789e2..aff757cee05fb 100644 --- a/src/Symfony/Component/Security/Http/composer.json +++ b/src/Symfony/Component/Security/Http/composer.json @@ -25,6 +25,7 @@ "symfony/property-access": "^4.4|^5.0" }, "require-dev": { + "symfony/rate-limiter": "^5.2", "symfony/routing": "^4.4|^5.0", "symfony/security-csrf": "^4.4|^5.0", "symfony/translation": "^4.4|^5.0",