Skip to content

Commit 95fc76a

Browse files
committed
Added request rate limiters and improved login throttling
This allows limiting on different elements of a request. This is usefull to e.g. prevent breadth-first attacks, by allowing to enforce a limit on both IP and IP+username.
1 parent 3c7a131 commit 95fc76a

File tree

12 files changed

+232
-47
lines changed

12 files changed

+232
-47
lines changed

src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/LoginThrottlingFactory.php

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@
1818
use Symfony\Component\DependencyInjection\ContainerBuilder;
1919
use Symfony\Component\DependencyInjection\Reference;
2020
use Symfony\Component\RateLimiter\Limiter;
21+
use Symfony\Component\HttpFoundation\RateLimiter\RequestRateLimiterInterface;
2122
use Symfony\Component\Security\Http\EventListener\LoginThrottlingListener;
23+
use Symfony\Component\Security\Http\RateLimiter\DefaultLoginRateLimiter;
2224

2325
/**
2426
* @author Wouter de Jong <wouter@wouterj.nl>
@@ -50,7 +52,7 @@ public function addConfiguration(NodeDefinition $builder)
5052
{
5153
$builder
5254
->children()
53-
->scalarNode('limiter')->info('The name of the limiter that you defined under "framework.rate_limiter".')->end()
55+
->scalarNode('limiter')->info(sprintf('A service id implementing "%s".', RequestRateLimiterInterface::class))->end()
5456
->integerNode('max_attempts')->defaultValue(5)->end()
5557
->end();
5658
}
@@ -70,18 +72,27 @@ public function createAuthenticator(ContainerBuilder $container, string $firewal
7072
throw new \LogicException('You must either configure a rate limiter for "security.firewalls.'.$firewallName.'.login_throttling" or install symfony/framework-bundle:^5.2.');
7173
}
7274

73-
FrameworkExtension::registerRateLimiter($container, $config['limiter'] = '_login_'.$firewallName, [
75+
$limiterOptions = [
7476
'strategy' => 'fixed_window',
7577
'limit' => $config['max_attempts'],
7678
'interval' => '1 minute',
7779
'lock_factory' => 'lock.factory',
7880
'cache_pool' => 'cache.app',
79-
]);
81+
];
82+
FrameworkExtension::registerRateLimiter($container, $localId = '_login_local_'.$firewallName, $limiterOptions);
83+
84+
$limiterOptions['limit'] = 5 * $config['max_attempts'];
85+
FrameworkExtension::registerRateLimiter($container, $globalId = '_login_global_'.$firewallName, $limiterOptions);
86+
87+
$container->register($config['limiter'] = 'security.login_throttling.'.$firewallName.'.limiter', DefaultLoginRateLimiter::class)
88+
->addArgument(new Reference('limiter.'.$globalId))
89+
->addArgument(new Reference('limiter.'.$localId))
90+
;
8091
}
8192

8293
$container
8394
->setDefinition('security.listener.login_throttling.'.$firewallName, new ChildDefinition('security.listener.login_throttling'))
84-
->replaceArgument(1, new Reference('limiter.'.$config['limiter']))
95+
->replaceArgument(1, new Reference($config['limiter']))
8596
->addTag('kernel.event_subscriber', ['dispatcher' => 'security.event_dispatcher.'.$firewallName]);
8697

8798
return [];

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@
118118
->abstract()
119119
->args([
120120
service('request_stack'),
121-
abstract_arg('rate limiter'),
121+
abstract_arg('request rate limiter'),
122122
])
123123

124124
// Authenticators

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.messageKey }}</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: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,10 @@ public function testFormLoginRedirectsToProtectedResourceAfterLogin(array $optio
108108
$this->assertStringContainsString('You\'re browsing to path "/protected_resource".', $text);
109109
}
110110

111-
public function testLoginThrottling()
111+
/**
112+
* @dataProvider provideInvalidCredentials
113+
*/
114+
public function testLoginThrottling($username, $password)
112115
{
113116
if (!class_exists(LoginThrottlingListener::class)) {
114117
$this->markTestSkipped('Login throttling requires symfony/security-http:^5.2');
@@ -117,17 +120,23 @@ public function testLoginThrottling()
117120
$client = $this->createClient(['test_case' => 'StandardFormLogin', 'root_config' => 'login_throttling.yml', 'enable_authenticator_manager' => true]);
118121

119122
$form = $client->request('GET', '/login')->selectButton('login')->form();
120-
$form['_username'] = 'johannes';
121-
$form['_password'] = 'wrong';
123+
$form['_username'] = $username;
124+
$form['_password'] = $password;
122125
$client->submit($form);
123126

124127
$client->followRedirect()->selectButton('login')->form();
125-
$form['_username'] = 'johannes';
126-
$form['_password'] = 'wrong';
128+
$form['_username'] = $username;
129+
$form['_password'] = $password;
127130
$client->submit($form);
128131

129132
$text = $client->followRedirect()->text(null, true);
130-
$this->assertStringContainsString('Too many failed login attempts, please try again later.', $text);
133+
$this->assertStringContainsString('Too many failed login attempts, please try again in 1 minute.', $text);
134+
}
135+
136+
public function provideInvalidCredentials()
137+
{
138+
yield 'invalid_password' => ['johannes', 'wrong'];
139+
yield 'invalid_username' => ['wrong', 'wrong'];
131140
}
132141

133142
public function provideClientOptions()

src/Symfony/Component/HttpFoundation/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ CHANGELOG
88
* added `HeaderUtils::parseQuery()`: it does the same as `parse_str()` but preserves dots in variable names
99
* added `File::getContent()`
1010
* added ability to use comma separated ip addresses for `RequestMatcher::matchIps()`
11+
* added `RateLimiter\RequestRateLimiterInterface` and `RateLimiter\AbstractRequestRateLimiter`
1112

1213
5.1.0
1314
-----
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
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\HttpFoundation\RateLimiter;
13+
14+
use Symfony\Component\HttpFoundation\Request;
15+
use Symfony\Component\RateLimiter\Limit;
16+
use Symfony\Component\RateLimiter\LimiterInterface;
17+
use Symfony\Component\RateLimiter\NoLimiter;
18+
19+
/**
20+
* An implementation of RequestRateLimiterInterface that
21+
* fits most use-cases.
22+
*
23+
* @author Wouter de Jong <wouter@wouterj.nl>
24+
*
25+
* @experimental in Symfony 5.2
26+
*/
27+
abstract class AbstractRequestRateLimiter implements RequestRateLimiterInterface
28+
{
29+
public function consume(Request $request): Limit
30+
{
31+
$limiters = $this->getLimiters($request);
32+
if (0 === count($limiters)) {
33+
$limiters = [new NoLimiter()];
34+
}
35+
36+
$minimalLimit = null;
37+
foreach ($limiters as $limiter) {
38+
$limit = $limiter->consume(1);
39+
40+
if (null === $minimalLimit || $limit->getRemainingTokens() < $minimalLimit->getRemainingTokens()) {
41+
$minimalLimit = $limit;
42+
}
43+
}
44+
45+
return $minimalLimit;
46+
}
47+
48+
public function reset(): void
49+
{
50+
foreach ($this->getLimiters($request) as $limiter) {
51+
$limiter->reset();
52+
}
53+
}
54+
55+
/**
56+
* @return LimiterInterface[] a set of limiters using keys extracted from the request
57+
*/
58+
abstract protected function getLimiters(Request $request): array;
59+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
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\HttpFoundation\RateLimiter;
13+
14+
use Symfony\Component\HttpFoundation\Request;
15+
use Symfony\Component\RateLimiter\Limit;
16+
17+
/**
18+
* A special type of limiter that deals with requests.
19+
*
20+
* This allows to limit on different types of information
21+
* from the requests.
22+
*
23+
* @author Wouter de Jong <wouter@wouterj.nl>
24+
*
25+
* @experimental in Symfony 5.2
26+
*/
27+
interface RequestRateLimiterInterface
28+
{
29+
public function consume(Request $request): Limit;
30+
31+
public function reset(): void;
32+
}

src/Symfony/Component/Security/Core/Exception/TooManyLoginAttemptsAuthenticationException.php

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,46 @@
1919
*/
2020
class TooManyLoginAttemptsAuthenticationException extends AuthenticationException
2121
{
22+
private $threshold;
23+
24+
public function __construct(int $threshold = null)
25+
{
26+
$this->threshold = $threshold;
27+
}
28+
29+
/**
30+
* {@inheritdoc}
31+
*/
32+
public function getMessageData(): array
33+
{
34+
return [
35+
'%minutes%' => $this->threshold,
36+
];
37+
}
38+
2239
/**
2340
* {@inheritdoc}
2441
*/
2542
public function getMessageKey(): string
2643
{
27-
return 'Too many failed login attempts, please try again later.';
44+
return 'Too many failed login attempts, please try again '.($this->threshold ? 'in %minutes% minute'.($this->threshold > 1 ? 's' : '').'.' : 'later.');
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);
2863
}
2964
}

src/Symfony/Component/Security/Http/EventListener/LoginThrottlingListener.php

Lines changed: 8 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,12 @@
1212
namespace Symfony\Component\Security\Http\EventListener;
1313

1414
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
15-
use Symfony\Component\HttpFoundation\Request;
15+
use Symfony\Component\HttpFoundation\RateLimiter\RequestRateLimiterInterface;
1616
use Symfony\Component\HttpFoundation\RequestStack;
17-
use Symfony\Component\RateLimiter\Limiter;
1817
use Symfony\Component\Security\Core\Exception\TooManyLoginAttemptsAuthenticationException;
18+
use Symfony\Component\Security\Core\Security;
1919
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
2020
use Symfony\Component\Security\Http\Event\CheckPassportEvent;
21-
use Symfony\Component\Security\Http\Event\LoginSuccessEvent;
2221

2322
/**
2423
* @author Wouter de Jong <wouter@wouterj.nl>
@@ -30,7 +29,7 @@ final class LoginThrottlingListener implements EventSubscriberInterface
3029
private $requestStack;
3130
private $limiter;
3231

33-
public function __construct(RequestStack $requestStack, Limiter $limiter)
32+
public function __construct(RequestStack $requestStack, RequestRateLimiterInterface $limiter)
3433
{
3534
$this->requestStack = $requestStack;
3635
$this->limiter = $limiter;
@@ -44,33 +43,18 @@ public function checkPassport(CheckPassportEvent $event): void
4443
}
4544

4645
$request = $this->requestStack->getMasterRequest();
47-
$username = $passport->getBadge(UserBadge::class)->getUserIdentifier();
48-
$limiterKey = $this->createLimiterKey($username, $request);
46+
$request->attributes->set(Security::LAST_USERNAME, $passport->getBadge(UserBadge::class)->getUserIdentifier());
4947

50-
$limiter = $this->limiter->create($limiterKey);
51-
if (!$limiter->consume()->isAccepted()) {
52-
throw new TooManyLoginAttemptsAuthenticationException();
48+
$limit = $this->limiter->consume($request);
49+
if (!$limit->isAccepted()) {
50+
throw new TooManyLoginAttemptsAuthenticationException(ceil(($limit->getRetryAfter()->getTimestamp() - time()) / 60));
5351
}
5452
}
5553

56-
public function onSuccessfulLogin(LoginSuccessEvent $event): void
57-
{
58-
$limiterKey = $this->createLimiterKey($event->getAuthenticatedToken()->getUsername(), $event->getRequest());
59-
$limiter = $this->limiter->create($limiterKey);
60-
61-
$limiter->reset();
62-
}
63-
6454
public static function getSubscribedEvents(): array
6555
{
6656
return [
67-
CheckPassportEvent::class => ['checkPassport', 64],
68-
LoginSuccessEvent::class => 'onSuccessfulLogin',
57+
CheckPassportEvent::class => ['checkPassport', 2080],
6958
];
7059
}
71-
72-
private function createLimiterKey($username, Request $request): string
73-
{
74-
return $username.$request->getClientIp();
75-
}
7660
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
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\Http\RateLimiter;
13+
14+
use Symfony\Component\HttpFoundation\RateLimiter\AbstractRequestRateLimiter;
15+
use Symfony\Component\HttpFoundation\Request;
16+
use Symfony\Component\RateLimiter\Limiter;
17+
use Symfony\Component\Security\Core\Security;
18+
19+
/**
20+
* A default login throttling limiter.
21+
*
22+
* This limiter prevents breadth-first attacks by enforcing
23+
* a limit on username+IP and a (higher) limit on IP.
24+
*
25+
* @author Wouter de Jong <wouter@wouterj.nl>
26+
*
27+
* @experimental in Symfony 5.2
28+
*/
29+
final class DefaultLoginRateLimiter extends AbstractRequestRateLimiter
30+
{
31+
private $globalLimiter;
32+
private $localLimiter;
33+
34+
public function __construct(Limiter $globalLimiter, Limiter $localLimiter)
35+
{
36+
$this->globalLimiter = $globalLimiter;
37+
$this->localLimiter = $localLimiter;
38+
}
39+
40+
protected function getLimiters(Request $request): array
41+
{
42+
return [
43+
$this->globalLimiter->create($request->getClientIp()),
44+
$this->localLimiter->create($request->attributes->get(Security::LAST_USERNAME).$request->getClientIp()),
45+
];
46+
}
47+
}

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy