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

Skip to content

[Security] Added login throttling feature #38204

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

Merged
merged 1 commit into from
Sep 17, 2020
Merged
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 @@ -1736,15 +1736,37 @@ private function addRateLimiterSection(ArrayNodeDefinition $rootNode)
->useAttributeAsKey('name')
->arrayPrototype()
->children()
->scalarNode('lock')->defaultValue('lock.factory')->end()
->scalarNode('storage')->defaultValue('cache.app')->end()
->scalarNode('strategy')->isRequired()->end()
->integerNode('limit')->isRequired()->end()
->scalarNode('interval')->end()
->scalarNode('lock_factory')
->info('The service ID of the lock factory used by this limiter')
->defaultValue('lock.factory')
->end()
->scalarNode('cache_pool')
->info('The cache pool to use for storing the current limiter state')
->defaultValue('cache.app')
->end()
->scalarNode('storage_service')
->info('The service ID of a custom storage implementation, this precedes any configured "cache_pool"')
->defaultNull()
->end()
->enumNode('strategy')
->info('The rate limiting algorithm to use for this rate')
->isRequired()
->values(['fixed_window', 'token_bucket'])
->end()
->integerNode('limit')
->info('The maximum allowed hits in a fixed interval or burst')
->isRequired()
->end()
->scalarNode('interval')
->info('Configures the fixed interval if "strategy" is set to "fixed_window". The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent).')
->end()
->arrayNode('rate')
->info('Configures the fill rate if "strategy" is set to "token_bucket"')
->children()
->scalarNode('interval')->isRequired()->end()
->integerNode('amount')->defaultValue(1)->end()
->scalarNode('interval')
->info('Configures the rate interval. The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent).')
->end()
->integerNode('amount')->info('Amount of tokens to add each interval')->defaultValue(1)->end()
->end()
->end()
->end()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2190,38 +2190,31 @@ private function registerRateLimiterConfiguration(array $config, ContainerBuilde

$loader->load('rate_limiter.php');

$locks = [];
$storages = [];
foreach ($config['limiters'] as $name => $limiterConfig) {
$limiter = $container->setDefinition($limiterId = 'limiter.'.$name, new ChildDefinition('limiter'));

if (!isset($locks[$limiterConfig['lock']])) {
$locks[$limiterConfig['lock']] = new Reference($limiterConfig['lock']);
}
$limiter->addArgument($locks[$limiterConfig['lock']]);
unset($limiterConfig['lock']);

if (!isset($storages[$limiterConfig['storage']])) {
$storageId = $limiterConfig['storage'];
// cache pools are configured by the FrameworkBundle, so they
// exists in the scoped ContainerBuilder provided to this method
if ($container->has($storageId)) {
if ($container->findDefinition($storageId)->hasTag('cache.pool')) {
$container->register('limiter.storage.'.$storageId, CacheStorage::class)->addArgument(new Reference($storageId));
$storageId = 'limiter.storage.'.$storageId;
}
}
self::registerRateLimiter($container, $name, $limiterConfig);
}
}

$storages[$limiterConfig['storage']] = new Reference($storageId);
}
$limiter->replaceArgument(1, $storages[$limiterConfig['storage']]);
unset($limiterConfig['storage']);
public static function registerRateLimiter(ContainerBuilder $container, string $name, array $limiterConfig)
{
$limiter = $container->setDefinition($limiterId = 'limiter.'.$name, new ChildDefinition('limiter'));

$limiterConfig['id'] = $name;
$limiter->replaceArgument(0, $limiterConfig);
$limiter->addArgument(new Reference($limiterConfig['lock_factory']));
unset($limiterConfig['lock_factory']);

$container->registerAliasForArgument($limiterId, Limiter::class, $name.'.limiter');
$storageId = $limiterConfig['storage_service'] ?? null;
if (null === $storageId) {
$container->register($storageId = 'limiter.storage.'.$name, CacheStorage::class)->addArgument(new Reference($limiterConfig['cache_pool']));
}

$limiter->replaceArgument(1, new Reference($storageId));
unset($limiterConfig['storage']);
unset($limiterConfig['cache_pool']);

$limiterConfig['id'] = $name;
$limiter->replaceArgument(0, $limiterConfig);

$container->registerAliasForArgument($limiterId, Limiter::class, $name.'.limiter');
}

private function resolveTrustedHeaders(array $headers): int
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -650,8 +650,9 @@
<xsd:element name="rate" type="rate_limiter_rate" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" />
<xsd:attribute name="lock" type="xsd:string" />
<xsd:attribute name="storage" type="xsd:string" />
<xsd:attribute name="lock-factory" type="xsd:string" />
<xsd:attribute name="storage-service" type="xsd:string" />
<xsd:attribute name="cache-pool" type="xsd:string" />
<xsd:attribute name="strategy" type="xsd:string" />
<xsd:attribute name="limit" type="xsd:int" />
<xsd:attribute name="interval" type="xsd:string" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* 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\Bundle\FrameworkBundle\DependencyInjection\FrameworkExtension;
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\DependencyInjection\Reference;
use Symfony\Component\Security\Http\EventListener\LoginThrottlingListener;

/**
* @author Wouter de Jong <[email protected]>
*
* @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()
->scalarNode('limiter')->info('The name of the limiter that you defined under "framework.rate_limiter".')->end()
->integerNode('max_attempts')->defaultValue(5)->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.');
}

if (!isset($config['limiter'])) {
if (!class_exists(FrameworkExtension::class) || !method_exists(FrameworkExtension::class, 'registerRateLimiter')) {
throw new \LogicException('You must either configure a rate limiter for "security.firewalls.'.$firewallName.'.login_throttling" or install symfony/framework-bundle:^5.2');
}

FrameworkExtension::registerRateLimiter($container, $config['limiter'] = '_login_'.$firewallName, [
'strategy' => 'fixed_window',
'limit' => $config['max_attempts'],
'interval' => '1 minute',
'lock_factory' => 'lock.factory',
'cache_pool' => 'cache.app',
]);
}

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

return [];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
use Symfony\Component\Security\Http\Authenticator\X509Authenticator;
use Symfony\Component\Security\Http\Event\CheckPassportEvent;
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 @@ -113,6 +114,13 @@
])
->tag('monolog.logger', ['channel' => 'security'])

->set('security.listener.login_throttling', LoginThrottlingListener::class)
->abstract()
->args([
service('request_stack'),
abstract_arg('rate limiter'),
])

// 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 @@ -28,6 +28,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 @@ -64,6 +65,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 @@ -3,7 +3,7 @@
{% block body %}

{% if error %}
<div>{{ error.message }}</div>
<div>{{ error.messageKey }}</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 later.', $text);
}

public function provideClientOptions()
{
yield [['test_case' => 'StandardFormLogin', 'root_config' => 'config.yml', 'enable_authenticator_manager' => true]];
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
imports:
- { resource: ./config.yml }

framework:
lock: ~
rate_limiter: ~

security:
firewalls:
default:
login_throttling:
max_attempts: 1
1 change: 1 addition & 0 deletions src/Symfony/Bundle/SecurityBundle/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"symfony/form": "^4.4|^5.0",
"symfony/framework-bundle": "^5.2",
"symfony/process": "^4.4|^5.0",
"symfony/rate-limiter": "^5.2",
"symfony/serializer": "^4.4|^5.0",
"symfony/translation": "^4.4|^5.0",
"symfony/twig-bundle": "^4.4|^5.0",
Expand Down
1 change: 1 addition & 0 deletions src/Symfony/Component/Security/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ CHANGELOG
* Added `FirewallListenerInterface` to make the execution order of firewall listeners configurable
* Added translator to `\Symfony\Component\Security\Http\Authenticator\JsonLoginAuthenticator` and `\Symfony\Component\Security\Http\Firewall\UsernamePasswordJsonAuthenticationListener` to translate authentication failure messages
* Added a CurrentUser attribute to force the UserValueResolver to resolve an argument to the current user.
* Added `LoginThrottlingListener`.

5.1.0
-----
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* 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 <[email protected]>
*/
class TooManyLoginAttemptsAuthenticationException extends AuthenticationException
{
/**
* {@inheritdoc}
*/
public function getMessageKey(): string
{
return 'Too many failed login attempts, please try again later.';
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@
<source>Account is locked.</source>
<target>Account is locked.</target>
</trans-unit>
<trans-unit id="17">
<source>Too many failed login attempts, please try again later.</source>
<target>Too many failed login attempts, please try again later.</target>
</trans-unit>
</body>
</file>
</xliff>
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@
<source>Account is locked.</source>
<target>Account is geblokkeerd.</target>
</trans-unit>
<trans-unit id="17">
<source>Too many failed login attempts, please try again later.</source>
<target>Er waren teveel mislukte inlogpogingen, probeer het later opnieuw.</target>
</trans-unit>
</body>
</file>
</xliff>
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ public function __construct(string $userIdentifier, ?callable $userLoader = null
$this->userLoader = $userLoader;
}

public function getUserIdentifier(): string
{
return $this->userIdentifier;
}

public function getUser(): UserInterface
{
if (null === $this->user) {
Expand Down
Loading