Skip to content

Commit f33ef3c

Browse files
Seldaekwouterj
authored andcommitted
Add PeekableRequestRateLimiterInterface and fix the LoginThrottlingListener to reduce amount of writes on the cache backend, fixes #40371
1 parent b2cd454 commit f33ef3c

File tree

2 files changed

+33
-14
lines changed

2 files changed

+33
-14
lines changed

EventListener/LoginThrottlingListener.php

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

1414
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
15+
use Symfony\Component\HttpFoundation\RateLimiter\PeekableRequestRateLimiterInterface;
1516
use Symfony\Component\HttpFoundation\RateLimiter\RequestRateLimiterInterface;
1617
use Symfony\Component\HttpFoundation\RequestStack;
1718
use Symfony\Component\Security\Core\Exception\TooManyLoginAttemptsAuthenticationException;
1819
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
1920
use Symfony\Component\Security\Http\Event\CheckPassportEvent;
21+
use Symfony\Component\Security\Http\Event\LoginFailureEvent;
2022
use Symfony\Component\Security\Http\Event\LoginSuccessEvent;
2123
use Symfony\Component\Security\Http\SecurityRequestAttributes;
2224

@@ -44,21 +46,41 @@ public function checkPassport(CheckPassportEvent $event): void
4446
$request = $this->requestStack->getMainRequest();
4547
$request->attributes->set(SecurityRequestAttributes::LAST_USERNAME, $passport->getBadge(UserBadge::class)->getUserIdentifier());
4648

47-
$limit = $this->limiter->consume($request);
48-
if (!$limit->isAccepted()) {
49-
throw new TooManyLoginAttemptsAuthenticationException(ceil(($limit->getRetryAfter()->getTimestamp() - time()) / 60));
49+
if ($this->limiter instanceof PeekableRequestRateLimiterInterface) {
50+
$limit = $this->limiter->peek($request);
51+
// Checking isAccepted here is not enough as peek consumes 0 token, it will
52+
// be accepted even if there are 0 tokens remaining to be consumed. We check both
53+
// anyway for safety in case third party implementations behave unexpectedly.
54+
if (!$limit->isAccepted() || 0 === $limit->getRemainingTokens()) {
55+
throw new TooManyLoginAttemptsAuthenticationException(ceil(($limit->getRetryAfter()->getTimestamp() - time()) / 60));
56+
}
57+
} else {
58+
$limit = $this->limiter->consume($request);
59+
if (!$limit->isAccepted()) {
60+
throw new TooManyLoginAttemptsAuthenticationException(ceil(($limit->getRetryAfter()->getTimestamp() - time()) / 60));
61+
}
5062
}
5163
}
5264

5365
public function onSuccessfulLogin(LoginSuccessEvent $event): void
5466
{
55-
$this->limiter->reset($event->getRequest());
67+
if (!$this->limiter instanceof PeekableRequestRateLimiterInterface) {
68+
$this->limiter->reset($event->getRequest());
69+
}
70+
}
71+
72+
public function onFailedLogin(LoginFailureEvent $event): void
73+
{
74+
if ($this->limiter instanceof PeekableRequestRateLimiterInterface) {
75+
$this->limiter->consume($event->getRequest());
76+
}
5677
}
5778

5879
public static function getSubscribedEvents(): array
5980
{
6081
return [
6182
CheckPassportEvent::class => ['checkPassport', 2080],
83+
LoginFailureEvent::class => 'onFailedLogin',
6284
LoginSuccessEvent::class => 'onSuccessfulLogin',
6385
];
6486
}

Tests/EventListener/LoginThrottlingListenerTest.php

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,13 @@
1616
use Symfony\Component\HttpFoundation\RequestStack;
1717
use Symfony\Component\RateLimiter\RateLimiterFactory;
1818
use Symfony\Component\RateLimiter\Storage\InMemoryStorage;
19-
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
19+
use Symfony\Component\Security\Core\Exception\AuthenticationException;
2020
use Symfony\Component\Security\Core\Exception\TooManyLoginAttemptsAuthenticationException;
2121
use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface;
2222
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
2323
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
2424
use Symfony\Component\Security\Http\Event\CheckPassportEvent;
25-
use Symfony\Component\Security\Http\Event\LoginSuccessEvent;
25+
use Symfony\Component\Security\Http\Event\LoginFailureEvent;
2626
use Symfony\Component\Security\Http\EventListener\LoginThrottlingListener;
2727
use Symfony\Component\Security\Http\RateLimiter\DefaultLoginRateLimiter;
2828

@@ -61,12 +61,7 @@ public function testPreventsLoginWhenOverLocalThreshold()
6161

6262
for ($i = 0; $i < 3; ++$i) {
6363
$this->listener->checkPassport($this->createCheckPassportEvent($passport));
64-
}
65-
66-
$this->listener->onSuccessfulLogin($this->createLoginSuccessfulEvent($passport));
67-
68-
for ($i = 0; $i < 3; ++$i) {
69-
$this->listener->checkPassport($this->createCheckPassportEvent($passport));
64+
$this->listener->onFailedLogin($this->createLoginFailedEvent($passport));
7065
}
7166

7267
$this->expectException(TooManyLoginAttemptsAuthenticationException::class);
@@ -82,6 +77,7 @@ public function testPreventsLoginWithMultipleCase()
8277

8378
for ($i = 0; $i < 3; ++$i) {
8479
$this->listener->checkPassport($this->createCheckPassportEvent($passports[$i % 3]));
80+
$this->listener->onFailedLogin($this->createLoginFailedEvent($passports[$i % 3]));
8581
}
8682

8783
$this->expectException(TooManyLoginAttemptsAuthenticationException::class);
@@ -97,6 +93,7 @@ public function testPreventsLoginWhenOverGlobalThreshold()
9793

9894
for ($i = 0; $i < 6; ++$i) {
9995
$this->listener->checkPassport($this->createCheckPassportEvent($passports[$i % 2]));
96+
$this->listener->onFailedLogin($this->createLoginFailedEvent($passports[$i % 2]));
10097
}
10198

10299
$this->expectException(TooManyLoginAttemptsAuthenticationException::class);
@@ -108,9 +105,9 @@ private function createPassport($username)
108105
return new SelfValidatingPassport(new UserBadge($username));
109106
}
110107

111-
private function createLoginSuccessfulEvent($passport)
108+
private function createLoginFailedEvent($passport)
112109
{
113-
return new LoginSuccessEvent($this->createMock(AuthenticatorInterface::class), $passport, $this->createMock(TokenInterface::class), $this->requestStack->getCurrentRequest(), null, 'main');
110+
return new LoginFailureEvent($this->createMock(AuthenticationException::class), $this->createMock(AuthenticatorInterface::class), $this->requestStack->getCurrentRequest(), null, 'main', $passport);
114111
}
115112

116113
private function createCheckPassportEvent($passport)

0 commit comments

Comments
 (0)