Skip to content

Commit 8fe76ea

Browse files
committed
Added basic login throttling feature
1 parent 18fcb5f commit 8fe76ea

File tree

20 files changed

+524
-9
lines changed

20 files changed

+524
-9
lines changed
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
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\Security\Http\EventListener\LoginThrottlingListener;
19+
20+
/**
21+
* @author Wouter de Jong <wouter@wouterj.nl>
22+
*
23+
* @internal
24+
*/
25+
class LoginThrottlingFactory implements AuthenticatorFactoryInterface, SecurityFactoryInterface
26+
{
27+
public function create(ContainerBuilder $container, string $id, array $config, string $userProvider, ?string $defaultEntryPoint)
28+
{
29+
throw new \LogicException('Login throttling is not supported when "security.enable_authenticator_manager" is not set to true.');
30+
}
31+
32+
public function getPosition(): string
33+
{
34+
// this factory doesn't register any authenticators, this position doesn't matter
35+
return 'pre_auth';
36+
}
37+
38+
public function getKey(): string
39+
{
40+
return 'login_throttling';
41+
}
42+
43+
/**
44+
* @param ArrayNodeDefinition $builder
45+
*/
46+
public function addConfiguration(NodeDefinition $builder)
47+
{
48+
$builder
49+
->children()
50+
->integerNode('threshold')->defaultValue(3)->end()
51+
->integerNode('lock_timeout')->defaultValue(1)->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, $config['threshold'])
64+
->replaceArgument(2, $config['lock_timeout'])
65+
->addTag('kernel.event_subscriber', ['dispatcher' => 'security.event_dispatcher.'.$firewallName]);
66+
67+
return [];
68+
}
69+
}

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
use Symfony\Component\Security\Http\Authenticator\RemoteUserAuthenticator;
2525
use Symfony\Component\Security\Http\Authenticator\X509Authenticator;
2626
use Symfony\Component\Security\Http\EventListener\CheckCredentialsListener;
27+
use Symfony\Component\Security\Http\EventListener\LoginThrottlingListener;
2728
use Symfony\Component\Security\Http\EventListener\PasswordMigratingListener;
2829
use Symfony\Component\Security\Http\EventListener\RememberMeListener;
2930
use Symfony\Component\Security\Http\EventListener\SessionStrategyListener;
@@ -99,6 +100,17 @@
99100
])
100101
->tag('monolog.logger', ['channel' => 'security'])
101102

103+
->set('security.listener.login_throttling', LoginThrottlingListener::class)
104+
->abstract()
105+
->args([
106+
service('request_stack'),
107+
inline_service('cache.security.locked_sessions')
108+
->parent('cache.system')
109+
->tag('cache.pool'),
110+
abstract_arg('threshold'),
111+
abstract_arg('timeout'),
112+
])
113+
102114
// Authenticators
103115
->set('security.authenticator.http_basic', HttpBasicAuthenticator::class)
104116
->abstract()

src/Symfony/Bundle/SecurityBundle/SecurityBundle.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\HttpBasicLdapFactory;
2828
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\JsonLoginFactory;
2929
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\JsonLoginLdapFactory;
30+
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\LoginThrottlingFactory;
3031
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\RememberMeFactory;
3132
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\RemoteUserFactory;
3233
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\X509Factory;
@@ -67,6 +68,7 @@ public function build(ContainerBuilder $container)
6768
$extension->addSecurityListenerFactory(new GuardAuthenticationFactory());
6869
$extension->addSecurityListenerFactory(new AnonymousFactory());
6970
$extension->addSecurityListenerFactory(new CustomAuthenticatorFactory());
71+
$extension->addSecurityListenerFactory(new LoginThrottlingFactory());
7072

7173
$extension->addUserProviderFactory(new InMemoryFactory());
7274
$extension->addUserProviderFactory(new LdapFactory());

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
{% if error %}
66
<div>{{ error.message }}</div>
7+
<div>{{ error.messageKey|replace(error.messageData) }}</div>
78
{% endif %}
89

910
<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 in 10 minutes.', $text);
131+
}
132+
109133
public function provideClientOptions()
110134
{
111135
yield [['test_case' => 'StandardFormLogin', 'root_config' => 'config.yml', 'enable_authenticator_manager' => true]];
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
imports:
2+
- { resource: ./config.yml }
3+
4+
security:
5+
firewalls:
6+
default:
7+
login_throttling:
8+
threshold: 1
9+
lock_timeout: 10

src/Symfony/Component/Security/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ CHANGELOG
55
-----
66

77
* Added attributes on ``Passport``
8+
* Added `LoginThrottlingBadge` and listener
9+
* Marked `Http\CheckPassportEvent`, `Http\LoginFailureEvent` and `Http\LoginSuccessEvent` as `@final`
10+
* [BC break] Added `?PassportInterface $passport` as 3rd argument in `Http\LoginFailureEvent`
811

912
5.1.0
1013
-----
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
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+
private $threshold;
23+
24+
/**
25+
* @param int $threshold in minutes
26+
*/
27+
public function __construct(int $threshold)
28+
{
29+
$this->threshold = $threshold;
30+
}
31+
32+
public function getMessageData(): array
33+
{
34+
return [
35+
'%minutes%' => $this->threshold,
36+
];
37+
}
38+
39+
/**
40+
* {@inheritdoc}
41+
*/
42+
public function getMessageKey(): string
43+
{
44+
return 'Too many failed login attempts, please try again in %minutes% minutes.';
45+
}
46+
47+
/**
48+
* {@inheritdoc}
49+
*/
50+
public function __serialize(): array
51+
{
52+
return [$this->threshold, parent::__serialize()];
53+
}
54+
55+
/**
56+
* {@inheritdoc}
57+
*/
58+
public function __unserialize(array $data): void
59+
{
60+
[$this->threshold, $parentData] = $data;
61+
$parentData = \is_array($parentData) ? $parentData : unserialize($parentData);
62+
parent::__unserialize($parentData);
63+
}
64+
}

src/Symfony/Component/Security/Http/Authentication/AuthenticatorManager.php

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ private function executeAuthenticators(array $authenticators, Request $request):
154154

155155
private function executeAuthenticator(AuthenticatorInterface $authenticator, Request $request): ?Response
156156
{
157+
$passport = null;
157158
try {
158159
// get the passport from the Authenticator
159160
$passport = $authenticator->authenticate($request);
@@ -190,7 +191,7 @@ private function executeAuthenticator(AuthenticatorInterface $authenticator, Req
190191
return null;
191192
} catch (AuthenticationException $e) {
192193
// oh no! Authentication failed!
193-
$response = $this->handleAuthenticationFailure($e, $request, $authenticator);
194+
$response = $this->handleAuthenticationFailure($e, $request, $authenticator, $passport);
194195
if ($response instanceof Response) {
195196
return $response;
196197
}
@@ -221,7 +222,7 @@ private function handleAuthenticationSuccess(TokenInterface $authenticatedToken,
221222
/**
222223
* Handles an authentication failure and returns the Response for the authenticator.
223224
*/
224-
private function handleAuthenticationFailure(AuthenticationException $authenticationException, Request $request, AuthenticatorInterface $authenticator): ?Response
225+
private function handleAuthenticationFailure(AuthenticationException $authenticationException, Request $request, AuthenticatorInterface $authenticator, ?PassportInterface $passport): ?Response
225226
{
226227
if (null !== $this->logger) {
227228
$this->logger->info('Authenticator failed.', ['exception' => $authenticationException, 'authenticator' => \get_class($authenticator)]);
@@ -232,7 +233,7 @@ private function handleAuthenticationFailure(AuthenticationException $authentica
232233
$this->logger->debug('The "{authenticator}" authenticator set the failure response.', ['authenticator' => \get_class($authenticator)]);
233234
}
234235

235-
$this->eventDispatcher->dispatch($loginFailureEvent = new LoginFailureEvent($authenticationException, $authenticator, $request, $response, $this->firewallName));
236+
$this->eventDispatcher->dispatch($loginFailureEvent = new LoginFailureEvent($authenticationException, $authenticator, $passport, $request, $response, $this->firewallName));
236237

237238
// returning null is ok, it means they want the request to continue
238239
return $loginFailureEvent->getResponse();

src/Symfony/Component/Security/Http/Authenticator/FormLoginAuthenticator.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface;
2727
use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface;
2828
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge;
29+
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\LoginThrottlingBadge;
2930
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\PasswordUpgradeBadge;
3031
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\RememberMeBadge;
3132
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials;
@@ -85,7 +86,7 @@ public function authenticate(Request $request): PassportInterface
8586
throw new AuthenticationServiceException('The user provider must return a UserInterface object.');
8687
}
8788

88-
$passport = new Passport($user, new PasswordCredentials($credentials['password']), [new RememberMeBadge()]);
89+
$passport = new Passport($user, new PasswordCredentials($credentials['password']), [new RememberMeBadge(), new LoginThrottlingBadge($credentials['username'])]);
8990
if ($this->options['enable_csrf']) {
9091
$passport->addBadge(new CsrfTokenBadge($this->options['csrf_token_id'], $credentials['csrf_token']));
9192
}

0 commit comments

Comments
 (0)