feature #40284 [RateLimiter][Security] Allow to use no lock in the rate limiter/login throttling (wouterj)

This PR was merged into the 5.3-dev branch.

Discussion
----------

[RateLimiter][Security] Allow to use no lock in the rate limiter/login throttling

| Q             | A
| ------------- | ---
| Branch?       | 5.x
| Bug fix?      | no
| New feature?  | yes
| Deprecations? | no
| Tickets       | Fix -
| License       | MIT
| Doc PR        | tbd

This PR adds support for disabling lock in rate limiters. This was brought up by @Seldaek. In most cases (e.g. login throttling), it's not critical to strictly avoid even a single overflow of the window/token. At least, it's probably not always worth the extra load on the lock storage (e.g. redis).

It also directly disables locking by default for login throttling. I'm not sure about this, but I feel like this fits the 80% case where it's definitely not needed (and it's easier to use if you don't need to set-up locking first).

Commits
-------

45be875e84 [Security][RateLimiter] Allow to use no lock in the rate limiter/login throttling
This commit is contained in:
Fabien Potencier 2021-02-26 08:27:17 +01:00
commit 79f6a5c692
8 changed files with 64 additions and 8 deletions

View File

@ -84,6 +84,8 @@ Security
SecurityBundle SecurityBundle
-------------- --------------
* [BC break] Add `login_throttling.lock_factory` setting defaulting to `null`. Set this option
to `lock.factory` if you need precise login rate limiting with synchronous requests.
* Deprecate `UserPasswordEncoderCommand` class and the corresponding `user:encode-password` command, * Deprecate `UserPasswordEncoderCommand` class and the corresponding `user:encode-password` command,
use `UserPasswordHashCommand` and `user:hash-password` instead use `UserPasswordHashCommand` and `user:hash-password` instead
* Deprecate the `security.encoder_factory.generic` service, the `security.encoder_factory` and `Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface` aliases, * Deprecate the `security.encoder_factory.generic` service, the `security.encoder_factory` and `Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface` aliases,

View File

@ -1874,7 +1874,7 @@ class Configuration implements ConfigurationInterface
->arrayPrototype() ->arrayPrototype()
->children() ->children()
->scalarNode('lock_factory') ->scalarNode('lock_factory')
->info('The service ID of the lock factory used by this limiter') ->info('The service ID of the lock factory used by this limiter (or null to disable locking)')
->defaultValue('lock.factory') ->defaultValue('lock.factory')
->end() ->end()
->scalarNode('cache_pool') ->scalarNode('cache_pool')

View File

@ -205,7 +205,7 @@ class FrameworkExtension extends Extension
private $httpClientConfigEnabled = false; private $httpClientConfigEnabled = false;
private $notifierConfigEnabled = false; private $notifierConfigEnabled = false;
private $propertyAccessConfigEnabled = false; private $propertyAccessConfigEnabled = false;
private $lockConfigEnabled = false; private static $lockConfigEnabled = false;
/** /**
* Responds to the app.config configuration parameter. * Responds to the app.config configuration parameter.
@ -438,7 +438,7 @@ class FrameworkExtension extends Extension
$this->registerPropertyInfoConfiguration($container, $loader); $this->registerPropertyInfoConfiguration($container, $loader);
} }
if ($this->lockConfigEnabled = $this->isConfigEnabled($container, $config['lock'])) { if (self::$lockConfigEnabled = $this->isConfigEnabled($container, $config['lock'])) {
$this->registerLockConfiguration($config['lock'], $container, $loader); $this->registerLockConfiguration($config['lock'], $container, $loader);
} }
@ -2344,10 +2344,6 @@ class FrameworkExtension extends Extension
private function registerRateLimiterConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader) private function registerRateLimiterConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader)
{ {
if (!$this->lockConfigEnabled) {
throw new LogicException('Rate limiter support cannot be enabled without enabling the Lock component.');
}
$loader->load('rate_limiter.php'); $loader->load('rate_limiter.php');
foreach ($config['limiters'] as $name => $limiterConfig) { foreach ($config['limiters'] as $name => $limiterConfig) {
@ -2362,7 +2358,13 @@ class FrameworkExtension extends Extension
$limiter = $container->setDefinition($limiterId = 'limiter.'.$name, new ChildDefinition('limiter')); $limiter = $container->setDefinition($limiterId = 'limiter.'.$name, new ChildDefinition('limiter'));
$limiter->addArgument(new Reference($limiterConfig['lock_factory'])); if (null !== $limiterConfig['lock_factory']) {
if (!self::$lockConfigEnabled) {
throw new LogicException(sprintf('Rate limiter "%s" requires the Lock component to be installed and configured.', $name));
}
$limiter->replaceArgument(2, new Reference($limiterConfig['lock_factory']));
}
unset($limiterConfig['lock_factory']); unset($limiterConfig['lock_factory']);
$storageId = $limiterConfig['storage_service'] ?? null; $storageId = $limiterConfig['storage_service'] ?? null;

View File

@ -24,6 +24,7 @@ return static function (ContainerConfigurator $container) {
->args([ ->args([
abstract_arg('config'), abstract_arg('config'),
abstract_arg('storage'), abstract_arg('storage'),
null,
]) ])
; ;
}; };

View File

@ -13,6 +13,8 @@ namespace Symfony\Bundle\FrameworkBundle\Tests\DependencyInjection;
use Symfony\Component\Config\FileLocator; use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Exception\LogicException;
use Symfony\Component\DependencyInjection\Exception\OutOfBoundsException;
use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;
use Symfony\Component\Workflow\Exception\InvalidDefinitionException; use Symfony\Component\Workflow\Exception\InvalidDefinitionException;
@ -82,4 +84,49 @@ class PhpFrameworkExtensionTest extends FrameworkExtensionTest
]); ]);
}); });
} }
public function testRateLimiterWithLockFactory()
{
try {
$this->createContainerFromClosure(function (ContainerBuilder $container) {
$container->loadFromExtension('framework', [
'rate_limiter' => [
'with_lock' => ['policy' => 'fixed_window', 'limit' => 10, 'interval' => '1 hour'],
],
]);
});
$this->fail('No LogicException thrown');
} catch (LogicException $e) {
$this->assertEquals('Rate limiter "with_lock" requires the Lock component to be installed and configured.', $e->getMessage());
}
$container = $this->createContainerFromClosure(function (ContainerBuilder $container) {
$container->loadFromExtension('framework', [
'lock' => true,
'rate_limiter' => [
'with_lock' => ['policy' => 'fixed_window', 'limit' => 10, 'interval' => '1 hour'],
],
]);
});
$withLock = $container->getDefinition('limiter.with_lock');
$this->assertEquals('lock.factory', (string) $withLock->getArgument(2));
}
public function testRateLimiterLockFactory()
{
$container = $this->createContainerFromClosure(function (ContainerBuilder $container) {
$container->loadFromExtension('framework', [
'rate_limiter' => [
'without_lock' => ['policy' => 'fixed_window', 'limit' => 10, 'interval' => '1 hour', 'lock_factory' => null],
],
]);
});
$this->expectException(OutOfBoundsException::class);
$this->expectExceptionMessage('The argument "2" doesn\'t exist.');
$container->getDefinition('limiter.without_lock')->getArgument(2);
}
} }

View File

@ -51,6 +51,7 @@
"symfony/messenger": "^5.2", "symfony/messenger": "^5.2",
"symfony/mime": "^4.4|^5.0", "symfony/mime": "^4.4|^5.0",
"symfony/process": "^4.4|^5.0", "symfony/process": "^4.4|^5.0",
"symfony/rate-limiter": "^5.2",
"symfony/security-bundle": "^5.3", "symfony/security-bundle": "^5.3",
"symfony/serializer": "^5.2", "symfony/serializer": "^5.2",
"symfony/stopwatch": "^4.4|^5.0", "symfony/stopwatch": "^4.4|^5.0",

View File

@ -4,6 +4,7 @@ CHANGELOG
5.3 5.3
--- ---
* [BC break] Add `login_throttling.lock_factory` setting defaulting to `null` (instead of `lock.factory`)
* Add the `debug:firewall` command. * Add the `debug:firewall` command.
* Deprecate `UserPasswordEncoderCommand` class and the corresponding `user:encode-password` command, * Deprecate `UserPasswordEncoderCommand` class and the corresponding `user:encode-password` command,
use `UserPasswordHashCommand` and `user:hash-password` instead use `UserPasswordHashCommand` and `user:hash-password` instead

View File

@ -54,6 +54,7 @@ class LoginThrottlingFactory implements AuthenticatorFactoryInterface, SecurityF
->children() ->children()
->scalarNode('limiter')->info(sprintf('A service id implementing "%s".', RequestRateLimiterInterface::class))->end() ->scalarNode('limiter')->info(sprintf('A service id implementing "%s".', RequestRateLimiterInterface::class))->end()
->integerNode('max_attempts')->defaultValue(5)->end() ->integerNode('max_attempts')->defaultValue(5)->end()
->scalarNode('lock_factory')->info('The service ID of the lock factory used by the login rate limiter (or null to disable locking)')->defaultNull()->end()
->end(); ->end();
} }
@ -76,6 +77,7 @@ class LoginThrottlingFactory implements AuthenticatorFactoryInterface, SecurityF
'policy' => 'fixed_window', 'policy' => 'fixed_window',
'limit' => $config['max_attempts'], 'limit' => $config['max_attempts'],
'interval' => '1 minute', 'interval' => '1 minute',
'lock_factory' => $config['lock_factory'],
]; ];
FrameworkExtension::registerRateLimiter($container, $localId = '_login_local_'.$firewallName, $limiterOptions); FrameworkExtension::registerRateLimiter($container, $localId = '_login_local_'.$firewallName, $limiterOptions);