Skip to content

Commit df93b39

Browse files
committed
Added login throttling feature based on RateLimiter
1 parent 83910df commit df93b39

File tree

18 files changed

+381
-30
lines changed

18 files changed

+381
-30
lines changed

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1737,7 +1737,8 @@ private function addRateLimiterSection(ArrayNodeDefinition $rootNode)
17371737
->arrayPrototype()
17381738
->children()
17391739
->scalarNode('lock')->defaultValue('lock.factory')->end()
1740-
->scalarNode('storage')->defaultValue('cache.app')->end()
1740+
->scalarNode('cache_pool')->defaultValue('cache.app')->end()
1741+
->scalarNode('storage_service')->defaultNull()->end()
17411742
->scalarNode('strategy')->isRequired()->end()
17421743
->integerNode('limit')->isRequired()->end()
17431744
->scalarNode('interval')->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']));
2203+
unset($limiterConfig['lock']);
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: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -651,7 +651,8 @@
651651
</xsd:sequence>
652652
<xsd:attribute name="name" type="xsd:string" />
653653
<xsd:attribute name="lock" type="xsd:string" />
654-
<xsd:attribute name="storage" 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: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
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\RateLimiter\Storage\CacheStorage;
21+
use Symfony\Component\Security\Http\EventListener\LoginThrottlingListener;
22+
23+
/**
24+
* @author Wouter de Jong <wouter@wouterj.nl>
25+
*
26+
* @internal
27+
*/
28+
class LoginThrottlingFactory implements AuthenticatorFactoryInterface, SecurityFactoryInterface
29+
{
30+
public function create(ContainerBuilder $container, string $id, array $config, string $userProvider, ?string $defaultEntryPoint)
31+
{
32+
throw new \LogicException('Login throttling is not supported when "security.enable_authenticator_manager" is not set to true.');
33+
}
34+
35+
public function getPosition(): string
36+
{
37+
// this factory doesn't register any authenticators, this position doesn't matter
38+
return 'pre_auth';
39+
}
40+
41+
public function getKey(): string
42+
{
43+
return 'login_throttling';
44+
}
45+
46+
/**
47+
* @param ArrayNodeDefinition $builder
48+
*/
49+
public function addConfiguration(NodeDefinition $builder)
50+
{
51+
$builder
52+
->children()
53+
->scalarNode('limiter')->info('The name of the limiter that you defined under "framework.rate_limiter".')->end()
54+
->integerNode('max_attempts')->defaultValue(5)->end()
55+
->end();
56+
}
57+
58+
public function createAuthenticator(ContainerBuilder $container, string $firewallName, array $config, string $userProviderId): array
59+
{
60+
if (!class_exists(LoginThrottlingListener::class)) {
61+
throw new \LogicException('Login throttling requires symfony/security-http:^5.2.');
62+
}
63+
64+
if (!isset($config['limiter'])) {
65+
if (!class_exists(FrameworkExtension::class) || !method_exists(FrameworkExtension::class, 'registerRateLimiter')) {
66+
throw new \LogicException('You must either configure a rate limiter for "security.firewalls.'.$firewallName.'.login_throttling" or install symfony/framework-bundle:^5.2');
67+
}
68+
69+
FrameworkExtension::registerRateLimiter($container, $config['limiter'] = '_login_'.$firewallName, [
70+
'strategy' => 'fixed_window',
71+
'limit' => $config['max_attempts'],
72+
'interval' => '1 minute',
73+
'lock' => 'lock.factory',
74+
'cache_pool' => 'cache.app',
75+
]);
76+
}
77+
78+
$container
79+
->setDefinition('security.listener.login_throttling.'.$firewallName, new ChildDefinition('security.listener.login_throttling'))
80+
->replaceArgument(1, new Reference('limiter.'.$config['limiter']))
81+
->addTag('kernel.event_subscriber', ['dispatcher' => 'security.event_dispatcher.'.$firewallName]);
82+
83+
return [];
84+
}
85+
}

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)