Skip to content

Commit 730a56d

Browse files
committed
Added login throttling feature based on RateLimiter
1 parent fb9db12 commit 730a56d

File tree

13 files changed

+347
-1
lines changed

13 files changed

+347
-1
lines changed
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
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\Component\Config\Definition\Builder\ArrayNodeDefinition;
15+
use Symfony\Component\Config\Definition\Builder\NodeDefinition;
16+
use Symfony\Component\DependencyInjection\ChildDefinition;
17+
use Symfony\Component\DependencyInjection\ContainerBuilder;
18+
use Symfony\Component\DependencyInjection\Reference;
19+
use Symfony\Component\Security\Http\EventListener\LoginThrottlingListener;
20+
21+
/**
22+
* @author Wouter de Jong <wouter@wouterj.nl>
23+
*
24+
* @internal
25+
*/
26+
class LoginThrottlingFactory implements AuthenticatorFactoryInterface, SecurityFactoryInterface
27+
{
28+
public function create(ContainerBuilder $container, string $id, array $config, string $userProvider, ?string $defaultEntryPoint)
29+
{
30+
throw new \LogicException('Login throttling is not supported when "security.enable_authenticator_manager" is not set to true.');
31+
}
32+
33+
public function getPosition(): string
34+
{
35+
// this factory doesn't register any authenticators, this position doesn't matter
36+
return 'pre_auth';
37+
}
38+
39+
public function getKey(): string
40+
{
41+
return 'login_throttling';
42+
}
43+
44+
/**
45+
* @param ArrayNodeDefinition $builder
46+
*/
47+
public function addConfiguration(NodeDefinition $builder)
48+
{
49+
$builder
50+
->children()
51+
->scalarNode('limiter')->isRequired()->end()
52+
->end();
53+
}
54+
55+
public function createAuthenticator(ContainerBuilder $container, string $firewallName, array $config, string $userProviderId): array
56+
{
57+
if (!class_exists(LoginThrottlingListener::class)) {
58+
throw new \LogicException('Login throttling requires symfony/security-http:^5.2.');
59+
}
60+
61+
$container
62+
->setDefinition('security.listener.login_throttling.'.$firewallName, new ChildDefinition('security.listener.login_throttling'))
63+
->replaceArgument(1, new Reference('limiter.'.$config['limiter']))
64+
->addTag('kernel.event_subscriber', ['dispatcher' => 'security.event_dispatcher.'.$firewallName]);
65+
66+
return [];
67+
}
68+
}

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: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
imports:
2+
- { resource: ./config.yml }
3+
4+
framework:
5+
lock: ~
6+
rate_limiter:
7+
login:
8+
strategy: token_bucket
9+
limit: 1
10+
rate: { interval: '10 seconds' }
11+
12+
security:
13+
firewalls:
14+
default:
15+
login_throttling:
16+
limiter: login

src/Symfony/Component/Security/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ CHANGELOG
1212
* Added `FirewallListenerInterface` to make the execution order of firewall listeners configurable
1313
* Added translator to `\Symfony\Component\Security\Http\Authenticator\JsonLoginAuthenticator` and `\Symfony\Component\Security\Http\Firewall\UsernamePasswordJsonAuthenticationListener` to translate authentication failure messages
1414
* Added a CurrentUser attribute to force the UserValueResolver to resolve an argument to the current user.
15+
* Added `LoginThrottlingListener`.
1516

1617
5.1.0
1718
-----
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
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\Component\Security\Core\Exception;
13+
14+
/**
15+
* This exception is thrown if there where too many failed login attempts in
16+
* this session.
17+
*
18+
* @author Wouter de Jong <wouter@wouterj.nl>
19+
*/
20+
class SessionLockedException extends AuthenticationException
21+
{
22+
/**
23+
* {@inheritdoc}
24+
*/
25+
public function getMessageKey(): string
26+
{
27+
return 'Too many failed login attempts, please try again later.';
28+
}
29+
}

src/Symfony/Component/Security/Core/Resources/translations/security.en.xlf

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@
6262
<source>Account is locked.</source>
6363
<target>Account is locked.</target>
6464
</trans-unit>
65+
<trans-unit id="17">
66+
<source>Too many failed login attempts, please try again later.</source>
67+
<target>Too many failed login attempts, please try again later.</target>
68+
</trans-unit>
6569
</body>
6670
</file>
6771
</xliff>

src/Symfony/Component/Security/Core/Resources/translations/security.nl.xlf

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@
6262
<source>Account is locked.</source>
6363
<target>Account is geblokkeerd.</target>
6464
</trans-unit>
65+
<trans-unit id="17">
66+
<source>Too many failed login attempts, please try again later.</source>
67+
<target>Er waren teveel mislukte inlogpogingen, probeer het later opnieuw.</target>
68+
</trans-unit>
6569
</body>
6670
</file>
6771
</xliff>

0 commit comments

Comments
 (0)