Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@
->args([
service('request_stack'),
abstract_arg('request rate limiter'),
service('event_dispatcher'),
])

// Authenticators
Expand Down
1 change: 1 addition & 0 deletions src/Symfony/Component/Security/Http/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ CHANGELOG
* Configure the decorated handler of `CustomAuthenticationSuccessHandler` and `CustomAuthenticationFailureHandler` when they are called instead of when they are built, so that a single handler can be shared by several authenticators
* Add argument `$parameters` to `LoginLinkHandlerInterface::createLoginLink()` to add extra query parameters covered by the link signature
* Expose the verified extra parameters via the `_login_link_parameters` request attribute when consuming a login link
* Dispatch `RateLimitExceededEvent` from `LoginThrottlingListener` when login throttling rejects an attempt

8.1
---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,14 @@
use Symfony\Component\HttpFoundation\RateLimiter\PeekableRequestRateLimiterInterface;
use Symfony\Component\HttpFoundation\RateLimiter\RequestRateLimiterInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\RateLimiter\Event\RateLimitExceededEvent;
use Symfony\Component\Security\Core\Exception\TooManyLoginAttemptsAuthenticationException;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Event\CheckPassportEvent;
use Symfony\Component\Security\Http\Event\LoginFailureEvent;
use Symfony\Component\Security\Http\Event\LoginSuccessEvent;
use Symfony\Component\Security\Http\SecurityRequestAttributes;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;

/**
* @author Wouter de Jong <[email protected]>
Expand All @@ -30,6 +32,7 @@ final class LoginThrottlingListener implements EventSubscriberInterface
public function __construct(
private RequestStack $requestStack,
private RequestRateLimiterInterface $limiter,
private ?EventDispatcherInterface $eventDispatcher = null,
) {
}

Expand All @@ -49,11 +52,19 @@ public function checkPassport(CheckPassportEvent $event): void
// be accepted even if there are 0 tokens remaining to be consumed. We check both
// anyway for safety in case third party implementations behave unexpectedly.
if (!$limit->isAccepted() || 0 === $limit->getRemainingTokens()) {
if ($this->eventDispatcher && class_exists(RateLimitExceededEvent::class)) {
$this->eventDispatcher->dispatch(new RateLimitExceededEvent($limit, key: $request->attributes->get(SecurityRequestAttributes::LAST_USERNAME).'-'.$request->getClientIp()));
}

throw new TooManyLoginAttemptsAuthenticationException(ceil(($limit->getRetryAfter()->getTimestamp() - time()) / 60));
}
} else {
$limit = $this->limiter->consume($request);
if (!$limit->isAccepted()) {
if ($this->eventDispatcher && class_exists(RateLimitExceededEvent::class)) {
$this->eventDispatcher->dispatch(new RateLimitExceededEvent($limit, key: $request->attributes->get(SecurityRequestAttributes::LAST_USERNAME).'-'.$request->getClientIp()));
}

throw new TooManyLoginAttemptsAuthenticationException(ceil(($limit->getRetryAfter()->getTimestamp() - time()) / 60));
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@
namespace Symfony\Component\Security\Http\Tests\EventListener;

use PHPUnit\Framework\TestCase;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\RateLimiter\Event\RateLimitExceededEvent;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\RateLimiter\Storage\InMemoryStorage;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
Expand All @@ -34,22 +36,7 @@ class LoginThrottlingListenerTest extends TestCase
protected function setUp(): void
{
$this->requestStack = new RequestStack();

$localLimiter = new RateLimiterFactory([
'id' => 'login',
'policy' => 'fixed_window',
'limit' => 3,
'interval' => '1 minute',
], new InMemoryStorage());
$globalLimiter = new RateLimiterFactory([
'id' => 'login',
'policy' => 'fixed_window',
'limit' => 6,
'interval' => '1 minute',
], new InMemoryStorage());
$limiter = new DefaultLoginRateLimiter($globalLimiter, $localLimiter, '$3cre7');

$this->listener = new LoginThrottlingListener($this->requestStack, $limiter);
$this->listener = new LoginThrottlingListener($this->requestStack, $this->createLimiter());
}

public function testPreventsLoginWhenOverLocalThreshold()
Expand Down Expand Up @@ -100,6 +87,58 @@ public function testPreventsLoginWhenOverGlobalThreshold()
$this->listener->checkPassport($this->createCheckPassportEvent($passports[0]));
}

public function testDispatchesRateLimitExceededEventWhenThrottled()
{
if (!class_exists(RateLimitExceededEvent::class)) {
$this->markTestSkipped('The installed "symfony/rate-limiter" does not provide RateLimitExceededEvent.');
}

$passport = $this->createPassport('wouter');

$this->requestStack->push($this->createRequest());

$dispatcher = new EventDispatcher();

$dispatchedEvent = null;
$dispatcher->addListener(RateLimitExceededEvent::class, static function (RateLimitExceededEvent $event) use (&$dispatchedEvent) {
$dispatchedEvent = $event;
});

$listener = new LoginThrottlingListener($this->requestStack, $this->createLimiter(), $dispatcher);

for ($i = 0; $i < 3; ++$i) {
$listener->checkPassport($this->createCheckPassportEvent($passport));
$listener->onFailedLogin($this->createLoginFailedEvent($passport));
}

try {
$listener->checkPassport($this->createCheckPassportEvent($passport));
$this->fail('Expected TooManyLoginAttemptsAuthenticationException');
} catch (TooManyLoginAttemptsAuthenticationException) {
}

$this->assertInstanceOf(RateLimitExceededEvent::class, $dispatchedEvent);
$this->assertNull($dispatchedEvent->getLimiterName());
$this->assertSame('wouter-192.168.1.0', $dispatchedEvent->getKey());
}

public function testAcceptedDoesNotDispatchRateLimitExceededEvent()
{
$this->requestStack->push($this->createRequest());

$dispatched = [];
$dispatcher = new EventDispatcher();
$dispatcher->addListener(RateLimitExceededEvent::class, static function ($event) use (&$dispatched) {
$dispatched[] = $event;
});

$listener = new LoginThrottlingListener($this->requestStack, $this->createLimiter(), $dispatcher);

$listener->checkPassport($this->createCheckPassportEvent($this->createPassport('wouter')));

$this->assertSame([], $dispatched);
}

private function createPassport($username)
{
return new SelfValidatingPassport(new UserBadge($username));
Expand All @@ -122,4 +161,22 @@ private function createRequest($ip = '192.168.1.0')

return $request;
}

private function createLimiter(): DefaultLoginRateLimiter
{
$localLimiter = new RateLimiterFactory([
'id' => 'login',
'policy' => 'fixed_window',
'limit' => 3,
'interval' => '1 minute',
], new InMemoryStorage());
$globalLimiter = new RateLimiterFactory([
'id' => 'login',
'policy' => 'fixed_window',
'limit' => 6,
'interval' => '1 minute',
], new InMemoryStorage());

return new DefaultLoginRateLimiter($globalLimiter, $localLimiter, '$3cre7');
}
}