Skip to content
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
------

Expand Down
22 changes: 17 additions & 5 deletions docs/reference/03-users_and_security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Expand Down
3 changes: 3 additions & 0 deletions docs/reference/06-http_api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
----------

Expand Down
2 changes: 2 additions & 0 deletions src/CleverAgeUiProcessBundle.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -36,5 +37,6 @@ public function build(ContainerBuilder $container): void

$container->addCompilerPass(new CheckDoctrineLazyGhostObjectsPass());
$container->addCompilerPass(new UploadDirectoryParameterPass());
$container->addCompilerPass(new LoginThrottlingPass());
}
}
65 changes: 48 additions & 17 deletions src/DependencyInjection/CleverAgeUiProcessExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
[
Expand All @@ -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.
*/
Expand Down
62 changes: 62 additions & 0 deletions src/DependencyInjection/Compiler/LoginThrottlingPass.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php

declare(strict_types=1);

/*
* This file is part of the CleverAge/UiProcessBundle package.
*
* Copyright (c) Clever-Age
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace CleverAge\UiProcessBundle\DependencyInjection\Compiler;

use CleverAge\UiProcessBundle\Security\HttpProcessExecutionRequestRateLimiter;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Exception\LogicException;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\HttpFoundation\RateLimiter\RequestRateLimiterInterface;

/**
* The login throttling of a firewall (Symfony "login_throttling", added to the "main" firewall by the bundle) does not
* apply to the requests of the HTTP API: the limiter of its listener is decorated by
* HttpProcessExecutionRequestRateLimiter, the firewall settings (max_attempts, interval, limiter...) still apply to the
* other requests.
*
* Without the rate limiter of the framework ("framework.rate_limiter: false"), the default limiter of the login
* throttling cannot be created: explain how to fix it, instead of the "Parent definition "limiter" does not exist" error.
*
* @internal
*/
final readonly class LoginThrottlingPass implements CompilerPassInterface
{
/** Listener of the login throttling of a firewall, see Symfony LoginThrottlingFactory */
private const LISTENER_PREFIX = 'security.listener.login_throttling.';
private const AUTHENTICATOR = 'cleverage_ui_process.security.http_process_execution_authenticator';

public function process(ContainerBuilder $container): void
{
foreach ($container->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));
}
}
}
8 changes: 5 additions & 3 deletions src/Security/HttpProcessExecutionAuthenticator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -43,15 +44,15 @@ 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 ?? '');
$user = $this->entityManager->getRepository(User::class)->findOneBy(
['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() ?? ''));
Expand All @@ -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);
Expand Down
80 changes: 80 additions & 0 deletions src/Security/HttpProcessExecutionRequestRateLimiter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php

declare(strict_types=1);

/*
* This file is part of the CleverAge/UiProcessBundle package.
*
* Copyright (c) Clever-Age
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace CleverAge\UiProcessBundle\Security;

use Symfony\Component\HttpFoundation\RateLimiter\PeekableRequestRateLimiterInterface;
use Symfony\Component\HttpFoundation\RateLimiter\RequestRateLimiterInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\RateLimiter\RateLimit;
use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface;

/**
* Login throttling limiter of a firewall (see LoginThrottlingPass) which does not count the requests of the HTTP API:
* they are authenticated by a token, not by a password. The other requests (the login form) are counted by the
* limiter of the firewall, with its settings.
*
* @internal
*/
class HttpProcessExecutionRequestRateLimiter implements RequestRateLimiterInterface
{
public function __construct(
protected readonly RequestRateLimiterInterface $limiter,
protected readonly AuthenticatorInterface $authenticator,
) {
}

/**
* The decorated limiter is peekable or not, as the firewall limiter: the LoginThrottlingListener of Symfony counts
* the failed attempts only with a peekable limiter, every attempt otherwise.
*/
public static function create(RequestRateLimiterInterface $limiter, AuthenticatorInterface $authenticator): RequestRateLimiterInterface
{
if ($limiter instanceof PeekableRequestRateLimiterInterface) {
return new PeekableHttpProcessExecutionRequestRateLimiter($limiter, $authenticator);
}

return new self($limiter, $authenticator);
}

public function consume(Request $request): RateLimit
{
if ($this->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);
}
}
37 changes: 37 additions & 0 deletions src/Security/PeekableHttpProcessExecutionRequestRateLimiter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

/*
* This file is part of the CleverAge/UiProcessBundle package.
*
* Copyright (c) Clever-Age
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace CleverAge\UiProcessBundle\Security;

use Symfony\Component\HttpFoundation\RateLimiter\PeekableRequestRateLimiterInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\RateLimiter\RateLimit;

/**
* HttpProcessExecutionRequestRateLimiter of a peekable firewall limiter (the default one).
*
* @internal
*/
final class PeekableHttpProcessExecutionRequestRateLimiter extends HttpProcessExecutionRequestRateLimiter implements PeekableRequestRateLimiterInterface
{
public function peek(Request $request): RateLimit
{
if ($this->isHttpProcessExecution($request)) {
return $this->createAcceptedRateLimit();
}

\assert($this->limiter instanceof PeekableRequestRateLimiterInterface);

return $this->limiter->peek($request);
}
}
Loading
Loading