Skip to content

Commit

Permalink
Add ThrottlingListener to allow MFA attempt throttling
Browse files Browse the repository at this point in the history
  • Loading branch information
tostiheld committed Dec 9, 2023
1 parent 7990826 commit 53ff772
Show file tree
Hide file tree
Showing 4 changed files with 132 additions and 1 deletion.
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@
"squizlabs/php_codesniffer": "^3.5",
"symfony/mailer": "^6.4 || ^7.0",
"symfony/yaml": "^6.4 || ^7.0",
"vimeo/psalm": "^5.0"
"vimeo/psalm": "^5.0",
"symfony/rate-limiter": "^6.4 || ^7.0"
},
"autoload": {
"psr-4": {
Expand Down
7 changes: 7 additions & 0 deletions src/bundle/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ public function getConfigTreeBuilder(): TreeBuilder
->scalarNode('ip_whitelist_provider')->defaultValue('scheb_two_factor.default_ip_whitelist_provider')->end()
->scalarNode('two_factor_token_factory')->defaultValue('scheb_two_factor.default_token_factory')->end()
->scalarNode('two_factor_condition')->defaultNull()->end()
->arrayNode('rate_limiter')
->canBeEnabled()
->children()
->integerNode('max_attempts')->defaultValue(5)->end()
->scalarNode('interval')->defaultValue('1 minute')->end()
->scalarNode('lock_factory')->info('The service ID of the lock factory used by the MFA rate limiter (or null to disable locking)')->defaultNull()->end()
->end()
->end();

/** @psalm-suppress ArgumentTypeCoercion */
Expand Down
80 changes: 80 additions & 0 deletions src/bundle/DependencyInjection/SchebTwoFactorExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,24 @@

namespace Scheb\TwoFactorBundle\DependencyInjection;

use LogicException;
use Scheb\TwoFactorBundle\Security\TwoFactor\Event\ThrottlingListener;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\Argument\IteratorArgument;
use Symfony\Component\DependencyInjection\ChildDefinition;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
use Symfony\Component\Lock\LockInterface;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\RateLimiter\Storage\CacheStorage;
use Symfony\Component\Security\Http\RateLimiter\DefaultLoginRateLimiter;
use function assert;
use function interface_exists;
use function is_bool;
use function is_string;
use function sprintf;
use function trim;

/**
Expand Down Expand Up @@ -67,6 +76,77 @@ public function load(array $configs, ContainerBuilder $container): void
}

$this->configureBackupCodeManager($container, $config);

$this->configureRateLimiting($container, $config);
}

/**
* @param array<string,mixed> $config
*/
private function configureRateLimiting(ContainerBuilder $container, array $config): void
{
if (!isset($config['rate_limiter']) || !$config['rate_limiter']['enabled']) {
return;
}

$limiterOptions = [
'policy' => 'fixed_window',
'limit' => $config['rate_limiter']['max_attempts'],
'interval' => $config['rate_limiter']['interval'],
'lock_factory' => $config['rate_limiter']['lock_factory'],
];

$requestLimiterId = 'scheb_two_factor.request_rate_limiter';
$localLimiterId = 'scheb_two_factor.local_rate_limiter';
$globalLimiterId = 'scheb_two_factor.global_rate_limiter';
$this->registerRateLimiter($container, $localLimiterId, $limiterOptions);
$limiterOptions['limit'] = 5 * $limiterOptions['limit'];
$this->registerRateLimiter($container, $globalLimiterId, $limiterOptions);

$container->register($requestLimiterId, DefaultLoginRateLimiter::class)
->addArgument(new Reference($globalLimiterId))
->addArgument(new Reference($localLimiterId))
->addArgument('%kernel.secret%');

$container->register('scheb_two_factor.rate_limiter_listener', ThrottlingListener::class)
->setArguments([
'$requestRateLimiter' => new Reference($requestLimiterId),
]);
}

/**
* @param array<string,mixed> $limiterConfig
*/
private function registerRateLimiter(ContainerBuilder $container, string $name, array $limiterConfig): void
{
// default configuration (when used by other DI extensions)
$limiterConfig += ['lock_factory' => 'lock.factory', 'cache_pool' => 'cache.rate_limiter'];

$limiter = $container->setDefinition($limiterId = 'limiter.'.$name, new ChildDefinition('limiter'));

if (null !== $limiterConfig['lock_factory']) {
/** @psalm-suppress UndefinedClass */
if (!interface_exists(LockInterface::class)) {
throw new LogicException(sprintf('Rate limiter "%s" requires the Lock component to be installed. Try running "composer require symfony/lock".', $name));
}

$limiter->replaceArgument(2, new Reference($limiterConfig['lock_factory']));
}

unset($limiterConfig['lock_factory']);

$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_service'], $limiterConfig['cache_pool']);

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

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

/**
Expand Down
43 changes: 43 additions & 0 deletions src/bundle/Security/TwoFactor/Event/ThrottlingListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

declare(strict_types=1);

namespace Scheb\TwoFactorBundle\Security\TwoFactor\Event;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RateLimiter\RequestRateLimiterInterface;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;

/**
* @final
*/
class ThrottlingListener implements EventSubscriberInterface
{
public function __construct(
private RequestRateLimiterInterface $requestRateLimiter,
) {
}

/**
* {@inheritDoc}
*/
public static function getSubscribedEvents(): array
{
return [
TwoFactorAuthenticationEvents::ATTEMPT => 'onTwoFactorAttempt',
TwoFactorAuthenticationEvents::SUCCESS => 'onTwoFactorSuccess',
];
}

public function onTwoFactorAttempt(TwoFactorAuthenticationEvent $event): void
{
if (!$this->requestRateLimiter->consume($event->getRequest())->isAccepted()) {
throw new TooManyRequestsHttpException();
}
}

public function onTwoFactorSuccess(TwoFactorAuthenticationEvent $event): void
{
$this->requestRateLimiter->reset($event->getRequest());
}
}

0 comments on commit 53ff772

Please sign in to comment.