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 %}