8000 [POC][Security] Added basic login throttling feature by wouterj · Pull Request #37444 · symfony/symfony · GitHub
[go: up one dir, main page]

Skip to content

[POC][Security] Added basic login throttling feature #37444

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
Closed
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
@@ -0,0 +1,69 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <wouter@wouterj.nl>
*
* @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 [];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions src/Symfony/Bundle/SecurityBundle/SecurityBundle.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

{% if error %}
<div>{{ error.message }}</div>
<div>{{ error.messageKey|replace(error.messageData) }}</div>
{% endif %}

<form action="{{ path('form_login_check') }}" method="post">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@

namespace Symfony\Bundle\SecurityBundle\Tests\Functional;

use Symfony\Component\Security\Http\EventListener\LoginThrottlingListener;

class FormLoginTest extends AbstractWebTestCase
{
/**
Expand Down Expand Up @@ -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]];
Expand Down
F438
Original file line number Diff line numberDiff line change
@@ -0,0 +1,9 @@
imports:
- { resource: ./config.yml }

security:
firewalls:
default:
login_throttling:
threshold: 1
lock_timeout: 10
3 changes: 3 additions & 0 deletions src/Symfony/Component/Security/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
-----
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <wouter@wouterj.nl>
*/
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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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)]);
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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']));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <wouter@wouterj.nl>
*
* @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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
* user checking)
*
* @author Wouter de Jong <wouter@wouterj.nl>
*
* @final
*/
class CheckPassportEvent extends Event
{
Expand Down
Loading
0