diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/LoginThrottlingFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/LoginThrottlingFactory.php new file mode 100644 index 0000000000000..2d14a9ab48ce6 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/LoginThrottlingFactory.php @@ -0,0 +1,69 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory; + +use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; +use Symfony\Component\Config\Definition\Builder\NodeDefinition; +use Symfony\Component\DependencyInjection\ChildDefinition; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\Security\Http\EventListener\LoginThrottlingListener; + +/** + * @author Wouter de Jong + * + * @internal + */ +class LoginThrottlingFactory implements AuthenticatorFactoryInterface, SecurityFactoryInterface +{ + public function create(ContainerBuilder $container, string $id, array $config, string $userProvider, ?string $defaultEntryPoint) + { + throw new \LogicException('Login throttling is not supported when "security.enable_authenticator_manager" is not set to true.'); + } + + public function getPosition(): string + { + // this factory doesn't register any authenticators, this position doesn't matter + return 'pre_auth'; + } + + public function getKey(): string + { + return 'login_throttling'; + } + + /** + * @param ArrayNodeDefinition $builder + */ + public function addConfiguration(NodeDefinition $builder) + { + $builder + ->children() + ->integerNode('threshold')->defaultValue(3)->end() + ->integerNode('lock_timeout')->defaultValue(1)->end() + ->end(); + } + + public function createAuthenticator(ContainerBuilder $container, string $firewallName, array $config, string $userProviderId): array + { + if (!class_exists(LoginThrottlingListener::class)) { + throw new \LogicException('Login throttling requires symfony/security-http:^5.2.'); + } + + $container + ->setDefinition('security.listener.login_throttling.'.$firewallName, new ChildDefinition('security.listener.login_throttling')) + ->replaceArgument(1, $config['threshold']) + ->replaceArgument(2, $config['lock_timeout']) + ->addTag('kernel.event_subscriber', ['dispatcher' => 'security.event_dispatcher.'.$firewallName]); + + return []; + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator.php b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator.php index 0274a50765ce8..6f01982ce261e 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator.php +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator.php @@ -24,6 +24,7 @@ use Symfony\Component\Security\Http\Authenticator\RemoteUserAuthenticator; use Symfony\Component\Security\Http\Authenticator\X509Authenticator; use Symfony\Component\Security\Http\EventListener\CheckCredentialsListener; +use Symfony\Component\Security\Http\EventListener\LoginThrottlingListener; use Symfony\Component\Security\Http\EventListener\PasswordMigratingListener; use Symfony\Component\Security\Http\EventListener\RememberMeListener; use Symfony\Component\Security\Http\EventListener\SessionStrategyListener; @@ -99,6 +100,17 @@ ]) ->tag('monolog.logger', ['channel' => 'security']) + ->set('security.listener.login_throttling', LoginThrottlingListener::class) + ->abstract() + ->args([ + service('request_stack'), + inline_service('cache.security.locked_sessions') + ->parent('cache.system') + ->tag('cache.pool'), + abstract_arg('threshold'), + abstract_arg('timeout'), + ]) + // Authenticators ->set('security.authenticator.http_basic', HttpBasicAuthenticator::class) ->abstract() diff --git a/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php b/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php index 9388ec3331f14..66d4665fb99e1 100644 --- a/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php +++ b/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php @@ -27,6 +27,7 @@ use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\HttpBasicLdapFactory; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\JsonLoginFactory; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\JsonLoginLdapFactory; +use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\LoginThrottlingFactory; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\RememberMeFactory; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\RemoteUserFactory; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\X509Factory; @@ -67,6 +68,7 @@ public function build(ContainerBuilder $container) $extension->addSecurityListenerFactory(new GuardAuthenticationFactory()); $extension->addSecurityListenerFactory(new AnonymousFactory()); $extension->addSecurityListenerFactory(new CustomAuthenticatorFactory()); + $extension->addSecurityListenerFactory(new LoginThrottlingFactory()); $extension->addUserProviderFactory(new InMemoryFactory()); $extension->addUserProviderFactory(new LdapFactory()); diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/FormLoginBundle/Resources/views/Login/login.html.twig b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/FormLoginBundle/Resources/views/Login/login.html.twig index 059f5f2bca1d2..37debb972799d 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/FormLoginBundle/Resources/views/Login/login.html.twig +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/FormLoginBundle/Resources/views/Login/login.html.twig @@ -4,6 +4,7 @@ {% if error %}
{{ error.message }}
+
{{ error.messageKey|replace(error.messageData) }}
{% endif %}
diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/FormLoginTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/FormLoginTest.php index 45d74fc72261f..96eb3041e7145 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/FormLoginTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/FormLoginTest.php @@ -11,6 +11,8 @@ namespace Symfony\Bundle\SecurityBundle\Tests\Functional; +use Symfony\Component\Security\Http\EventListener\LoginThrottlingListener; + class FormLoginTest extends AbstractWebTestCase { /** @@ -106,6 +108,28 @@ public function testFormLoginRedirectsToProtectedResourceAfterLogin(array $optio $this->assertStringContainsString('You\'re browsing to path "/protected_resource".', $text); } + public function testLoginThrottling() + { + if (!class_exists(LoginThrottlingListener::class)) { + $this->markTestSkipped('Login throttling requires symfony/security-http:^5.2'); + } + + $client = $this->createClient(['test_case' => 'StandardFormLogin', 'root_config' => 'login_throttling.yml', 'enable_authenticator_manager' => true]); + + $form = $client->request('GET', '/login')->selectButton('login')->form(); + $form['_username'] = 'johannes'; + $form['_password'] = 'wrong'; + $client->submit($form); + + $client->followRedirect()->selectButton('login')->form(); + $form['_username'] = 'johannes'; + $form['_password'] = 'wrong'; + $client->submit($form); + + $text = $client->followRedirect()->text(null, true); + $this->assertStringContainsString('Too many failed login attempts, please try again in 10 minutes.', $text); + } + public function provideClientOptions() { yield [['test_case' => 'StandardFormLogin', 'root_config' => 'config.yml', 'enable_authenticator_manager' => true]]; diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/login_throttling.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/login_throttling.yml new file mode 100644 index 0000000000000..88a938c004209 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/login_throttling.yml @@ -0,0 +1,9 @@ +imports: + - { resource: ./config.yml } + +security: + firewalls: + default: + login_throttling: + threshold: 1 + lock_timeout: 10 diff --git a/src/Symfony/Component/Security/CHANGELOG.md b/src/Symfony/Component/Security/CHANGELOG.md index c0981d698c8d8..7889c2190bff0 100644 --- a/src/Symfony/Component/Security/CHANGELOG.md +++ b/src/Symfony/Component/Security/CHANGELOG.md @@ -5,6 +5,9 @@ CHANGELOG ----- * Added attributes on ``Passport`` + * Added `LoginThrottlingBadge` and listener + * Marked `Http\CheckPassportEvent`, `Http\LoginFailureEvent` and `Http\LoginSuccessEvent` as `@final` + * [BC break] Added `?PassportInterface $passport` as 3rd argument in `Http\LoginFailureEvent` 5.1.0 ----- diff --git a/src/Symfony/Component/Security/Core/Exception/SessionLockedException.php b/src/Symfony/Component/Security/Core/Exception/SessionLockedException.php new file mode 100644 index 0000000000000..4247fcb011726 --- /dev/null +++ b/src/Symfony/Component/Security/Core/Exception/SessionLockedException.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Core\Exception; + +/** + * This exception is thrown if there where too many failed login attempts in + * this session. + * + * @author Wouter de Jong + */ +class SessionLockedException extends AuthenticationException +{ + private $threshold; + + /** + * @param int $threshold in minutes + */ + public function __construct(int $threshold) + { + $this->threshold = $threshold; + } + + public function getMessageData(): array + { + return [ + '%minutes%' => $this->threshold, + ]; + } + + /** + * {@inheritdoc} + */ + public function getMessageKey(): string + { + return 'Too many failed login attempts, please try again in %minutes% minutes.'; + } + + /** + * {@inheritdoc} + */ + public function __serialize(): array + { + return [$this->threshold, parent::__serialize()]; + } + + /** + * {@inheritdoc} + */ + public function __unserialize(array $data): void + { + [$this->threshold, $parentData] = $data; + $parentData = \is_array($parentData) ? $parentData : unserialize($parentData); + parent::__unserialize($parentData); + } +} diff --git a/src/Symfony/Component/Security/Http/Authentication/AuthenticatorManager.php b/src/Symfony/Component/Security/Http/Authentication/AuthenticatorManager.php index a9bdf8a36f236..d48a9bc9fe1ef 100644 --- a/src/Symfony/Component/Security/Http/Authentication/AuthenticatorManager.php +++ b/src/Symfony/Component/Security/Http/Authentication/AuthenticatorManager.php @@ -154,6 +154,7 @@ private function executeAuthenticators(array $authenticators, Request $request): private function executeAuthenticator(AuthenticatorInterface $authenticator, Request $request): ?Response { + $passport = null; try { // get the passport from the Authenticator $passport = $authenticator->authenticate($request); @@ -190,7 +191,7 @@ private function executeAuthenticator(AuthenticatorInterface $authenticator, Req return null; } catch (AuthenticationException $e) { // oh no! Authentication failed! - $response = $this->handleAuthenticationFailure($e, $request, $authenticator); + $response = $this->handleAuthenticationFailure($e, $request, $authenticator, $passport); if ($response instanceof Response) { return $response; } @@ -221,7 +222,7 @@ private function handleAuthenticationSuccess(TokenInterface $authenticatedToken, /** * Handles an authentication failure and returns the Response for the authenticator. */ - private function handleAuthenticationFailure(AuthenticationException $authenticationException, Request $request, AuthenticatorInterface $authenticator): ?Response + private function handleAuthenticationFailure(AuthenticationException $authenticationException, Request $request, AuthenticatorInterface $authenticator, ?PassportInterface $passport): ?Response { if (null !== $this->logger) { $this->logger->info('Authenticator failed.', ['exception' => $authenticationException, 'authenticator' => \get_class($authenticator)]); @@ -232,7 +233,7 @@ private function handleAuthenticationFailure(AuthenticationException $authentica $this->logger->debug('The "{authenticator}" authenticator set the failure response.', ['authenticator' => \get_class($authenticator)]); } - $this->eventDispatcher->dispatch($loginFailureEvent = new LoginFailureEvent($authenticationException, $authenticator, $request, $response, $this->firewallName)); + $this->eventDispatcher->dispatch($loginFailureEvent = new LoginFailureEvent($authenticationException, $authenticator, $passport, $request, $response, $this->firewallName)); // returning null is ok, it means they want the request to continue return $loginFailureEvent->getResponse(); diff --git a/src/Symfony/Component/Security/Http/Authenticator/FormLoginAuthenticator.php b/src/Symfony/Component/Security/Http/Authenticator/FormLoginAuthenticator.php index 201eab349ded4..758e06516d134 100644 --- a/src/Symfony/Component/Security/Http/Authenticator/FormLoginAuthenticator.php +++ b/src/Symfony/Component/Security/Http/Authenticator/FormLoginAuthenticator.php @@ -26,6 +26,7 @@ use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface; use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\LoginThrottlingBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\PasswordUpgradeBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\RememberMeBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials; @@ -85,7 +86,7 @@ public function authenticate(Request $request): PassportInterface throw new AuthenticationServiceException('The user provider must return a UserInterface object.'); } - $passport = new Passport($user, new PasswordCredentials($credentials['password']), [new RememberMeBadge()]); + $passport = new Passport($user, new PasswordCredentials($credentials['password']), [new RememberMeBadge(), new LoginThrottlingBadge($credentials['username'])]); if ($this->options['enable_csrf']) { $passport->addBadge(new CsrfTokenBadge($this->options['csrf_token_id'], $credentials['csrf_token'])); } diff --git a/src/Symfony/Component/Security/Http/Authenticator/HttpBasicAuthenticator.php b/src/Symfony/Component/Security/Http/Authenticator/HttpBasicAuthenticator.php index 7a70ddc9f37d2..6bb0829e6be27 100644 --- a/src/Symfony/Component/Security/Http/Authenticator/HttpBasicAuthenticator.php +++ b/src/Symfony/Component/Security/Http/Authenticator/HttpBasicAuthenticator.php @@ -21,6 +21,7 @@ use Symfony\Component\Security\Core\User\PasswordUpgraderInterface; use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserProviderInterface; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\LoginThrottlingBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\PasswordUpgradeBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials; use Symfony\Component\Security\Http\Authenticator\Passport\Passport; @@ -71,7 +72,7 @@ public function authenticate(Request $request): PassportInterface throw new AuthenticationServiceException('The user provider must return a UserInterface object.'); } - $passport = new Passport($user, new PasswordCredentials($password)); + $passport = new Passport($user, new PasswordCredentials($password), [new LoginThrottlingBadge($username)]); if ($this->userProvider instanceof PasswordUpgraderInterface) { $passport->addBadge(new PasswordUpgradeBadge($password, $this->userProvider)); } diff --git a/src/Symfony/Component/Security/Http/Authenticator/JsonLoginAuthenticator.php b/src/Symfony/Component/Security/Http/Authenticator/JsonLoginAuthenticator.php index b277082a846d1..f3431a5ba362a 100644 --- a/src/Symfony/Component/Security/Http/Authenticator/JsonLoginAuthenticator.php +++ b/src/Symfony/Component/Security/Http/Authenticator/JsonLoginAuthenticator.php @@ -29,6 +29,7 @@ use Symfony\Component\Security\Core\User\UserProviderInterface; use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface; use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\LoginThrottlingBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\PasswordUpgradeBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials; use Symfony\Component\Security\Http\Authenticator\Passport\Passport; @@ -92,7 +93,7 @@ public function authenticate(Request $request): PassportInterface throw new AuthenticationServiceException('The user provider must return a UserInterface object.'); } - $passport = new Passport($user, new PasswordCredentials($credentials['password'])); + $passport = new Passport($user, new PasswordCredentials($credentials['password']), [new LoginThrottlingBadge($credentials['username'])]); if ($this->userProvider instanceof PasswordUpgraderInterface) { $passport->addBadge(new PasswordUpgradeBadge($credentials['password'], $this->userProvider)); } diff --git a/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/LoginThrottlingBadge.php b/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/LoginThrottlingBadge.php new file mode 100644 index 0000000000000..e44b98d4d5350 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/LoginThrottlingBadge.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Authenticator\Passport\Badge; + +/** + * Adds automatic login throttling. + * + * This limits the number of failed login attempts over + * a period of time based on username and IP address. + * + * @author Wouter de Jong + * + * @final + * @experimental in 5.2 + */ +class LoginThrottlingBadge implements BadgeInterface +{ + private $username; + + /** + * @param string $username The presented username + */ + public function __construct(string $username) + { + $this->username = $username; + } + + public function getUsername(): string + { + return $this->username; + } + + public function isResolved(): bool + { + return true; + } +} diff --git a/src/Symfony/Component/Security/Http/Event/CheckPassportEvent.php b/src/Symfony/Component/Security/Http/Event/CheckPassportEvent.php index 859d2d28dc8f3..002789b89dfe8 100644 --- a/src/Symfony/Component/Security/Http/Event/CheckPassportEvent.php +++ b/src/Symfony/Component/Security/Http/Event/CheckPassportEvent.php @@ -23,6 +23,8 @@ * user checking) * * @author Wouter de Jong + * + * @final */ class CheckPassportEvent extends Event { diff --git a/src/Symfony/Component/Security/Http/Event/LoginFailureEvent.php b/src/Symfony/Component/Security/Http/Event/LoginFailureEvent.php index 96da4e35ff73f..b1e1bc773413d 100644 --- a/src/Symfony/Component/Security/Http/Event/LoginFailureEvent.php +++ b/src/Symfony/Component/Security/Http/Event/LoginFailureEvent.php @@ -6,6 +6,7 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Security\Core\Exception\AuthenticationException; use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface; +use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface; use Symfony\Contracts\EventDispatcher\Event; /** @@ -15,19 +16,23 @@ * failure (e.g. to implement login throttling). * * @author Wouter de Jong + * + * @final */ class LoginFailureEvent extends Event { private $exception; private $authenticator; + private $passport; private $request; private $response; private $firewallName; - public function __construct(AuthenticationException $exception, AuthenticatorInterface $authenticator, Request $request, ?Response $response, string $firewallName) + public function __construct(AuthenticationException $exception, AuthenticatorInterface $authenticator, ?PassportInterface $passport, Request $request, ?Response $response, string $firewallName) { $this->exception = $exception; $this->authenticator = $authenticator; + $this->passport = $passport; $this->request = $request; $this->response = $response; $this->firewallName = $firewallName; @@ -43,6 +48,11 @@ public function getAuthenticator(): AuthenticatorInterface return $this->authenticator; } + public function getPassport(): ?PassportInterface + { + return $this->passport; + } + public function getFirewallName(): string { return $this->firewallName; diff --git a/src/Symfony/Component/Security/Http/Event/LoginSuccessEvent.php b/src/Symfony/Component/Security/Http/Event/LoginSuccessEvent.php index c7eee3a66e74d..1da218d9c5394 100644 --- a/src/Symfony/Component/Security/Http/Event/LoginSuccessEvent.php +++ b/src/Symfony/Component/Security/Http/Event/LoginSuccessEvent.php @@ -21,6 +21,8 @@ * (such as migrating the password). * * @author Wouter de Jong + * + * @final */ class LoginSuccessEvent extends Event { diff --git a/src/Symfony/Component/Security/Http/EventListener/LoginThrottlingListener.php b/src/Symfony/Component/Security/Http/EventListener/LoginThrottlingListener.php new file mode 100644 index 0000000000000..edf666c746b41 --- /dev/null +++ b/src/Symfony/Component/Security/Http/EventListener/LoginThrottlingListener.php @@ -0,0 +1,134 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\EventListener; + +use Psr\Cache\CacheItemPoolInterface; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\Security\Core\Exception\SessionLockedException; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\LoginThrottlingBadge; +use Symfony\Component\Security\Http\Event\CheckPassportEvent; +use Symfony\Component\Security\Http\Event\LoginFailureEvent; +use Symfony\Component\Security\Http\Event\LoginSuccessEvent; + +/** + * @author Wouter de Jong + * + * @final + * @experimental in 5.2 + */ +class LoginThrottlingListener implements EventSubscriberInterface +{ + private $requestStack; + private $cache; + private $threshold; + private $timeout; + + /** + * @param int $timeout in minutes + */ + public function __construct(RequestStack $requestStack, CacheItemPoolInterface $cache, int $threshold = 3, int $timeout = 1) + { + $this->requestStack = $requestStack; + $this->cache = $cache; + $this->threshold = $threshold; + $this->timeout = $timeout; + } + + /** + * Prevents authentication if the session is locked (due to too many failed attempts). + */ + public function checkPassport(CheckPassportEvent $event): void + { + $passport = $event->getPassport(); + if (!$passport->hasBadge(LoginThrottlingBadge::class)) { + return; + } + + $request = $this->requestStack->getMasterRequest(); + + $username = $passport->getBadge(LoginThrottlingBadge::class)->getUsername(); + $cacheKey = $this->generateCacheKey($username, $request); + + $cacheItem = $this->cache->getItem($cacheKey); + if (!$cacheItem->isHit()) { + return; + } + + $loginAttempts = $cacheItem->get(); + if ($loginAttempts >= $this->threshold) { + throw new SessionLockedException($this->timeout); + } + } + + /** + * Increases failed attempt counter and expands expiration time. + */ + public function onLoginFailure(LoginFailureEvent $event): void + { + $passport = $event->getPassport(); + if (!$passport->hasBadge(LoginThrottlingBadge::class)) { + return; + } + + $request = $this->requestStack->getMasterRequest(); + + $username = $passport->getBadge(LoginThrottlingBadge::class)->getUsername(); + $cacheKey = $this->generateCacheKey($username, $request); + $cacheItem = $this->cache->getItem($cacheKey); + + $count = $cacheItem->isHit() ? $cacheItem->get() : 0; + + $cacheItem->set(++$count); + $cacheItem->expiresAfter($this->timeout * 60); + + $this->cache->save($cacheItem); + } + + /** + * Resets failed attempt counter. + */ + public function onLoginSuccess(LoginSuccessEvent $event): void + { + $passport = $event->getPassport(); + if (!$passport->hasBadge(LoginThrottlingBadge::class)) { + return; + } + + $request = $this->requestStack->getMasterRequest(); + + $username = $passport->getBadge(LoginThrottlingBadge::class)->getUsername(); + $cacheKey = $this->generateCacheKey($username, $request); + + $cacheItem = $this->cache->getItem($cacheKey); + if (!$cacheItem->isHit()) { + return; + } + + $this->cache->deleteItem($cacheKey); + } + + public static function getSubscribedEvents(): array + { + return [ + CheckPassportEvent::class => ['checkPassport', 64], + LoginFailureEvent::class => 'onLoginFailure', + LoginSuccessEvent::class => 'onLoginSuccess', + ]; + } + + private function generateCacheKey(string $username, Request $request): string + { + return $username.$request->getClientIp(); + } +} diff --git a/src/Symfony/Component/Security/Http/Tests/EventListener/LoginThrottlingListenerTest.php b/src/Symfony/Component/Security/Http/Tests/EventListener/LoginThrottlingListenerTest.php new file mode 100644 index 0000000000000..980c6c63984d3 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/EventListener/LoginThrottlingListenerTest.php @@ -0,0 +1,132 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\EventListener; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Cache\Adapter\ArrayAdapter; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Exception\AuthenticationException; +use Symfony\Component\Security\Core\Exception\SessionLockedException; +use Symfony\Component\Security\Core\User\User; +use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\LoginThrottlingBadge; +use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; +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\EventListener\LoginThrottlingListener; + +class LoginThrottlingListenerTest extends TestCase +{ + private $requestStack; + private $listener; + private $cache; + + protected function setUp(): void + { + $this->requestStack = new RequestStack(); + $this->cache = new ArrayAdapter(); + $this->listener = new LoginThrottlingListener($this->requestStack, $this->cache); + } + + public function testCountsFailedAttempts() + { + $request = $this->createRequest(); + $passport = $this->createPassport('wouter'); + + $this->requestStack->push($request); + + $this->listener->onLoginFailure($this->createLoginFailureEvent($passport)); + $this->listener->onLoginFailure($this->createLoginFailureEvent($passport)); + $this->listener->onLoginFailure($this->createLoginFailureEvent($passport)); + + $cacheItem = $this->cache->getItem('wouter192.168.1.0'); + $this->assertTrue($cacheItem->isHit()); + $this->assertEquals(3, $cacheItem->get()); + } + + public function testSuccessfulLoginResetsCount() + { + $request = $this->createRequest(); + $passport = $this->createPassport('wouter'); + + $this->requestStack->push($request); + + $this->listener->onLoginFailure($this->createLoginFailureEvent($passport)); + $this->assertEquals(1, $this->cache->getItem('wouter192.168.1.0')->get()); + + $this->listener->onLoginSuccess($this->createLoginSuccessfulEvent($passport)); + $this->assertFalse($this->cache->getItem('wouter192.168.1.0')->isHit()); + } + + /** + * @dataProvider provideTooManyAttemptsData + */ + public function testPreventsLoginWhenOverThreshold($time, $attempts, $expectError) + { + if ($expectError) { + $this->expectException(SessionLockedException::class); + } else { + $this->expectNotToPerformAssertions(); + } + + $request = $this->createRequest(); + $passport = $this->createPassport('wouter'); + + $cacheItem = $this->cache->getItem('wouter192.168.1.0'); + $cacheItem->expiresAt(new \DateTime(($time >= 0 ? '+' : '').$time.' minutes')); + $cacheItem->set($attempts); + $this->cache->save($cacheItem); + + $this->requestStack->push($request); + + $this->listener->checkPassport($this->createCheckPassportEvent($passport)); + } + + public function provideTooManyAttemptsData() + { + yield [time() + 100, 3, true]; + yield [time() + 100, 4, true]; + yield [time() + 100, 0, false]; // below threshold + yield [time() + 100, 1, false]; // below threshold + } + + private function createPassport($username) + { + return new SelfValidatingPassport(new User($username, null), [new LoginThrottlingBadge($username)]); + } + + private function createLoginSuccessfulEvent($passport) + { + return new LoginSuccessEvent($this->createMock(AuthenticatorInterface::class), $passport, $this->createMock(TokenInterface::class), $this->requestStack->getCurrentRequest(), null, 'main'); + } + + private function createLoginFailureEvent($passport) + { + return new LoginFailureEvent(new AuthenticationException(), $this->createMock(AuthenticatorInterface::class), $passport, $this->requestStack->getCurrentRequest(), null, 'main'); + } + + private function createCheckPassportEvent($passport) + { + return new CheckPassportEvent($this->createMock(AuthenticatorInterface::class), $passport); + } + + private function createRequest($ip = '192.168.1.0') + { + $request = new Request(); + $request->server->set('REMOTE_ADDR', $ip); + + return $request; + } +} diff --git a/src/Symfony/Component/Security/Http/Tests/EventListener/RememberMeListenerTest.php b/src/Symfony/Component/Security/Http/Tests/EventListener/RememberMeListenerTest.php index 9af16a6a767c7..cbf59dd7ce665 100644 --- a/src/Symfony/Component/Security/Http/Tests/EventListener/RememberMeListenerTest.php +++ b/src/Symfony/Component/Security/Http/Tests/EventListener/RememberMeListenerTest.php @@ -86,6 +86,6 @@ private function createLoginSuccessfulEvent($providerKey, $response, PassportInt private function createLoginFailureEvent($providerKey) { - return new LoginFailureEvent(new AuthenticationException(), $this->createMock(AuthenticatorInterface::class), $this->request, null, $providerKey); + return new LoginFailureEvent(new AuthenticationException(), $this->createMock(AuthenticatorInterface::class), $this->createMock(PassportInterface::class), $this->request, null, $providerKey); } } diff --git a/src/Symfony/Component/Security/Http/composer.json b/src/Symfony/Component/Security/Http/composer.json index 7dfb787d4aa07..53648f6ffbf39 100644 --- a/src/Symfony/Component/Security/Http/composer.json +++ b/src/Symfony/Component/Security/Http/composer.json @@ -18,13 +18,14 @@ "require": { "php": ">=7.2.5", "symfony/deprecation-contracts": "^2.1", - "symfony/security-core": "^5.1", + "symfony/security-core": "^5.2", "symfony/http-foundation": "^4.4.7|^5.0.7", "symfony/http-kernel": "^4.4|^5.0", "symfony/polyfill-php80": "^1.15", "symfony/property-access": "^4.4|^5.0" }, "require-dev": { + "symfony/cache": "^4.4|^5.0", "symfony/routing": "^4.4|^5.0", "symfony/security-csrf": "^4.4|^5.0", "psr/log": "~1.0"