diff --git a/CHANGELOG.md b/CHANGELOG.md index 624b243..d598cce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ Latest ------ +## Fixes +* [#229](https://github.com/cleverage/ui-process-bundle/issues/229) Login throttling of the `main` firewall (#207, #222): the requests of the HTTP API (`HttpProcessExecutionAuthenticator`) are no longer counted, on every firewall with a login throttling (`LoginThrottlingPass` decorates its limiter, the firewall settings still apply to the login form): failed logins blocked a valid API token, and invalid tokens blocked the login form of the IP address. `login_throttling: false` in the application `main` firewall disables it (it was enabled again by the bundle). With `framework.rate_limiter: false`, an explicit error tells how to fix the configuration instead of "Parent definition "limiter" does not exist". `HttpProcessExecutionAuthenticator::onAuthenticationFailure()` answers the message key of the exception, instead of an empty message when there were too many attempts. Update documentation, add tests. + v3.4 ------ diff --git a/docs/reference/03-users_and_security.md b/docs/reference/03-users_and_security.md index b243c95..b7c7022 100644 --- a/docs/reference/03-users_and_security.md +++ b/docs/reference/03-users_and_security.md @@ -30,13 +30,25 @@ security: This configuration is added **after** the application one, so it overrides it: the `provider`, `form_login` and `logout` of your `main` firewall are replaced by the bundle ones (e.g. the `provider: users_in_memory` of the default Symfony recipe), and the API token authenticator is added to it. So your application firewall must be named `main`, and it is -dedicated to the UI. The default settings of the -[login throttling](https://symfony.com/doc/current/security.html#limiting-login-attempts) (5 failed attempts per minute -for a username and an IP address, 25 for an IP address) can still be changed in the `main` firewall of the application -(e.g. `login_throttling: { max_attempts: 3 }`): the empty `login_throttling` of the bundle keeps them. To keep the -control of your security configuration, disable it, see +dedicated to the UI. To keep the control of your security configuration, disable it, see [configuring the security in your application](#configuring-the-security-in-your-application). +### Login throttling + +The [login throttling](https://symfony.com/doc/current/security.html#limiting-login-attempts) limits the failed +attempts of the login form: by default 5 per minute for a username and an IP address, 25 for an IP address. Its +settings can still be changed in the `main` firewall of the application (e.g. `login_throttling: { max_attempts: 3 }`, +or a custom `limiter`): the empty `login_throttling` of the bundle keeps them. `login_throttling: false` disables it +(`login_throttling: ~` enables it with the default settings). Only a literal `false` is recognized: a parameter (e.g. +`'%throttle%'`) or an environment variable is not resolved when the bundle reads it. + +It requires the Symfony rate limiter (`framework.rate_limiter`, enabled by default): when your application disables it, +the container cannot be compiled until the login throttling is disabled too. + +The requests of the [HTTP API](06-http_api.md) are not counted (they are authenticated by a token, not by a password), +on every firewall with a login throttling: a failed login does not block the API, and an invalid token does not block +the login form. + With this configuration, the only required application configuration is a password hasher for the `User` entity (the Symfony default configuration is enough): diff --git a/docs/reference/06-http_api.md b/docs/reference/06-http_api.md index c52185c..35331cd 100644 --- a/docs/reference/06-http_api.md +++ b/docs/reference/06-http_api.md @@ -22,6 +22,9 @@ The token is generated from the UI, on the user edit page ("Generate token" acti | `401` `{"message": "Missing auth token."}` | No `Authorization` header. | | `401` `{"message": "Invalid token."}` | The token does not match any user. | +The requests of the API are not limited by the +[login throttling](03-users_and_security.md#login-throttling) of the firewall. + Parameters ---------- diff --git a/src/CleverAgeUiProcessBundle.php b/src/CleverAgeUiProcessBundle.php index 4c28e2d..ae3680e 100644 --- a/src/CleverAgeUiProcessBundle.php +++ b/src/CleverAgeUiProcessBundle.php @@ -14,6 +14,7 @@ namespace CleverAge\UiProcessBundle; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; +use CleverAge\UiProcessBundle\DependencyInjection\Compiler\LoginThrottlingPass; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\UploadDirectoryParameterPass; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Bundle\Bundle; @@ -36,5 +37,6 @@ public function build(ContainerBuilder $container): void $container->addCompilerPass(new CheckDoctrineLazyGhostObjectsPass()); $container->addCompilerPass(new UploadDirectoryParameterPass()); + $container->addCompilerPass(new LoginThrottlingPass()); } } diff --git a/src/DependencyInjection/CleverAgeUiProcessExtension.php b/src/DependencyInjection/CleverAgeUiProcessExtension.php index f5d14f0..c8d5b59 100644 --- a/src/DependencyInjection/CleverAgeUiProcessExtension.php +++ b/src/DependencyInjection/CleverAgeUiProcessExtension.php @@ -226,6 +226,28 @@ private function configureMonolog(ContainerBuilder $container): void private function configureSecurity(ContainerBuilder $container): void { + $firewall = [ + 'provider' => 'process_user_provider', + 'custom_authenticator' => ['cleverage_ui_process.security.http_process_execution_authenticator'], + 'form_login' => [ + 'login_path' => 'cleverage_ui_process_login', + 'check_path' => 'cleverage_ui_process_login', + ], + // Not prepended: a prepended firewall would be the first definition of the "firewalls" node, the other + // firewalls of the application (e.g. "dev") could no longer be declared. Merged after the application + // configuration, it keeps its settings (max_attempts, interval...), but would enable it again when the + // application disables it + 'login_throttling' => [], + 'logout' => [ + 'path' => 'cleverage_ui_process_logout', + 'target' => 'cleverage_ui_process_login', + 'clear_site_data' => '*', + ], + ]; + if ($this->isLoginThrottlingDisabled($container)) { + unset($firewall['login_throttling']); + } + $container->loadFromExtension( 'security', [ @@ -238,28 +260,37 @@ private function configureSecurity(ContainerBuilder $container): void ], ], 'firewalls' => [ - 'main' => [ - 'provider' => 'process_user_provider', - 'custom_authenticator' => ['cleverage_ui_process.security.http_process_execution_authenticator'], - 'form_login' => [ - 'login_path' => 'cleverage_ui_process_login', - 'check_path' => 'cleverage_ui_process_login', - ], - // Not prepended: a prepended firewall would be the first definition of the "firewalls" node, - // the other firewalls of the application (e.g. "dev") could no longer be declared. Merged - // after the application configuration, it keeps its settings (max_attempts, interval...) - 'login_throttling' => [], - 'logout' => [ - 'path' => 'cleverage_ui_process_logout', - 'target' => 'cleverage_ui_process_login', - 'clear_site_data' => '*', - ], - ], + 'main' => $firewall, ], ] ); } + /** + * Whether the application disables the login throttling of its "main" firewall ("login_throttling: false"): the + * last value defined wins, as when the security configuration is processed. A null value ("login_throttling: ~") + * enables it with the default settings. Only a literal false is recognized: parameters are not resolved yet. + */ + private function isLoginThrottlingDisabled(ContainerBuilder $container): bool + { + $disabled = false; + foreach ($container->getExtensionConfig('security') as $config) { + $firewall = \is_array($config['firewalls'] ?? null) ? ($config['firewalls']['main'] ?? null) : null; + if (!\is_array($firewall)) { + continue; + } + // "login-throttling" is normalized to "login_throttling", unless both are defined + foreach (['login_throttling', 'login-throttling'] as $key) { + if (\array_key_exists($key, $firewall)) { + $disabled = false === $firewall[$key]; + break; + } + } + } + + return $disabled; + } + /** * Import the service definition files of a directory into the container. */ diff --git a/src/DependencyInjection/Compiler/LoginThrottlingPass.php b/src/DependencyInjection/Compiler/LoginThrottlingPass.php new file mode 100644 index 0000000..b1efda8 --- /dev/null +++ b/src/DependencyInjection/Compiler/LoginThrottlingPass.php @@ -0,0 +1,62 @@ +getDefinitions() as $id => $definition) { + if (!str_starts_with($id, self::LISTENER_PREFIX)) { + continue; + } + $firewall = substr($id, \strlen(self::LISTENER_PREFIX)); + + if ($container->hasDefinition('limiter._login_local_'.$firewall) && !$container->hasDefinition('limiter')) { + throw new LogicException(\sprintf('The login throttling of the "%s" firewall requires the rate limiter, disabled by the "framework.rate_limiter" option: enable it, or disable the login throttling of the firewall ("login_throttling: false").', $firewall)); + } + + if (!$container->hasDefinition(self::AUTHENTICATOR)) { + continue; + } + $limiter = $definition->getArgument(1); + $container->register($limiterId = 'cleverage_ui_process.security.login_throttling.'.$firewall.'.limiter', RequestRateLimiterInterface::class) + ->setFactory([HttpProcessExecutionRequestRateLimiter::class, 'create']) + ->setArguments([$limiter, new Reference(self::AUTHENTICATOR)]); + $definition->replaceArgument(1, new Reference($limiterId)); + } + } +} diff --git a/src/Security/HttpProcessExecutionAuthenticator.php b/src/Security/HttpProcessExecutionAuthenticator.php index b5a368a..c984bc9 100644 --- a/src/Security/HttpProcessExecutionAuthenticator.php +++ b/src/Security/HttpProcessExecutionAuthenticator.php @@ -21,6 +21,7 @@ use Symfony\Component\PasswordHasher\Hasher\Pbkdf2PasswordHasher; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Exception\AuthenticationException; +use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException; use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Passport; @@ -43,7 +44,7 @@ public function supports(Request $request): ?bool public function authenticate(Request $request): Passport { if (false === $request->headers->has('Authorization')) { - throw new AuthenticationException('Missing auth token.'); + throw new CustomUserMessageAuthenticationException('Missing auth token.'); } $token = $request->headers->get('Authorization'); $token = str_replace('Bearer ', '', $token ?? ''); @@ -51,7 +52,7 @@ public function authenticate(Request $request): Passport ['token' => (new Pbkdf2PasswordHasher())->hash($token)] ); if (null === $user) { - throw new AuthenticationException('Invalid token.'); + throw new CustomUserMessageAuthenticationException('Invalid token.'); } return new SelfValidatingPassport(new UserBadge($user->getEmail() ?? '')); @@ -64,8 +65,9 @@ public function onAuthenticationSuccess(Request $request, TokenInterface $token, public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response { + // The message key: some exceptions have no message (e.g. TooManyLoginAttemptsAuthenticationException) $data = [ - 'message' => $exception->getMessage(), + 'message' => strtr($exception->getMessageKey(), $exception->getMessageData()), ]; return new JsonResponse($data, Response::HTTP_UNAUTHORIZED); diff --git a/src/Security/HttpProcessExecutionRequestRateLimiter.php b/src/Security/HttpProcessExecutionRequestRateLimiter.php new file mode 100644 index 0000000..120d38e --- /dev/null +++ b/src/Security/HttpProcessExecutionRequestRateLimiter.php @@ -0,0 +1,80 @@ +isHttpProcessExecution($request)) { + return $this->createAcceptedRateLimit(); + } + + return $this->limiter->consume($request); + } + + public function reset(Request $request): void + { + if ($this->isHttpProcessExecution($request)) { + return; + } + + $this->limiter->reset($request); + } + + protected function isHttpProcessExecution(Request $request): bool + { + return false !== $this->authenticator->supports($request); + } + + /** + * An untouched limit: the listener neither rejects the request nor resets the limiter after a success. + */ + protected function createAcceptedRateLimit(): RateLimit + { + return new RateLimit(1, new \DateTimeImmutable(), true, 1); + } +} diff --git a/src/Security/PeekableHttpProcessExecutionRequestRateLimiter.php b/src/Security/PeekableHttpProcessExecutionRequestRateLimiter.php new file mode 100644 index 0000000..c891b25 --- /dev/null +++ b/src/Security/PeekableHttpProcessExecutionRequestRateLimiter.php @@ -0,0 +1,37 @@ +isHttpProcessExecution($request)) { + return $this->createAcceptedRateLimit(); + } + + \assert($this->limiter instanceof PeekableRequestRateLimiterInterface); + + return $this->limiter->peek($request); + } +} diff --git a/tests/App/TestKernel.php b/tests/App/TestKernel.php index 68a7c74..55c176d 100644 --- a/tests/App/TestKernel.php +++ b/tests/App/TestKernel.php @@ -50,6 +50,14 @@ class TestKernel extends Kernel private static ?string $runDir = null; + /** + * @param array> $config Additional configuration, by extension alias + */ + public function __construct(string $environment, bool $debug, private readonly array $config = []) + { + parent::__construct($environment, $debug); + } + public function registerBundles(): iterable { yield new FrameworkBundle(); @@ -72,7 +80,10 @@ public function getProjectDir(): string public function getCacheDir(): string { - return self::getRunDir().'/cache/'.$this->environment; + $cacheDir = self::getRunDir().'/cache/'.$this->environment; + + // A container of its own for an additional configuration + return [] === $this->config ? $cacheDir : $cacheDir.'_'.md5(serialize($this->config)); } public function getLogDir(): string @@ -91,6 +102,9 @@ protected function configureContainer(\Symfony\Component\DependencyInjection\Loa // Symfony 6.4 calls User::eraseCredentials(), marked #[\Deprecated] for Symfony 7.3 (deprecation since PHP 8.4) $container->extension('security', ['erase_credentials' => false]); } + foreach ($this->config as $alias => $config) { + $container->extension($alias, $config); + } // Doctrine ORM 2 (lowest dependencies): lazy ghost proxies, as configured by the Symfony recipe, the legacy // proxies cannot unset the readonly properties of the entities (ProcessExecution::$code...), see // CheckDoctrineLazyGhostObjectsPass diff --git a/tests/CleverAgeUiProcessBundleTest.php b/tests/CleverAgeUiProcessBundleTest.php index 6408097..efa9ba4 100644 --- a/tests/CleverAgeUiProcessBundleTest.php +++ b/tests/CleverAgeUiProcessBundleTest.php @@ -16,6 +16,7 @@ use CleverAge\UiProcessBundle\CleverAgeUiProcessBundle; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; +use CleverAge\UiProcessBundle\DependencyInjection\Compiler\LoginThrottlingPass; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\UploadDirectoryParameterPass; use CleverAge\UiProcessBundle\Routing\DeprecatedEasyAdminRouteAliasLoader; use PHPUnit\Framework\Attributes\CoversClass; @@ -28,6 +29,7 @@ #[UsesClass(DeprecatedEasyAdminRouteAliasLoader::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] #[UsesClass(UploadDirectoryParameterPass::class)] +#[UsesClass(LoginThrottlingPass::class)] class CleverAgeUiProcessBundleTest extends TestCase { public function testPathIsTheBundleRootDirectory(): void @@ -70,4 +72,16 @@ public function testUploadDirectoryParameterPassIsRegistered(): void ); self::assertCount(1, $passes); } + + public function testLoginThrottlingPassIsRegistered(): void + { + $container = new ContainerBuilder(); + (new CleverAgeUiProcessBundle())->build($container); + + $passes = array_filter( + $container->getCompilerPassConfig()->getPasses(), + static fn (object $pass): bool => $pass instanceof LoginThrottlingPass, + ); + self::assertCount(1, $passes); + } } diff --git a/tests/DependencyInjection/CleverAgeUiProcessExtensionTest.php b/tests/DependencyInjection/CleverAgeUiProcessExtensionTest.php index 65acb58..ec9ff03 100644 --- a/tests/DependencyInjection/CleverAgeUiProcessExtensionTest.php +++ b/tests/DependencyInjection/CleverAgeUiProcessExtensionTest.php @@ -445,6 +445,55 @@ public function testPrependWithoutFirewallConfiguration(): void self::assertCount(1, $container->getExtensionConfig('doctrine_migrations')); } + /** + * @return iterable, bool}> + */ + public static function provideApplicationLoginThrottling(): iterable + { + yield 'not configured' => [[], true]; + yield 'disabled' => [[false], false]; + // "~" enables it with the default settings + yield 'null' => [[null], true]; + yield 'settings' => [[['max_attempts' => 3]], true]; + yield 'disabled, then enabled' => [[false, ['max_attempts' => 3]], true]; + yield 'enabled, then disabled' => [[['max_attempts' => 3], false], false]; + // Normalized to "login_throttling" by the Config component, e.g. in XML + yield 'disabled with a dash' => [[['login-throttling' => false]], false]; + yield 'enabled with a dash' => [[['login-throttling' => null]], true]; + yield 'both keys: the underscore one wins' => [[['login_throttling' => null, 'login-throttling' => false]], true]; + // Not resolved before the security configuration is processed + yield 'parameter' => [['%throttle%'], true]; + } + + /** + * @param list $applicationValues "login_throttling" values of the application "main" firewall + */ + #[DataProvider('provideApplicationLoginThrottling')] + public function testPrependLoginThrottling(array $applicationValues, bool $enabled): void + { + $container = $this->createContainer('prod'); + $container->registerExtension($this->createExtension('security')); + $container->loadFromExtension('security', ['firewalls' => ['dev' => ['security' => false]]]); + foreach ($applicationValues as $value) { + // A whole firewall configuration (to test the keys) or the "login_throttling" value + $firewall = \is_array($value) && [] !== array_intersect(['login_throttling', 'login-throttling'], array_keys($value)) ? $value : ['login_throttling' => $value]; + $container->loadFromExtension('security', ['firewalls' => ['main' => $firewall]]); + } + + (new CleverAgeUiProcessExtension())->prepend($container); + + $security = $container->getExtensionConfig('security'); + self::assertCount(\count($applicationValues) + 2, $security); + $bundleFirewalls = end($security)['firewalls'] ?? null; + self::assertIsArray($bundleFirewalls); + $bundleFirewall = $bundleFirewalls['main'] ?? null; + self::assertIsArray($bundleFirewall); + self::assertSame($enabled, \array_key_exists('login_throttling', $bundleFirewall)); + if ($enabled) { + self::assertSame([], $bundleFirewall['login_throttling']); + } + } + public function testPrependWithAParameterAsFirewallConfiguration(): void { $container = $this->createContainer('prod'); diff --git a/tests/DependencyInjection/Compiler/LoginThrottlingPassTest.php b/tests/DependencyInjection/Compiler/LoginThrottlingPassTest.php new file mode 100644 index 0000000..62f388b --- /dev/null +++ b/tests/DependencyInjection/Compiler/LoginThrottlingPassTest.php @@ -0,0 +1,122 @@ +createContainer(); + $this->registerFirewall($container, 'main', 'security.login_throttling.main.limiter'); + // A firewall of the application with a custom limiter (login_throttling.limiter) + $this->registerFirewall($container, 'api', 'app.limiter', defaultLimiter: false); + + (new LoginThrottlingPass())->process($container); + + foreach (['main' => 'security.login_throttling.main.limiter', 'api' => 'app.limiter'] as $firewall => $limiter) { + $listener = $container->getDefinition('security.listener.login_throttling.'.$firewall); + $decoratorId = 'cleverage_ui_process.security.login_throttling.'.$firewall.'.limiter'; + self::assertEquals(new Reference($decoratorId), $listener->getArgument(1)); + + $decorator = $container->getDefinition($decoratorId); + self::assertSame(RequestRateLimiterInterface::class, $decorator->getClass()); + self::assertSame([HttpProcessExecutionRequestRateLimiter::class, 'create'], $decorator->getFactory()); + self::assertEquals([new Reference($limiter), new Reference(self::AUTHENTICATOR)], $decorator->getArguments()); + } + // The parent definition of the listeners is left alone + self::assertSame([], $container->getDefinition('security.listener.login_throttling')->getArguments()); + } + + public function testWithoutTheAuthenticator(): void + { + $container = $this->createContainer(); + $container->removeDefinition(self::AUTHENTICATOR); + $this->registerFirewall($container, 'main', 'security.login_throttling.main.limiter'); + + (new LoginThrottlingPass())->process($container); + + self::assertEquals( + new Reference('security.login_throttling.main.limiter'), + $container->getDefinition('security.listener.login_throttling.main')->getArgument(1) + ); + } + + public function testWithoutTheRateLimiter(): void + { + $container = $this->createContainer(); + $container->removeDefinition('limiter'); + $this->registerFirewall($container, 'main', 'security.login_throttling.main.limiter'); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('The login throttling of the "main" firewall requires the rate limiter, disabled by the "framework.rate_limiter" option: enable it, or disable the login throttling of the firewall ("login_throttling: false").'); + + (new LoginThrottlingPass())->process($container); + } + + /** + * A custom limiter (login_throttling.limiter) does not require the rate limiter of the framework. + */ + public function testCustomLimiterWithoutTheRateLimiter(): void + { + $container = $this->createContainer(); + $container->removeDefinition('limiter'); + $this->registerFirewall($container, 'main', 'app.limiter', defaultLimiter: false); + + (new LoginThrottlingPass())->process($container); + + self::assertEquals( + new Reference('cleverage_ui_process.security.login_throttling.main.limiter'), + $container->getDefinition('security.listener.login_throttling.main')->getArgument(1) + ); + } + + private function createContainer(): ContainerBuilder + { + $container = new ContainerBuilder(); + $container->register(self::AUTHENTICATOR, HttpProcessExecutionAuthenticator::class); + $container->register('limiter'); + $container->register('security.listener.login_throttling')->setAbstract(true); + + return $container; + } + + /** + * Services registered by the LoginThrottlingFactory of Symfony. + */ + private function registerFirewall(ContainerBuilder $container, string $firewall, string $limiter, bool $defaultLimiter = true): void + { + if ($defaultLimiter) { + $container->setDefinition('limiter._login_local_'.$firewall, new ChildDefinition('limiter')); + $container->setDefinition('limiter._login_global_'.$firewall, new ChildDefinition('limiter')); + $container->setDefinition($limiter, new Definition()); + } + $container->setDefinition('security.listener.login_throttling.'.$firewall, new ChildDefinition('security.listener.login_throttling')) + ->replaceArgument(1, new Reference($limiter)); + } +} diff --git a/tests/Functional/FunctionalTestCase.php b/tests/Functional/FunctionalTestCase.php index 210fc59..8897c4b 100644 --- a/tests/Functional/FunctionalTestCase.php +++ b/tests/Functional/FunctionalTestCase.php @@ -20,6 +20,7 @@ use Doctrine\ORM\Tools\SchemaTool; use Symfony\Bundle\FrameworkBundle\KernelBrowser; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; +use Symfony\Component\HttpKernel\KernelInterface; use Symfony\Component\Messenger\Envelope; use Symfony\Component\Messenger\Transport\InMemory\InMemoryTransport; @@ -32,8 +33,17 @@ abstract class FunctionalTestCase extends WebTestCase protected function setUp(): void { + $this->createTestClient(); + } + + /** + * @param array> $config Additional configuration of the test application, by extension alias + */ + protected function createTestClient(array $config = []): void + { + self::ensureKernelShutdown(); // Without debug: less memory (the whole test suite runs with the default 128M memory_limit) - $this->client = static::createClient(['debug' => false]); + $this->client = static::createClient(['debug' => false, 'config' => $config]); $this->client->disableReboot(); $entityManager = $this->getEntityManager(); @@ -48,6 +58,14 @@ protected static function getKernelClass(): string return TestKernel::class; } + /** + * @param array{environment?: string, debug?: bool, config?: array>} $options + */ + protected static function createKernel(array $options = []): KernelInterface + { + return new TestKernel($options['environment'] ?? 'test', $options['debug'] ?? true, $options['config'] ?? []); + } + protected function getEntityManager(): EntityManagerInterface { /** @var EntityManagerInterface $entityManager */ diff --git a/tests/Functional/HttpProcessExecuteTest.php b/tests/Functional/HttpProcessExecuteTest.php index ac96f9b..71ff1cd 100644 --- a/tests/Functional/HttpProcessExecuteTest.php +++ b/tests/Functional/HttpProcessExecuteTest.php @@ -22,6 +22,7 @@ use CleverAge\UiProcessBundle\Controller\ProcessExecuteController; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; +use CleverAge\UiProcessBundle\DependencyInjection\Compiler\LoginThrottlingPass; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\UploadDirectoryParameterPass; use CleverAge\UiProcessBundle\DependencyInjection\Configuration; use CleverAge\UiProcessBundle\Entity\ProcessExecution; @@ -41,6 +42,8 @@ use CleverAge\UiProcessBundle\Repository\ProcessExecutionRepository; use CleverAge\UiProcessBundle\Routing\DeprecatedEasyAdminRouteAliasLoader; use CleverAge\UiProcessBundle\Security\HttpProcessExecutionAuthenticator; +use CleverAge\UiProcessBundle\Security\HttpProcessExecutionRequestRateLimiter; +use CleverAge\UiProcessBundle\Security\PeekableHttpProcessExecutionRequestRateLimiter; use CleverAge\UiProcessBundle\StopChecker\ProcessExecutionStopChecker; use CleverAge\UiProcessBundle\Twig\Extension\LogLevelExtension; use CleverAge\UiProcessBundle\Twig\Extension\MD5Extension; @@ -61,6 +64,9 @@ #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] #[UsesClass(UploadDirectoryParameterPass::class)] +#[UsesClass(LoginThrottlingPass::class)] +#[UsesClass(HttpProcessExecutionRequestRateLimiter::class)] +#[UsesClass(PeekableHttpProcessExecutionRequestRateLimiter::class)] #[UsesClass(LogRecordCrudController::class)] #[UsesClass(ProcessDashboardController::class)] #[UsesClass(ProcessExecutionCrudController::class)] @@ -207,6 +213,7 @@ public function testInvalidToken(): void $this->client->request('POST', '/http/process/execute', ['code' => 'test.process'], server: ['HTTP_AUTHORIZATION' => 'Bearer invalid']); self::assertResponseStatusCodeSame(401); + self::assertSame(['message' => 'Invalid token.'], json_decode((string) $this->client->getResponse()->getContent(), true)); self::assertSame([], $this->getDispatchedMessages()); } @@ -215,6 +222,7 @@ public function testMissingToken(): void $this->client->request('POST', '/http/process/execute', ['code' => 'test.process']); self::assertResponseStatusCodeSame(401); + self::assertSame(['message' => 'Missing auth token.'], json_decode((string) $this->client->getResponse()->getContent(), true)); self::assertSame([], $this->getDispatchedMessages()); } diff --git a/tests/Functional/LaunchActionTest.php b/tests/Functional/LaunchActionTest.php index db8fb28..6926a29 100644 --- a/tests/Functional/LaunchActionTest.php +++ b/tests/Functional/LaunchActionTest.php @@ -23,6 +23,7 @@ use CleverAge\UiProcessBundle\Controller\Admin\UserCrudController; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; +use CleverAge\UiProcessBundle\DependencyInjection\Compiler\LoginThrottlingPass; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\UploadDirectoryParameterPass; use CleverAge\UiProcessBundle\DependencyInjection\Configuration; use CleverAge\UiProcessBundle\Entity\User; @@ -65,6 +66,7 @@ #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] #[UsesClass(UploadDirectoryParameterPass::class)] +#[UsesClass(LoginThrottlingPass::class)] #[UsesClass(LogRecordCrudController::class)] #[UsesClass(ProcessExecutionCrudController::class)] #[UsesClass(ProcessScheduleCrudController::class)] diff --git a/tests/Functional/LogRecordCrudTest.php b/tests/Functional/LogRecordCrudTest.php index 6c8ec3b..ff87287 100644 --- a/tests/Functional/LogRecordCrudTest.php +++ b/tests/Functional/LogRecordCrudTest.php @@ -24,6 +24,7 @@ use CleverAge\UiProcessBundle\Controller\Admin\UserCrudController; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; +use CleverAge\UiProcessBundle\DependencyInjection\Compiler\LoginThrottlingPass; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\UploadDirectoryParameterPass; use CleverAge\UiProcessBundle\DependencyInjection\Configuration; use CleverAge\UiProcessBundle\Entity\LogRecord; @@ -65,6 +66,7 @@ #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] #[UsesClass(UploadDirectoryParameterPass::class)] +#[UsesClass(LoginThrottlingPass::class)] #[UsesClass(ProcessExecutionCrudController::class)] #[UsesClass(ProcessScheduleCrudController::class)] #[UsesClass(UserCrudController::class)] diff --git a/tests/Functional/LoginThrottlingTest.php b/tests/Functional/LoginThrottlingTest.php new file mode 100644 index 0000000..3d36ad8 --- /dev/null +++ b/tests/Functional/LoginThrottlingTest.php @@ -0,0 +1,236 @@ +client->setServerParameter('REMOTE_ADDR', '10.229.0.1'); + $this->createApiUser(); + + for ($i = 0; $i < 5; ++$i) { + $this->submitLogin('api@example.com', 'invalid'); + self::assertSelectorTextContains('.alert-danger', 'Invalid credentials.'); + } + // The login form is still throttled + $this->submitLogin('api@example.com', 'password'); + self::assertSelectorTextContains('.alert-danger', self::TOO_MANY_ATTEMPTS); + + $this->executeProcess(self::TOKEN); + self::assertResponseIsSuccessful(); + self::assertCount(1, $this->getDispatchedMessages()); + } + + /** + * Requests with an invalid token (e.g. a cron job whose token has been regenerated) do not block the login form. + */ + public function testInvalidTokensDoNotBlockTheLogin(): void + { + $this->client->setServerParameter('REMOTE_ADDR', '10.229.0.2'); + $this->createApiUser(); + $this->createUser('admin@example.com', ['ROLE_ADMIN'], 'secret'); + + // More than the limit of an IP address (5 x 5 attempts) + for ($i = 0; $i < 30; ++$i) { + $this->executeProcess('invalid'); + self::assertResponseStatusCodeSame(401); + self::assertSame(['message' => 'Invalid token.'], json_decode((string) $this->client->getResponse()->getContent(), true)); + } + $this->executeProcess(self::TOKEN); + self::assertResponseIsSuccessful(); + + $this->submitLogin('admin@example.com', 'secret', followRedirect: false); + self::assertResponseRedirects('/process'); + } + + /** + * The settings of the application firewall still apply. + */ + public function testApplicationSettings(): void + { + $this->createTestClient(['security' => ['firewalls' => ['main' => ['login_throttling' => ['max_attempts' => 2]]]]]); + $this->client->setServerParameter('REMOTE_ADDR', '10.229.0.3'); + $this->createUser('admin@example.com', ['ROLE_ADMIN'], 'secret'); + + for ($i = 0; $i < 2; ++$i) { + $this->submitLogin('admin@example.com', 'invalid'); + self::assertSelectorTextContains('.alert-danger', 'Invalid credentials.'); + } + $this->submitLogin('admin@example.com', 'secret'); + self::assertSelectorTextContains('.alert-danger', self::TOO_MANY_ATTEMPTS); + } + + /** + * "login_throttling: false" in the application firewall disables it. + */ + public function testDisabledByTheApplication(): void + { + $this->createTestClient(['security' => ['firewalls' => ['main' => ['login_throttling' => false]]]]); + $this->client->setServerParameter('REMOTE_ADDR', '10.229.0.4'); + $this->createUser('admin@example.com', ['ROLE_ADMIN'], 'secret'); + + self::assertFalse(static::getContainer()->has('security.listener.login_throttling.main')); + for ($i = 0; $i < 6; ++$i) { + $this->submitLogin('admin@example.com', 'invalid'); + self::assertSelectorTextContains('.alert-danger', 'Invalid credentials.'); + } + $this->submitLogin('admin@example.com', 'secret', followRedirect: false); + self::assertResponseRedirects('/process'); + } + + public function testRateLimiterDisabled(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('The login throttling of the "main" firewall requires the rate limiter, disabled by the "framework.rate_limiter" option: enable it, or disable the login throttling of the firewall ("login_throttling: false").'); + + $this->createTestClient(['framework' => ['rate_limiter' => false]]); + } + + public function testRateLimiterAndLoginThrottlingDisabled(): void + { + $this->createTestClient([ + 'framework' => ['rate_limiter' => false], + 'security' => ['firewalls' => ['main' => ['login_throttling' => false]]], + ]); + $this->createUser('admin@example.com', ['ROLE_ADMIN'], 'secret'); + + $this->submitLogin('admin@example.com', 'secret', followRedirect: false); + self::assertResponseRedirects('/process'); + } + + private function createApiUser(): void + { + $user = $this->createUser('api@example.com'); + $user->setToken((new Pbkdf2PasswordHasher())->hash(self::TOKEN)); + $this->getEntityManager()->flush(); + } + + private function submitLogin(string $username, string $password, bool $followRedirect = true): void + { + $crawler = $this->client->request('GET', '/process/login'); + $this->client->submit($crawler->filter('form')->form(['_username' => $username, '_password' => $password])); + if ($followRedirect) { + self::assertResponseRedirects('http://localhost/process/login'); + $this->client->followRedirect(); + } + } + + private function executeProcess(string $token): void + { + $this->client->request('POST', '/http/process/execute', ['code' => 'test.process'], server: ['HTTP_AUTHORIZATION' => 'Bearer '.$token]); + } +} diff --git a/tests/Functional/ProcessExecutionCrudTest.php b/tests/Functional/ProcessExecutionCrudTest.php index 39b78e8..d407fef 100644 --- a/tests/Functional/ProcessExecutionCrudTest.php +++ b/tests/Functional/ProcessExecutionCrudTest.php @@ -24,6 +24,7 @@ use CleverAge\UiProcessBundle\Controller\Admin\UserCrudController; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; +use CleverAge\UiProcessBundle\DependencyInjection\Compiler\LoginThrottlingPass; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\UploadDirectoryParameterPass; use CleverAge\UiProcessBundle\DependencyInjection\Configuration; use CleverAge\UiProcessBundle\Entity\Enum\ProcessExecutionStatus; @@ -69,6 +70,7 @@ #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] #[UsesClass(UploadDirectoryParameterPass::class)] +#[UsesClass(LoginThrottlingPass::class)] #[UsesClass(LogRecordCrudController::class)] #[UsesClass(ProcessScheduleCrudController::class)] #[UsesClass(UserCrudController::class)] diff --git a/tests/Functional/ProcessExecutionNotificationTest.php b/tests/Functional/ProcessExecutionNotificationTest.php index 2e08d49..7ba14f2 100644 --- a/tests/Functional/ProcessExecutionNotificationTest.php +++ b/tests/Functional/ProcessExecutionNotificationTest.php @@ -17,6 +17,7 @@ use CleverAge\UiProcessBundle\CleverAgeUiProcessBundle; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; +use CleverAge\UiProcessBundle\DependencyInjection\Compiler\LoginThrottlingPass; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\UploadDirectoryParameterPass; use CleverAge\UiProcessBundle\DependencyInjection\Configuration; use CleverAge\UiProcessBundle\Entity\ProcessExecution; @@ -43,6 +44,7 @@ #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] #[UsesClass(UploadDirectoryParameterPass::class)] +#[UsesClass(LoginThrottlingPass::class)] #[UsesClass(CleverAgeUiProcessExtension::class)] #[UsesClass(DeprecatedEasyAdminRouteAliasLoader::class)] #[UsesClass(Configuration::class)] diff --git a/tests/Functional/ProcessListTest.php b/tests/Functional/ProcessListTest.php index 37a7046..bd67e06 100644 --- a/tests/Functional/ProcessListTest.php +++ b/tests/Functional/ProcessListTest.php @@ -22,6 +22,7 @@ use CleverAge\UiProcessBundle\Controller\Admin\UserCrudController; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; +use CleverAge\UiProcessBundle\DependencyInjection\Compiler\LoginThrottlingPass; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\UploadDirectoryParameterPass; use CleverAge\UiProcessBundle\DependencyInjection\Configuration; use CleverAge\UiProcessBundle\Entity\User; @@ -58,6 +59,7 @@ #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] #[UsesClass(UploadDirectoryParameterPass::class)] +#[UsesClass(LoginThrottlingPass::class)] #[UsesClass(LogRecordCrudController::class)] #[UsesClass(ProcessExecutionCrudController::class)] #[UsesClass(ProcessScheduleCrudController::class)] diff --git a/tests/Functional/ProcessScheduleCrudTest.php b/tests/Functional/ProcessScheduleCrudTest.php index 2a2b110..0571397 100644 --- a/tests/Functional/ProcessScheduleCrudTest.php +++ b/tests/Functional/ProcessScheduleCrudTest.php @@ -22,6 +22,7 @@ use CleverAge\UiProcessBundle\Controller\Admin\UserCrudController; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; +use CleverAge\UiProcessBundle\DependencyInjection\Compiler\LoginThrottlingPass; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\UploadDirectoryParameterPass; use CleverAge\UiProcessBundle\DependencyInjection\Configuration; use CleverAge\UiProcessBundle\Entity\Enum\ProcessScheduleType; @@ -66,6 +67,7 @@ #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] #[UsesClass(UploadDirectoryParameterPass::class)] +#[UsesClass(LoginThrottlingPass::class)] #[UsesClass(LogRecordCrudController::class)] #[UsesClass(ProcessExecutionCrudController::class)] #[UsesClass(UserCrudController::class)] diff --git a/tests/Functional/PurgeLogsCommandTest.php b/tests/Functional/PurgeLogsCommandTest.php index 4d0a50c..312c117 100644 --- a/tests/Functional/PurgeLogsCommandTest.php +++ b/tests/Functional/PurgeLogsCommandTest.php @@ -18,6 +18,7 @@ use CleverAge\UiProcessBundle\Command\PurgeLogsCommand; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; +use CleverAge\UiProcessBundle\DependencyInjection\Compiler\LoginThrottlingPass; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\UploadDirectoryParameterPass; use CleverAge\UiProcessBundle\DependencyInjection\Configuration; use CleverAge\UiProcessBundle\Entity\LogRecord; @@ -37,6 +38,7 @@ #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] #[UsesClass(UploadDirectoryParameterPass::class)] +#[UsesClass(LoginThrottlingPass::class)] #[UsesClass(CleverAgeUiProcessExtension::class)] #[UsesClass(DeprecatedEasyAdminRouteAliasLoader::class)] #[UsesClass(Configuration::class)] diff --git a/tests/Functional/SecurityTest.php b/tests/Functional/SecurityTest.php index d9f796b..ba8bc2b 100644 --- a/tests/Functional/SecurityTest.php +++ b/tests/Functional/SecurityTest.php @@ -29,6 +29,7 @@ use CleverAge\UiProcessBundle\Controller\Admin\UserCrudController; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; +use CleverAge\UiProcessBundle\DependencyInjection\Compiler\LoginThrottlingPass; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\UploadDirectoryParameterPass; use CleverAge\UiProcessBundle\DependencyInjection\Configuration; use CleverAge\UiProcessBundle\Entity\User; @@ -39,6 +40,8 @@ use CleverAge\UiProcessBundle\Repository\ProcessExecutionRepository; use CleverAge\UiProcessBundle\Routing\DeprecatedEasyAdminRouteAliasLoader; use CleverAge\UiProcessBundle\Security\HttpProcessExecutionAuthenticator; +use CleverAge\UiProcessBundle\Security\HttpProcessExecutionRequestRateLimiter; +use CleverAge\UiProcessBundle\Security\PeekableHttpProcessExecutionRequestRateLimiter; use CleverAge\UiProcessBundle\Twig\Extension\LogLevelExtension; use CleverAge\UiProcessBundle\Twig\Extension\MD5Extension; use CleverAge\UiProcessBundle\Twig\Extension\ProcessExecutionExtension; @@ -74,6 +77,9 @@ #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] #[UsesClass(UploadDirectoryParameterPass::class)] +#[UsesClass(LoginThrottlingPass::class)] +#[UsesClass(HttpProcessExecutionRequestRateLimiter::class)] +#[UsesClass(PeekableHttpProcessExecutionRequestRateLimiter::class)] #[UsesClass(CleverAgeUiProcessExtension::class)] #[UsesClass(DeprecatedEasyAdminRouteAliasLoader::class)] #[UsesClass(Configuration::class)] diff --git a/tests/Functional/UserCrudTest.php b/tests/Functional/UserCrudTest.php index 2f0996b..2b653a0 100644 --- a/tests/Functional/UserCrudTest.php +++ b/tests/Functional/UserCrudTest.php @@ -21,6 +21,7 @@ use CleverAge\UiProcessBundle\Controller\Admin\UserCrudController; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; +use CleverAge\UiProcessBundle\DependencyInjection\Compiler\LoginThrottlingPass; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\UploadDirectoryParameterPass; use CleverAge\UiProcessBundle\DependencyInjection\Configuration; use CleverAge\UiProcessBundle\Entity\User; @@ -50,6 +51,7 @@ #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] #[UsesClass(UploadDirectoryParameterPass::class)] +#[UsesClass(LoginThrottlingPass::class)] #[UsesClass(LogRecordCrudController::class)] #[UsesClass(ProcessExecutionCrudController::class)] #[UsesClass(ProcessScheduleCrudController::class)] diff --git a/tests/Manager/ProcessExecutionManagerEntityManagerTest.php b/tests/Manager/ProcessExecutionManagerEntityManagerTest.php index 8e966ca..9dce700 100644 --- a/tests/Manager/ProcessExecutionManagerEntityManagerTest.php +++ b/tests/Manager/ProcessExecutionManagerEntityManagerTest.php @@ -16,6 +16,7 @@ use CleverAge\UiProcessBundle\CleverAgeUiProcessBundle; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; +use CleverAge\UiProcessBundle\DependencyInjection\Compiler\LoginThrottlingPass; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\UploadDirectoryParameterPass; use CleverAge\UiProcessBundle\DependencyInjection\Configuration; use CleverAge\UiProcessBundle\Entity\Enum\ProcessExecutionStatus; @@ -46,6 +47,7 @@ #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] #[UsesClass(UploadDirectoryParameterPass::class)] +#[UsesClass(LoginThrottlingPass::class)] #[UsesClass(CleverAgeUiProcessExtension::class)] #[UsesClass(DeprecatedEasyAdminRouteAliasLoader::class)] #[UsesClass(Configuration::class)] diff --git a/tests/Monolog/Handler/DoctrineProcessHandlerEntityManagerTest.php b/tests/Monolog/Handler/DoctrineProcessHandlerEntityManagerTest.php index de2af18..167d5a8 100644 --- a/tests/Monolog/Handler/DoctrineProcessHandlerEntityManagerTest.php +++ b/tests/Monolog/Handler/DoctrineProcessHandlerEntityManagerTest.php @@ -16,6 +16,7 @@ use CleverAge\UiProcessBundle\CleverAgeUiProcessBundle; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; +use CleverAge\UiProcessBundle\DependencyInjection\Compiler\LoginThrottlingPass; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\UploadDirectoryParameterPass; use CleverAge\UiProcessBundle\DependencyInjection\Configuration; use CleverAge\UiProcessBundle\Entity\Enum\ProcessExecutionStatus; @@ -41,6 +42,7 @@ #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] #[UsesClass(UploadDirectoryParameterPass::class)] +#[UsesClass(LoginThrottlingPass::class)] #[UsesClass(CleverAgeUiProcessExtension::class)] #[UsesClass(DeprecatedEasyAdminRouteAliasLoader::class)] #[UsesClass(Configuration::class)] diff --git a/tests/Repository/ProcessExecutionRepositoryTest.php b/tests/Repository/ProcessExecutionRepositoryTest.php index 0e0187f..17bcd24 100644 --- a/tests/Repository/ProcessExecutionRepositoryTest.php +++ b/tests/Repository/ProcessExecutionRepositoryTest.php @@ -16,6 +16,7 @@ use CleverAge\UiProcessBundle\CleverAgeUiProcessBundle; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; +use CleverAge\UiProcessBundle\DependencyInjection\Compiler\LoginThrottlingPass; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\UploadDirectoryParameterPass; use CleverAge\UiProcessBundle\DependencyInjection\Configuration; use CleverAge\UiProcessBundle\Entity\LogRecord; @@ -36,6 +37,7 @@ #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] #[UsesClass(UploadDirectoryParameterPass::class)] +#[UsesClass(LoginThrottlingPass::class)] #[UsesClass(CleverAgeUiProcessExtension::class)] #[UsesClass(DeprecatedEasyAdminRouteAliasLoader::class)] #[UsesClass(Configuration::class)] diff --git a/tests/Repository/ProcessScheduleRepositoryTest.php b/tests/Repository/ProcessScheduleRepositoryTest.php index d393c77..673d63d 100644 --- a/tests/Repository/ProcessScheduleRepositoryTest.php +++ b/tests/Repository/ProcessScheduleRepositoryTest.php @@ -16,6 +16,7 @@ use CleverAge\UiProcessBundle\CleverAgeUiProcessBundle; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; +use CleverAge\UiProcessBundle\DependencyInjection\Compiler\LoginThrottlingPass; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\UploadDirectoryParameterPass; use CleverAge\UiProcessBundle\DependencyInjection\Configuration; use CleverAge\UiProcessBundle\Entity\Enum\ProcessScheduleType; @@ -34,6 +35,7 @@ #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] #[UsesClass(UploadDirectoryParameterPass::class)] +#[UsesClass(LoginThrottlingPass::class)] #[UsesClass(CleverAgeUiProcessExtension::class)] #[UsesClass(DeprecatedEasyAdminRouteAliasLoader::class)] #[UsesClass(Configuration::class)] diff --git a/tests/Security/HttpProcessExecutionAuthenticatorTest.php b/tests/Security/HttpProcessExecutionAuthenticatorTest.php index c27ee6b..fd58c53 100644 --- a/tests/Security/HttpProcessExecutionAuthenticatorTest.php +++ b/tests/Security/HttpProcessExecutionAuthenticatorTest.php @@ -16,8 +16,13 @@ use CleverAge\UiProcessBundle\Security\HttpProcessExecutionAuthenticator; use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Security\Core\Exception\AuthenticationException; +use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException; +use Symfony\Component\Security\Core\Exception\TooManyLoginAttemptsAuthenticationException; #[CoversClass(HttpProcessExecutionAuthenticator::class)] class HttpProcessExecutionAuthenticatorTest extends TestCase @@ -46,6 +51,28 @@ public function testDoesNotSupportOtherRoutes(): void self::assertFalse($this->createAuthenticator()->supports($request)); } + /** + * @return iterable + */ + public static function provideAuthenticationFailures(): iterable + { + yield 'invalid token' => [new CustomUserMessageAuthenticationException('Invalid token.'), 'Invalid token.']; + // No message, only a message key with data + yield 'too many attempts' => [new TooManyLoginAttemptsAuthenticationException(2), 'Too many failed login attempts, please try again in 2 minutes.']; + // The message of an exception is not displayed, it may disclose internal details + yield 'other exception' => [new AuthenticationException('Internal details.'), 'An authentication exception occurred.']; + } + + #[DataProvider('provideAuthenticationFailures')] + public function testOnAuthenticationFailure(AuthenticationException $exception, string $message): void + { + $response = $this->createAuthenticator()->onAuthenticationFailure(new Request(), $exception); + + self::assertInstanceOf(JsonResponse::class, $response); + self::assertSame(401, $response->getStatusCode()); + self::assertSame(['message' => $message], json_decode((string) $response->getContent(), true)); + } + private function createAuthenticator(): HttpProcessExecutionAuthenticator { return new HttpProcessExecutionAuthenticator($this->createStub(EntityManagerInterface::class)); diff --git a/tests/Security/HttpProcessExecutionRequestRateLimiterTest.php b/tests/Security/HttpProcessExecutionRequestRateLimiterTest.php new file mode 100644 index 0000000..ebf3c22 --- /dev/null +++ b/tests/Security/HttpProcessExecutionRequestRateLimiterTest.php @@ -0,0 +1,118 @@ +createAuthenticator(); + + $limiter = HttpProcessExecutionRequestRateLimiter::create($this->createStub(PeekableRequestRateLimiterInterface::class), $authenticator); + self::assertInstanceOf(PeekableHttpProcessExecutionRequestRateLimiter::class, $limiter); + + // The LoginThrottlingListener of Symfony counts every attempt with a limiter which is not peekable + $limiter = HttpProcessExecutionRequestRateLimiter::create($this->createStub(RequestRateLimiterInterface::class), $authenticator); + self::assertNotInstanceOf(PeekableRequestRateLimiterInterface::class, $limiter); + } + + public function testHttpProcessExecutionIsNotCounted(): void + { + $inner = $this->createMock(PeekableRequestRateLimiterInterface::class); + $inner->expects(self::never())->method('consume'); + $inner->expects(self::never())->method('peek'); + $inner->expects(self::never())->method('reset'); + $limiter = HttpProcessExecutionRequestRateLimiter::create($inner, $this->createAuthenticator()); + self::assertInstanceOf(PeekableRequestRateLimiterInterface::class, $limiter); + $request = $this->createHttpProcessExecutionRequest(); + + foreach ([$limiter->peek($request), $limiter->consume($request)] as $limit) { + // Accepted, untouched: the listener does not reset the limiter after a success + self::assertTrue($limit->isAccepted()); + self::assertGreaterThan(0, $limit->getRemainingTokens()); + self::assertSame($limit->getLimit(), $limit->getRemainingTokens()); + } + $limiter->reset($request); + } + + public function testHttpProcessExecutionIsNotCountedByANonPeekableLimiter(): void + { + $inner = $this->createMock(RequestRateLimiterInterface::class); + $inner->expects(self::never())->method('consume'); + $inner->expects(self::never())->method('reset'); + $limiter = HttpProcessExecutionRequestRateLimiter::create($inner, $this->createAuthenticator()); + $request = $this->createHttpProcessExecutionRequest(); + + self::assertTrue($limiter->consume($request)->isAccepted()); + $limiter->reset($request); + } + + public function testOtherRequestsAreCounted(): void + { + $request = Request::create('/process/login', Request::METHOD_POST); + $request->attributes->set('_route', 'cleverage_ui_process_login'); + $rejected = new RateLimit(0, new \DateTimeImmutable('+1 minute'), false, 5); + $inner = $this->createMock(PeekableRequestRateLimiterInterface::class); + $inner->expects(self::once())->method('peek')->with($request)->willReturn($rejected); + $inner->expects(self::once())->method('consume')->with($request)->willReturn($rejected); + $inner->expects(self::once())->method('reset')->with($request); + $limiter = HttpProcessExecutionRequestRateLimiter::create($inner, $this->createAuthenticator()); + self::assertInstanceOf(PeekableRequestRateLimiterInterface::class, $limiter); + + self::assertSame($rejected, $limiter->peek($request)); + self::assertSame($rejected, $limiter->consume($request)); + $limiter->reset($request); + } + + public function testOtherRequestsAreCountedByANonPeekableLimiter(): void + { + $request = Request::create('/process/login', Request::METHOD_POST); + $rejected = new RateLimit(0, new \DateTimeImmutable('+1 minute'), false, 5); + $inner = $this->createMock(RequestRateLimiterInterface::class); + $inner->expects(self::once())->method('consume')->with($request)->willReturn($rejected); + $inner->expects(self::once())->method('reset')->with($request); + $limiter = HttpProcessExecutionRequestRateLimiter::create($inner, $this->createAuthenticator()); + + self::assertSame($rejected, $limiter->consume($request)); + $limiter->reset($request); + } + + private function createAuthenticator(): HttpProcessExecutionAuthenticator + { + return new HttpProcessExecutionAuthenticator($this->createStub(EntityManagerInterface::class)); + } + + private function createHttpProcessExecutionRequest(): Request + { + $request = Request::create('/http/process/execute', Request::METHOD_POST); + $request->attributes->set('_route', 'cleverage_ui_process_http_execute'); + + return $request; + } +} diff --git a/tests/StopChecker/ProcessExecutionStopCheckerTest.php b/tests/StopChecker/ProcessExecutionStopCheckerTest.php index bf07d6d..2101df3 100644 --- a/tests/StopChecker/ProcessExecutionStopCheckerTest.php +++ b/tests/StopChecker/ProcessExecutionStopCheckerTest.php @@ -19,6 +19,7 @@ use CleverAge\UiProcessBundle\CleverAgeUiProcessBundle; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; +use CleverAge\UiProcessBundle\DependencyInjection\Compiler\LoginThrottlingPass; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\UploadDirectoryParameterPass; use CleverAge\UiProcessBundle\DependencyInjection\Configuration; use CleverAge\UiProcessBundle\Entity\ProcessExecution; @@ -39,6 +40,7 @@ #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] #[UsesClass(UploadDirectoryParameterPass::class)] +#[UsesClass(LoginThrottlingPass::class)] #[UsesClass(CleverAgeUiProcessExtension::class)] #[UsesClass(DeprecatedEasyAdminRouteAliasLoader::class)] #[UsesClass(Configuration::class)]