Skip to content

Commit 237d91f

Browse files
committed
feature #38204 [Security] Added login throttling feature (wouterj)
This PR was squashed before being merged into the 5.2-dev branch. Discussion ---------- [Security] Added login throttling feature | Q | A | ------------- | --- | Branch? | master | Bug fix? | no | New feature? | yes | Deprecations? | no | Tickets | Fix #37266 | License | MIT | Doc PR | tbd This "recreates" #37444 based on the RateLimiter component from #37546 <s>(commits are included in this branch atm)</s>. Login throttling can be enabled on any user-based authenticator (thanks to the `UserBadge`) with this configuration: ```yaml security: firewalls: default: # default limits to 5 login attempts per minute, the number can be configured via "max_attempts" login_throttling: ~ # or you can define your own RateLimiter on framework.rate_limiter and configure it instead: login_throttling: limiter: login ``` Commits ------- afdd805 [Security] Added login throttling feature
2 parents 9b81056 + afdd805 commit 237d91f

File tree

18 files changed

+408
-37
lines changed

18 files changed

+408
-37
lines changed

src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1736,15 +1736,37 @@ private function addRateLimiterSection(ArrayNodeDefinition $rootNode)
17361736
->useAttributeAsKey('name')
17371737
->arrayPrototype()
17381738
->children()
1739-
->scalarNode('lock')->defaultValue('lock.factory')->end()
1740-
->scalarNode('storage')->defaultValue('cache.app')->end()
1741-
->scalarNode('strategy')->isRequired()->end()
1742-
->integerNode('limit')->isRequired()->end()
1743-
->scalarNode('interval')->end()
1739+
->scalarNode('lock_factory')
1740+
->info('The service ID of the lock factory used by this limiter')
1741+
->defaultValue('lock.factory')
1742+
->end()
1743+
->scalarNode('cache_pool')
1744+
->info('The cache pool to use for storing the current limiter state')
1745+
->defaultValue('cache.app')
1746+
->end()
1747+
->scalarNode('storage_service')
1748+
->info('The service ID of a custom storage implementation, this precedes any configured "cache_pool"')
1749+
->defaultNull()
1750+
->end()
1751+
->enumNode('strategy')
1752+
->info('The rate limiting algorithm to use for this rate')
1753+
->isRequired()
1754+
->values(['fixed_window', 'token_bucket'])
1755+
->end()
1756+
->integerNode('limit')
1757+
->info('The maximum allowed hits in a fixed interval or burst')
1758+
->isRequired()
1759+
->end()
1760+
->scalarNode('interval')
1761+
->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).')
1762+
->end()
17441763
->arrayNode('rate')
1764+
->info('Configures the fill rate if "strategy" is set to "token_bucket"')
17451765
->children()
1746-
->scalarNode('interval')->isRequired()->end()
1747-
->integerNode('amount')->defaultValue(1)->end()
1766+
->scalarNode('interval')
1767+
->info('Configures the rate interval. The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent).')
1768+
->end()
1769+
->integerNode('amount')->info('Amount of tokens to add each interval')->defaultValue(1)->end()
17481770
->end()
17491771
->end()
17501772
->end()

src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php

Lines changed: 20 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2190,38 +2190,31 @@ private function registerRateLimiterConfiguration(array $config, ContainerBuilde
21902190

21912191
$loader->load('rate_limiter.php');
21922192

2193-
$locks = [];
2194-
$storages = [];
21952193
foreach ($config['limiters'] as $name => $limiterConfig) {
2196-
$limiter = $container->setDefinition($limiterId = 'limiter.'.$name, new ChildDefinition('limiter'));
2197-
2198-
if (!isset($locks[$limiterConfig['lock']])) {
2199-
$locks[$limiterConfig['lock']] = new Reference($limiterConfig['lock']);
2200-
}
2201-
$limiter->addArgument($locks[$limiterConfig['lock']]);
2202-
unset($limiterConfig['lock']);
2203-
2204-
if (!isset($storages[$limiterConfig['storage']])) {
2205-
$storageId = $limiterConfig['storage'];
2206-
// cache pools are configured by the FrameworkBundle, so they
2207-
// exists in the scoped ContainerBuilder provided to this method
2208-
if ($container->has($storageId)) {
2209-
if ($container->findDefinition($storageId)->hasTag('cache.pool')) {
2210-
$container->register('limiter.storage.'.$storageId, CacheStorage::class)->addArgument(new Reference($storageId));
2211-
$storageId = 'limiter.storage.'.$storageId;
2212-
}
2213-
}
2194+
self::registerRateLimiter($container, $name, $limiterConfig);
2195+
}
2196+
}
22142197

2215-
$storages[$limiterConfig['storage']] = new Reference($storageId);
2216-
}
2217-
$limiter->replaceArgument(1, $storages[$limiterConfig['storage']]);
2218-
unset($limiterConfig['storage']);
2198+
public static function registerRateLimiter(ContainerBuilder $container, string $name, array $limiterConfig)
2199+
{
2200+
$limiter = $container->setDefinition($limiterId = 'limiter.'.$name, new ChildDefinition('limiter'));
22192201

2220-
$limiterConfig['id'] = $name;
2221-
$limiter->replaceArgument(0, $limiterConfig);
2202+
$limiter->addArgument(new Reference($limiterConfig['lock_factory']));
2203+
unset($limiterConfig['lock_factory']);
22222204

2223-
$container->registerAliasForArgument($limiterId, Limiter::class, $name.'.limiter');
2205+
$storageId = $limiterConfig['storage_service'] ?? null;
2206+
if (null === $storageId) {
2207+
$container->register($storageId = 'limiter.storage.'.$name, CacheStorage::class)->addArgument(new Reference($limiterConfig['cache_pool']));
22242208
}
2209+
2210+
$limiter->replaceArgument(1, new Reference($storageId));
2211+
unset($limiterConfig['storage']);
2212+
unset($limiterConfig['cache_pool']);
2213+
2214+
$limiterConfig['id'] = $name;
2215+
$limiter->replaceArgument(0, $limiterConfig);
2216+
2217+
$container->registerAliasForArgument($limiterId, Limiter::class, $name.'.limiter');
22252218
}
22262219

22272220
private function resolveTrustedHeaders(array $headers): int

src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -650,8 +650,9 @@
650650
<xsd:element name="rate" type="rate_limiter_rate" minOccurs="0" />
651651
</xsd:sequence>
652652
<xsd:attribute name="name" type="xsd:string" />
653-
<xsd:attribute name="lock" type="xsd:string" />
654-
<xsd:attribute name="storage" type="xsd:string" />
653+
<xsd:attribute name="lock-factory" type="xsd:string" />
654+
<xsd:attribute name="storage-service" type="xsd:string" />
655+
<xsd:attribute name="cache-pool" type="xsd:string" />
655656
<xsd:attribute name="strategy" type="xsd:string" />
656657
<xsd:attribute name="limit" type="xsd:int" />
657658
<xsd:attribute name="interval" type="xsd:string" />
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory;
13+
14+
use Symfony\Bundle\FrameworkBundle\DependencyInjection\FrameworkExtension;
15+
use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition;
16+
use Symfony\Component\Config\Definition\Builder\NodeDefinition;
17+
use Symfony\Component\DependencyInjection\ChildDefinition;
18+
use Symfony\Component\DependencyInjection\ContainerBuilder;
19+
use Symfony\Component\DependencyInjection\Reference;
20+
use Symfony\Component\Security\Http\EventListener\LoginThrottlingListener;
21+
22+
/**
23+
* @author Wouter de Jong <wouter@wouterj.nl>
24+
*
25+
* @internal
26+
*/
27+
class LoginThrottlingFactory implements AuthenticatorFactoryInterface, SecurityFactoryInterface
28+
{
29+
public function create(ContainerBuilder $container, string $id, array $config, string $userProvider, ?string $defaultEntryPoint)
30+
{
31+
throw new \LogicException('Login throttling is not supported when "security.enable_authenticator_manager" is not set to true.');
32+
}
33+
34+
public function getPosition(): string
35+
{
36+
// this factory doesn't register any authenticators, this position doesn't matter
37+
return 'pre_auth';
38+
}
39+
40+
public function getKey(): string
41+
{
42+
return 'login_throttling';
43+
}
44+
45+
/**
46+
* @param ArrayNodeDefinition $builder
47+
*/
48+
public function addConfiguration(NodeDefinition $builder)
49+
{
50+
$builder
51+
->children()
52+
->scalarNode('limiter')->info('The name of the limiter that you defined under "framework.rate_limiter".')->end()
53+
->integerNode('max_attempts')->defaultValue(5)->end()
54+
->end();
55+
}
56+
57+
public function createAuthenticator(ContainerBuilder $container, string $firewallName, array $config, string $userProviderId): array
58+
{
59+
if (!class_exists(LoginThrottlingListener::class)) {
60+
throw new \LogicException('Login throttling requires symfony/security-http:^5.2.');
61+
}
62+
63+
if (!isset($config['limiter'])) {
64+
if (!class_exists(FrameworkExtension::class) || !method_exists(FrameworkExtension::class, 'registerRateLimiter')) {
65+
throw new \LogicException('You must either configure a rate limiter for "security.firewalls.'.$firewallName.'.login_throttling" or install symfony/framework-bundle:^5.2');
66+
}
67+
68+
FrameworkExtension::registerRateLimiter($container, $config['limiter'] = '_login_'.$firewallName, [
69+
'strategy' => 'fixed_window',
70+
'limit' => $config['max_attempts'],
71+
'interval' => '1 minute',
72+
'lock_factory' => 'lock.factory',
73+
'cache_pool' => 'cache.app',
74+
]);
75+
}
76+
77+
$container
78+
->setDefinition('security.listener.login_throttling.'.$firewallName, new ChildDefinition('security.listener.login_throttling'))
79+
->replaceArgument(1, new Reference('limiter.'.$config['limiter']))
80+
->addTag('kernel.event_subscriber', ['dispatcher' => 'security.event_dispatcher.'.$firewallName]);
81+
82+
return [];
83+
}
84+
}

src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
use Symfony\Component\Security\Http\Authenticator\X509Authenticator;
2626
use Symfony\Component\Security\Http\Event\CheckPassportEvent;
2727
use Symfony\Component\Security\Http\EventListener\CheckCredentialsListener;
28+
use Symfony\Component\Security\Http\EventListener\LoginThrottlingListener;
2829
use Symfony\Component\Security\Http\EventListener\PasswordMigratingListener;
2930
use Symfony\Component\Security\Http\EventListener\RememberMeListener;
3031
use Symfony\Component\Security\Http\EventListener\SessionStrategyListener;
@@ -113,6 +114,13 @@
113114
])
114115
->tag('monolog.logger', ['channel' => 'security'])
115116

117+
->set('security.listener.login_throttling', LoginThrottlingListener::class)
118+
->abstract()
119+
->args([
120+
service('request_stack'),
121+
abstract_arg('rate limiter'),
122+
])
123+
116124
// Authenticators
117125
->set('security.authenticator.http_basic', HttpBasicAuthenticator::class)
118126
->abstract()

src/Symfony/Bundle/SecurityBundle/SecurityBundle.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\HttpBasicLdapFactory;
2929
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\JsonLoginFactory;
3030
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\JsonLoginLdapFactory;
31+
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\LoginThrottlingFactory;
3132
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\RememberMeFactory;
3233
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\RemoteUserFactory;
3334
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\X509Factory;
@@ -64,6 +65,7 @@ public function build(ContainerBuilder $container)
6465
$extension->addSecurityListenerFactory(new GuardAuthenticationFactory());
6566
$extension->addSecurityListenerFactory(new AnonymousFactory());
6667
$extension->addSecurityListenerFactory(new CustomAuthenticatorFactory());
68+
$extension->addSecurityListenerFactory(new LoginThrottlingFactory());
6769

6870
$extension->addUserProviderFactory(new InMemoryFactory());
6971
$extension->addUserProviderFactory(new LdapFactory());

src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/FormLoginBundle/Resources/views/Login/login.html.twig

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
{% block body %}
44

55
{% if error %}
6-
<div>{{ error.message }}</div>
6+
<div>{{ error.messageKey }}</div>
77
{% endif %}
88

99
<form action="{{ path('form_login_check') }}" method="post">

src/Symfony/Bundle/SecurityBundle/Tests/Functional/FormLoginTest.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
namespace Symfony\Bundle\SecurityBundle\Tests\Functional;
1313

14+
use Symfony\Component\Security\Http\EventListener\LoginThrottlingListener;
15+
1416
class FormLoginTest extends AbstractWebTestCase
1517
{
1618
/**
@@ -106,6 +108,28 @@ public function testFormLoginRedirectsToProtectedResourceAfterLogin(array $optio
106108
$this->assertStringContainsString('You\'re browsing to path "/protected_resource".', $text);
107109
}
108110

111+
public function testLoginThrottling()
112+
{
113+
if (!class_exists(LoginThrottlingListener::class)) {
114+
$this->markTestSkipped('Login throttling requires symfony/security-http:^5.2');
115+
}
116+
117+
$client = $this->createClient(['test_case' => 'StandardFormLogin', 'root_config' => 'login_throttling.yml', 'enable_authenticator_manager' => true]);
118+
119+
$form = $client->request('GET', '/login')->selectButton('login')->form();
120+
$form['_username'] = 'johannes';
121+
$form['_password'] = 'wrong';
122+
$client->submit($form);
123+
124+
$client->followRedirect()->selectButton('login')->form();
125+
$form['_username'] = 'johannes';
126+
$form['_password'] = 'wrong';
127+
$client->submit($form);
128+
129+
$text = $client->followRedirect()->text(null, true);
130+
$this->assertStringContainsString('Too many failed login attempts, please try again later.', $text);
131+
}
132+
109133
public function provideClientOptions()
110134
{
111135
yield [['test_case' => 'StandardFormLogin', 'root_config' => 'config.yml', 'enable_authenticator_manager' => true]];
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
imports:
2+
- { resource: ./config.yml }
3+
4+
framework:
5+
lock: ~
6+
rate_limiter: ~
7+
8+
security:
9+
firewalls:
10+
default:
11+
login_throttling:
12+
max_attempts: 1

src/Symfony/Bundle/SecurityBundle/composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"symfony/form": "^4.4|^5.0",
4040
"symfony/framework-bundle": "^5.2",
4141
"symfony/process": "^4.4|^5.0",
42+
"symfony/rate-limiter": "^5.2",
4243
"symfony/serializer": "^4.4|^5.0",
4344
"symfony/translation": "^4.4|^5.0",
4445
"symfony/twig-bundle": "^4.4|^5.0",

0 commit comments

Comments
 (0)