From cd01715226367bed38e0b2771d55d7a1f136b017 Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Sat, 3 Jan 2026 16:27:05 +0100 Subject: [PATCH 1/9] Add new `Authenticator` service to decouple authentication from panel --- formwork/config/system.yaml | 8 +- formwork/src/Authentication/Authenticator.php | 82 ++++++++++++++++ .../AuthenticationFailedException.php | 2 +- .../Exceptions/RateLimitExceededException.php | 31 +++++++ .../Exceptions/UserNotLoggedException.php | 2 +- .../RateLimiter.php} | 34 +++++-- formwork/src/Cms/App.php | 8 ++ .../Panel/Controllers/AbstractController.php | 2 +- .../Controllers/AuthenticationController.php | 93 ++++++++----------- .../src/Panel/Controllers/UsersController.php | 6 -- formwork/src/Panel/Panel.php | 2 +- .../Loaders/AuthenticationServiceLoader.php | 28 ++++++ .../Services/Loaders/PanelServiceLoader.php | 25 +++-- formwork/src/Users/User.php | 61 ++++++------ 14 files changed, 279 insertions(+), 105 deletions(-) create mode 100644 formwork/src/Authentication/Authenticator.php rename formwork/src/{Users => Authentication}/Exceptions/AuthenticationFailedException.php (67%) create mode 100644 formwork/src/Authentication/Exceptions/RateLimitExceededException.php rename formwork/src/{Users => Authentication}/Exceptions/UserNotLoggedException.php (65%) rename formwork/src/{Panel/Security/AccessLimiter.php => Authentication/RateLimiter.php} (67%) create mode 100644 formwork/src/Services/Loaders/AuthenticationServiceLoader.php diff --git a/formwork/config/system.yaml b/formwork/config/system.yaml index d3f038d1b..de3e16cc6 100644 --- a/formwork/config/system.yaml +++ b/formwork/config/system.yaml @@ -1,3 +1,9 @@ +authentication: + registryPath: '${%ROOT_PATH%}/site/auth/registry/' + limits: + maxAttempts: 1 + resetTime: 3000 + backup: path: '${%ROOT_PATH%}/backup' name: formwork-backup @@ -91,8 +97,6 @@ panel: root: panel path: '${%ROOT_PATH%}/panel' translation: en - loginAttempts: 10 - loginResetTime: 300 logoutRedirect: login userImageSize: 512 colorScheme: auto diff --git a/formwork/src/Authentication/Authenticator.php b/formwork/src/Authentication/Authenticator.php new file mode 100644 index 000000000..beff9fc03 --- /dev/null +++ b/formwork/src/Authentication/Authenticator.php @@ -0,0 +1,82 @@ +rateLimiter->assertAllowed(); + + /** @var ?User */ + $user = $this->users->find(fn(User $user) => $user->username() === $login || $user->email() === $login); + + if (!$user?->verifyPassword($password)) { + throw new AuthenticationFailedException(sprintf('Authentication failed for "%s"', $login)); + } + } catch (RateLimitExceededException|AuthenticationFailedException $e) { + // Delay processing for 0.5-1s + usleep(random_int(500, 1000) * 1000); + + throw $e; + } + + $this->session->set(self::SESSION_LOGGED_USER_KEY, $user->username()); + + $user->set('lastAccess', time()); + $user->save(); + + return $user; + } + + /** + * Return whether a user is logged in + */ + public function isLoggedIn(): bool + { + return $this->session->has(self::SESSION_LOGGED_USER_KEY); + } + + /** + * Logout currently logged in user + */ + public function logout(): void + { + if (!$this->isLoggedIn()) { + throw new UserNotLoggedException('Cannot logout, no user is logged in'); + } + $this->session->remove(self::SESSION_LOGGED_USER_KEY); + } + + public function getUser(): ?User + { + $username = $this->session->get(self::SESSION_LOGGED_USER_KEY); + return $this->users->find(fn(User $user) => $user->username() === $username); + } +} diff --git a/formwork/src/Users/Exceptions/AuthenticationFailedException.php b/formwork/src/Authentication/Exceptions/AuthenticationFailedException.php similarity index 67% rename from formwork/src/Users/Exceptions/AuthenticationFailedException.php rename to formwork/src/Authentication/Exceptions/AuthenticationFailedException.php index 7208f726c..8b7217a48 100644 --- a/formwork/src/Users/Exceptions/AuthenticationFailedException.php +++ b/formwork/src/Authentication/Exceptions/AuthenticationFailedException.php @@ -1,6 +1,6 @@ resetTime; + } +} diff --git a/formwork/src/Users/Exceptions/UserNotLoggedException.php b/formwork/src/Authentication/Exceptions/UserNotLoggedException.php similarity index 65% rename from formwork/src/Users/Exceptions/UserNotLoggedException.php rename to formwork/src/Authentication/Exceptions/UserNotLoggedException.php index 852e0f424..ddda95ceb 100644 --- a/formwork/src/Users/Exceptions/UserNotLoggedException.php +++ b/formwork/src/Authentication/Exceptions/UserNotLoggedException.php @@ -1,6 +1,6 @@ registerAttempt(); + if ($this->hasReachedLimit()) { + throw new RateLimitExceededException('Rate limit exceeded', $this->resetTime); + } + } + /** * Return whether attempts limit is reached */ public function hasReachedLimit(): bool { - if (isset($this->lastAttemptTime) && time() - $this->lastAttemptTime > $this->resetTime) { - $this->resetAttempts(); - } return $this->attempts > $this->limit; } @@ -52,6 +63,9 @@ public function hasReachedLimit(): bool */ public function registerAttempt(): void { + if (isset($this->lastAttemptTime) && time() - $this->lastAttemptTime > $this->resetTime) { + $this->resetAttempts(); + } $this->registry->set($this->attemptHash, [++$this->attempts, time()]); } @@ -63,4 +77,12 @@ public function resetAttempts(): void $this->attempts = 0; $this->registry->remove($this->attemptHash); } + + /** + * Get the attempts reset time + */ + public function getResetTime(): int + { + return $this->resetTime; + } } diff --git a/formwork/src/Cms/App.php b/formwork/src/Cms/App.php index 24c3c7ec6..0e8b93cce 100644 --- a/formwork/src/Cms/App.php +++ b/formwork/src/Cms/App.php @@ -5,6 +5,7 @@ use BadMethodCallException; use ErrorException; use Formwork\Assets\Assets; +use Formwork\Authentication\Authenticator; use Formwork\Cache\AbstractCache; use Formwork\Cache\FilesCache; use Formwork\Cms\Events\ExceptionThrownEvent; @@ -20,6 +21,7 @@ use Formwork\Files\Services\FileUploader; use Formwork\Http\Request; use Formwork\Http\Response; +use Formwork\Http\Session\Session; use Formwork\Images\ImageFactory; use Formwork\Languages\LanguagesFactory; use Formwork\Log\Logger; @@ -33,6 +35,7 @@ use Formwork\Security\CsrfToken; use Formwork\Services\Container; use Formwork\Services\Loaders\AssetsServiceLoader; +use Formwork\Services\Loaders\AuthenticationServiceLoader; use Formwork\Services\Loaders\ConfigServiceLoader; use Formwork\Services\Loaders\LoggerServiceLoader; use Formwork\Services\Loaders\PanelServiceLoader; @@ -257,6 +260,8 @@ private function loadServices(Container $container): void $container->define(Request::class, fn() => Request::fromGlobals()) ->alias('request'); + $container->define(Session::class, fn(Request $request) => $request->session()); + $container->define(Config::class) ->loader(ConfigServiceLoader::class) ->alias('config'); @@ -317,6 +322,9 @@ private function loadServices(Container $container): void ->loader(UsersServiceLoader::class) ->alias('users'); + $container->define(Authenticator::class) + ->loader(AuthenticationServiceLoader::class); + $container->define(Assets::class) ->loader(AssetsServiceLoader::class) ->alias('assets'); diff --git a/formwork/src/Panel/Controllers/AbstractController.php b/formwork/src/Panel/Controllers/AbstractController.php index b4ec3897e..48109de38 100644 --- a/formwork/src/Panel/Controllers/AbstractController.php +++ b/formwork/src/Panel/Controllers/AbstractController.php @@ -85,7 +85,7 @@ protected function defaults(): array 'location' => $this->name, 'site' => $this->site, 'panel' => $this->panel, - 'csrfToken' => $this->csrfToken->get($this->panel->getCsrfTokenName()), + 'csrfToken' => $this->csrfToken->get($this->panel->getCsrfTokenName(), autoGenerate: true), ]; } } diff --git a/formwork/src/Panel/Controllers/AuthenticationController.php b/formwork/src/Panel/Controllers/AuthenticationController.php index 4b48257fd..f4b022137 100644 --- a/formwork/src/Panel/Controllers/AuthenticationController.php +++ b/formwork/src/Panel/Controllers/AuthenticationController.php @@ -2,16 +2,17 @@ namespace Formwork\Panel\Controllers; +use Formwork\Authentication\Authenticator; +use Formwork\Authentication\Exceptions\AuthenticationFailedException; +use Formwork\Authentication\Exceptions\RateLimitExceededException; +use Formwork\Authentication\Exceptions\UserNotLoggedException; use Formwork\Http\RedirectResponse; use Formwork\Http\RequestMethod; use Formwork\Http\Response; +use Formwork\Http\ResponseStatus; use Formwork\Panel\Events\PanelLoggedInEvent; use Formwork\Panel\Events\PanelLoggedOutEvent; -use Formwork\Panel\Security\AccessLimiter; use Formwork\Schemes\Schemes; -use Formwork\Users\Exceptions\AuthenticationFailedException; -use Formwork\Users\Exceptions\UserNotLoggedException; -use Formwork\Users\User; final class AuthenticationController extends AbstractController { @@ -23,7 +24,7 @@ final class AuthenticationController extends AbstractController /** * Authentication@login action */ - public function login(AccessLimiter $accessLimiter, Schemes $schemes): Response + public function login(Schemes $schemes, Authenticator $authenticator): Response { if ($this->panel->isLoggedIn()) { return $this->redirect($this->generateRoute('panel.index')); @@ -31,64 +32,49 @@ public function login(AccessLimiter $accessLimiter, Schemes $schemes): Response $fields = $schemes->get('forms.login')->fields(); - $csrfTokenName = $this->panel->getCsrfTokenName(); - - if ($accessLimiter->hasReachedLimit()) { - $minutes = round($this->config->get('system.panel.loginResetTime') / 60); - $this->csrfToken->generate($csrfTokenName); - return $this->error($this->translate('panel.login.attempt.tooMany', $minutes), ['fields' => $fields]); - } - if ($this->request->method() === RequestMethod::POST) { - // Delay request processing for 0.5-1s - usleep(random_int(500, 1000) * 1000); - $form = $this->form('login', $fields) ->processRequest($this->request); if (!$form->isValid()) { - // If validation fails, generate a new CSRF token and return an error - $this->csrfToken->generate($csrfTokenName); - return $this->error($this->translate('panel.login.attempt.failed'), ['fields' => $form->fields()]); + return $this->error( + $this->translate('panel.login.attempt.failed'), + ['fields' => $form->fields()] + ); } - $accessLimiter->registerAttempt(); - - $login = $form->data()->get('login'); - /** @var ?User */ - $user = $this->site->users()->find(fn($user) => $user->username() === $login || $user->email() === $login); - - // Authenticate user - if ($user !== null) { - try { - $user->authenticate($form->data()->get('password')); + try { + $user = $authenticator->login($form->data()->get('login'), $form->data()->get('password')); - // Regenerate CSRF token - $this->csrfToken->generate($csrfTokenName); + // Regenerate CSRF token + $this->csrfToken->generate($this->panel->getCsrfTokenName()); - $accessLimiter->resetAttempts(); + $this->events->dispatch(new PanelLoggedInEvent($user, $this->request)); - $this->events->dispatch(new PanelLoggedInEvent($user, $this->request)); - - if (($destination = $this->request->session()->get(self::SESSION_REDIRECT_KEY)) !== null) { - $this->request->session()->remove(self::SESSION_REDIRECT_KEY); - return new RedirectResponse($this->panel->uri($destination)); - } - - return $this->redirect($this->generateRoute('panel.index')); - } catch (AuthenticationFailedException) { - // Do nothing, the error response will be sent below + if (($destination = $this->request->session()->get(self::SESSION_REDIRECT_KEY)) !== null) { + $this->request->session()->remove(self::SESSION_REDIRECT_KEY); + return new RedirectResponse($this->panel->uri($destination)); } - } - $this->csrfToken->generate($csrfTokenName); - - return $this->error($this->translate('panel.login.attempt.failed'), ['fields' => $fields]); + return $this->redirect($this->generateRoute('panel.index')); + } catch (RateLimitExceededException $e) { + // Regenerate CSRF token + $this->csrfToken->generate($this->panel->getCsrfTokenName()); + + return $this->error( + $this->translate('panel.login.attempt.tooMany', round($e->getResetTime() / 60)), + ['fields' => $fields], + ResponseStatus::TooManyRequests, + ['Retry-After' => (string) $e->getResetTime()] + ); + } catch (AuthenticationFailedException) { + return $this->error( + $this->translate('panel.login.attempt.failed'), + ['fields' => $fields] + ); + } } - // Always generate a new CSRF token - $this->csrfToken->generate($csrfTokenName); - return new Response($this->view('@panel.authentication.login', [ 'title' => $this->translate('panel.login.login'), 'fields' => $fields, @@ -113,7 +99,7 @@ public function logout(): RedirectResponse $this->panel->notify($this->translate('panel.login.loggedOut'), 'info'); } catch (UserNotLoggedException) { - // Do nothing if user is not logged, the user will be redirected to the login page + // Do nothing if user is not logged in, the user will be redirected to the login page } return $this->redirect($this->generateRoute('panel.index')); @@ -122,12 +108,13 @@ public function logout(): RedirectResponse /** * Display login view with an error notification * - * @param array $data + * @param array $data + * @param array $headers */ - private function error(string $message, array $data = []): Response + private function error(string $message, array $data = [], ResponseStatus $responseStatus = ResponseStatus::OK, array $headers = []): Response { $defaults = ['title' => $this->translate('panel.login.login'), 'error' => true]; $this->panel->notify($message, 'error'); - return new Response($this->view('@panel.authentication.login', [...$defaults, ...$data])); + return new Response($this->view('@panel.authentication.login', [...$defaults, ...$data]), $responseStatus, $headers); } } diff --git a/formwork/src/Panel/Controllers/UsersController.php b/formwork/src/Panel/Controllers/UsersController.php index 67647fa92..fc27d323f 100644 --- a/formwork/src/Panel/Controllers/UsersController.php +++ b/formwork/src/Panel/Controllers/UsersController.php @@ -10,7 +10,6 @@ use Formwork\Http\RequestMethod; use Formwork\Http\Response; use Formwork\Images\Image; -use Formwork\Log\Registry; use Formwork\Router\RouteParams; use Formwork\Users\User; use Formwork\Users\UserFactory; @@ -112,11 +111,6 @@ public function delete(RouteParams $routeParams): Response return $this->redirectToReferer(default: $this->generateRoute('panel.users'), base: $this->panel->panelRoot()); } - $lastAccessRegistry = new Registry(FileSystem::joinPaths($this->config->get('system.panel.paths.logs'), 'lastAccess.json')); - - // Remove user last access from registry - $lastAccessRegistry->remove($user->username()); - $this->panel->notify($this->translate('panel.users.user.deleted'), 'success'); return $this->redirect($this->generateRoute('panel.users')); } diff --git a/formwork/src/Panel/Panel.php b/formwork/src/Panel/Panel.php index 4eae60ad8..4016a64f5 100644 --- a/formwork/src/Panel/Panel.php +++ b/formwork/src/Panel/Panel.php @@ -3,6 +3,7 @@ namespace Formwork\Panel; use Formwork\Assets\Assets; +use Formwork\Authentication\Exceptions\UserNotLoggedException; use Formwork\Config\Config; use Formwork\Events\EventDispatcher; use Formwork\Http\Request; @@ -15,7 +16,6 @@ use Formwork\Services\Container; use Formwork\Translations\Translations; use Formwork\Users\ColorScheme; -use Formwork\Users\Exceptions\UserNotLoggedException; use Formwork\Users\User; use Formwork\Users\Users; use Formwork\Utils\Arr; diff --git a/formwork/src/Services/Loaders/AuthenticationServiceLoader.php b/formwork/src/Services/Loaders/AuthenticationServiceLoader.php new file mode 100644 index 000000000..efda9d711 --- /dev/null +++ b/formwork/src/Services/Loaders/AuthenticationServiceLoader.php @@ -0,0 +1,28 @@ +define(RateLimiter::class) + ->parameter('registry', new Registry(FileSystem::joinPaths($this->config->get('system.authentication.registryPath'), 'accessAttempts.json'))) + ->parameter('limit', $this->config->get('system.authentication.limits.maxAttempts')) + ->parameter('resetTime', $this->config->get('system.authentication.limits.resetTime')); + + return $container->build(Authenticator::class); + } +} diff --git a/formwork/src/Services/Loaders/PanelServiceLoader.php b/formwork/src/Services/Loaders/PanelServiceLoader.php index e13bf7274..2eb4d68a6 100644 --- a/formwork/src/Services/Loaders/PanelServiceLoader.php +++ b/formwork/src/Services/Loaders/PanelServiceLoader.php @@ -3,6 +3,7 @@ namespace Formwork\Services\Loaders; use Formwork\Assets\Assets; +use Formwork\Authentication\RateLimiter; use Formwork\Cms\Site; use Formwork\Config\Config; use Formwork\Controllers\ErrorsControllerInterface; @@ -15,7 +16,6 @@ use Formwork\Panel\Modals\ModalFactory; use Formwork\Panel\Modals\Modals; use Formwork\Panel\Panel; -use Formwork\Panel\Security\AccessLimiter; use Formwork\Schemes\Schemes; use Formwork\Services\Container; use Formwork\Services\ResolutionAwareServiceLoaderInterface; @@ -39,10 +39,22 @@ public function __construct( public function load(Container $container): Panel { - $container->define(AccessLimiter::class) - ->parameter('registry', new Registry(FileSystem::joinPaths($this->config->get('system.panel.paths.logs'), 'accessAttempts.json'))) - ->parameter('limit', $this->config->get('system.panel.loginAttempts')) - ->parameter('resetTime', $this->config->get('system.panel.loginResetTime')); + if ($this->config->has('system.panel.loginAttempts') || $this->config->has('system.panel.resetTime')) { + if ($this->config->has('system.panel.loginAttempts')) { + trigger_error('The "system.panel.loginAttempts" configuration option is deprecated since Formwork 2.3.0 and will be removed in a future release. Use "system.authentication.maxAttempts" instead.', E_USER_DEPRECATED); + } + + if ($this->config->has('system.panel.loginResetTime')) { + trigger_error('The "system.panel.resetTime" configuration option is deprecated since Formwork 2.3.0 and will be removed in a future release. Use "system.authentication.resetTime" instead.', E_USER_DEPRECATED); + } + + $container->define(RateLimiter::class) + ->parameter('registry', new Registry(FileSystem::joinPaths($this->config->get('system.authentication.registryPath'), 'accessAttempts.json'))) + ->parameter('limit', $this->config->get('system.panel.loginAttempts', $this->config->get('system.authentication.maxAttempts'))) + ->parameter('resetTime', $this->config->get('system.panel.loginResetTime', $this->config->get('system.authentication.resetTime'))); + + $container->resolve(RateLimiter::class); + } $container->define(Updater::class) ->parameter('options', $this->config->get('system.updates')); @@ -95,9 +107,6 @@ public function onResolved(object $service, Container $container): void private function onPanelLoggedIn(PanelLoggedInEvent $panelLoggedInEvent): void { - $lastAccessRegistry = new Registry(FileSystem::joinPaths($this->config->get('system.panel.paths.logs'), 'lastAccess.json')); - $lastAccessRegistry->set($panelLoggedInEvent->user()->username(), sprintf('%F', microtime(true))); - $this->logger->info('Panel user {username} logged in', ['username' => $panelLoggedInEvent->user()->username()]); } } diff --git a/formwork/src/Users/User.php b/formwork/src/Users/User.php index 3d33aa62b..ad2387e53 100644 --- a/formwork/src/Users/User.php +++ b/formwork/src/Users/User.php @@ -2,30 +2,29 @@ namespace Formwork\Users; +use Formwork\Authentication\Authenticator; +use Formwork\Authentication\Exceptions\AuthenticationFailedException; +use Formwork\Authentication\Exceptions\UserNotLoggedException; use Formwork\Config\Config; use Formwork\Data\Exceptions\InvalidValueException; use Formwork\Exceptions\TranslatedException; use Formwork\Files\FileFactory; use Formwork\Http\Request; use Formwork\Images\Image; -use Formwork\Log\Registry; use Formwork\Model\Model; use Formwork\Parsers\Yaml; use Formwork\Schemes\Schemes; -use Formwork\Users\Exceptions\AuthenticationFailedException; use Formwork\Users\Exceptions\UserImageNotFoundException; -use Formwork\Users\Exceptions\UserNotLoggedException; use Formwork\Users\Utils\Password; use Formwork\Utils\Arr; use Formwork\Utils\FileSystem; use Formwork\Utils\Str; use LogicException; use SensitiveParameter; +use UnexpectedValueException; class User extends Model { - public const string SESSION_LOGGED_USER_KEY = '_formwork_logged_user'; - public const int MINIMUM_PASSWORD_LENGTH = 8; protected const string MODEL_IDENTIFIER = 'user'; @@ -44,6 +43,7 @@ class User extends Model 'role' => 'user', 'image' => null, 'colorScheme' => 'auto', + 'lastAccess' => null, ]; /** @@ -51,11 +51,6 @@ class User extends Model */ protected ?Image $image = null; - /** - * User last access time - */ - protected ?int $lastAccess = null; - /** * @param array $data */ @@ -149,11 +144,7 @@ public function authenticate( #[SensitiveParameter] string $password, ): void { - if (!$this->verifyPassword($password)) { - throw new AuthenticationFailedException(sprintf('Authentication failed for user "%s"', $this->username())); - } - $this->request->session()->regenerate(); - $this->request->session()->set(self::SESSION_LOGGED_USER_KEY, $this->username()); + $this->getAuthenticator()->login($this->username(), $password); } /** @@ -163,7 +154,7 @@ public function verifyPassword( #[SensitiveParameter] string $password, ): bool { - return Password::verify($password, $this->hash()); + return Password::verify($password, $this->getPasswordHash()); } /** @@ -173,11 +164,10 @@ public function verifyPassword( */ public function logout(): void { - if (!$this->isLoggedIn()) { + if ($this->getAuthenticator()->getUser()?->username() !== $this->username()) { throw new UserNotLoggedException(sprintf('Cannot logout user "%s": user not logged', $this->username())); } - $this->request->session()->remove(self::SESSION_LOGGED_USER_KEY); - $this->request->session()->destroy(); + $this->getAuthenticator()->logout(); } /** @@ -185,7 +175,7 @@ public function logout(): void */ public function isLoggedIn(): bool { - return $this->request->session()->get(self::SESSION_LOGGED_USER_KEY) === $this->username(); + return $this->getAuthenticator()->getUser()?->username() === $this->username(); } /** @@ -232,15 +222,17 @@ public function canChangeRoleOf(User $user): bool } /** - * Get the user last access time + * Get data by key returning a default value if key is not present + * + * @throws LogicException If trying to access the user password hash */ - public function lastAccess(): ?int + public function get(string $key, mixed $default = null): mixed { - if ($this->lastAccess !== null) { - return $this->lastAccess; + if ($key === 'hash') { + throw new LogicException('Cannot access user password hash'); } - $registry = new Registry(FileSystem::joinPaths($this->config->get('system.panel.paths.logs'), 'lastAccess.json')); - return $this->lastAccess = $registry->has($this->username()) ? (int) $registry->get($this->username()) : null; + + return parent::get($key, $default); } /** @@ -405,6 +397,15 @@ protected function validateRole(string $role): void } } + /** + * Get user password hash + */ + protected function getPasswordHash(): string + { + return Arr::get($this->data, 'hash') + ?? throw new UnexpectedValueException(sprintf('User "%s" has no password hash set', $this->username())); + } + /** * Set user password hash * @@ -417,4 +418,12 @@ protected function setPasswordHash(string $password): void } Arr::set($this->data, 'hash', Password::hash($password)); } + + /** + * Get the authenticator service + */ + protected function getAuthenticator(): Authenticator + { + return $this->app()->getService(Authenticator::class); + } } From 9d8abcd9efd9cafb89b49098bed9cbef4cb8e6dc Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Sat, 3 Jan 2026 16:56:17 +0100 Subject: [PATCH 2/9] Fix configuration default limits --- formwork/config/system.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/formwork/config/system.yaml b/formwork/config/system.yaml index de3e16cc6..67eaad7fd 100644 --- a/formwork/config/system.yaml +++ b/formwork/config/system.yaml @@ -1,8 +1,8 @@ authentication: registryPath: '${%ROOT_PATH%}/site/auth/registry/' limits: - maxAttempts: 1 - resetTime: 3000 + maxAttempts: 10 + resetTime: 300 backup: path: '${%ROOT_PATH%}/backup' From 40e1a0e33e061e17825cd839183b324293d12396 Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Sat, 3 Jan 2026 16:58:00 +0100 Subject: [PATCH 3/9] Fix handling of deprecated config --- formwork/src/Services/Loaders/PanelServiceLoader.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/formwork/src/Services/Loaders/PanelServiceLoader.php b/formwork/src/Services/Loaders/PanelServiceLoader.php index 2eb4d68a6..45cbe1b1f 100644 --- a/formwork/src/Services/Loaders/PanelServiceLoader.php +++ b/formwork/src/Services/Loaders/PanelServiceLoader.php @@ -39,19 +39,19 @@ public function __construct( public function load(Container $container): Panel { - if ($this->config->has('system.panel.loginAttempts') || $this->config->has('system.panel.resetTime')) { + if ($this->config->has('system.panel.loginAttempts') || $this->config->has('system.panel.loginResetTime')) { if ($this->config->has('system.panel.loginAttempts')) { - trigger_error('The "system.panel.loginAttempts" configuration option is deprecated since Formwork 2.3.0 and will be removed in a future release. Use "system.authentication.maxAttempts" instead.', E_USER_DEPRECATED); + trigger_error('The "system.panel.loginAttempts" configuration option is deprecated since Formwork 2.3.0 and will be removed in a future release. Use "system.authentication.limits.maxAttempts" instead.', E_USER_DEPRECATED); } if ($this->config->has('system.panel.loginResetTime')) { - trigger_error('The "system.panel.resetTime" configuration option is deprecated since Formwork 2.3.0 and will be removed in a future release. Use "system.authentication.resetTime" instead.', E_USER_DEPRECATED); + trigger_error('The "system.panel.loginResetTime" configuration option is deprecated since Formwork 2.3.0 and will be removed in a future release. Use "system.authentication.limits.resetTime" instead.', E_USER_DEPRECATED); } $container->define(RateLimiter::class) ->parameter('registry', new Registry(FileSystem::joinPaths($this->config->get('system.authentication.registryPath'), 'accessAttempts.json'))) - ->parameter('limit', $this->config->get('system.panel.loginAttempts', $this->config->get('system.authentication.maxAttempts'))) - ->parameter('resetTime', $this->config->get('system.panel.loginResetTime', $this->config->get('system.authentication.resetTime'))); + ->parameter('limit', $this->config->get('system.panel.loginAttempts', $this->config->get('system.authentication.limits.maxAttempts'))) + ->parameter('resetTime', $this->config->get('system.panel.loginResetTime', $this->config->get('system.authentication.limits.resetTime'))); $container->resolve(RateLimiter::class); } From c69908b6cd44765d20fe58beb2b640089dcb4a55 Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Sat, 3 Jan 2026 17:05:19 +0100 Subject: [PATCH 4/9] Regenerate session on login/logout to avoid session fixation --- formwork/src/Authentication/Authenticator.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/formwork/src/Authentication/Authenticator.php b/formwork/src/Authentication/Authenticator.php index beff9fc03..d59ba37b5 100644 --- a/formwork/src/Authentication/Authenticator.php +++ b/formwork/src/Authentication/Authenticator.php @@ -47,6 +47,7 @@ public function login( throw $e; } + $this->session->regenerate(); $this->session->set(self::SESSION_LOGGED_USER_KEY, $user->username()); $user->set('lastAccess', time()); @@ -72,6 +73,7 @@ public function logout(): void throw new UserNotLoggedException('Cannot logout, no user is logged in'); } $this->session->remove(self::SESSION_LOGGED_USER_KEY); + $this->session->regenerate(); } public function getUser(): ?User From 37615a35b3fe3a240b871e0bcd95b3e155d3defa Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Sat, 3 Jan 2026 17:05:51 +0100 Subject: [PATCH 5/9] Update documentation --- formwork/src/Authentication/Authenticator.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/formwork/src/Authentication/Authenticator.php b/formwork/src/Authentication/Authenticator.php index d59ba37b5..98c255f3a 100644 --- a/formwork/src/Authentication/Authenticator.php +++ b/formwork/src/Authentication/Authenticator.php @@ -76,6 +76,9 @@ public function logout(): void $this->session->regenerate(); } + /** + * Get currently logged in user if any + */ public function getUser(): ?User { $username = $this->session->get(self::SESSION_LOGGED_USER_KEY); From 83b517571df1e7f26176c9361cc0f2f8972dc413 Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Sat, 3 Jan 2026 17:20:19 +0100 Subject: [PATCH 6/9] Do not register further attempts if rate limit is reached --- formwork/src/Authentication/RateLimiter.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/formwork/src/Authentication/RateLimiter.php b/formwork/src/Authentication/RateLimiter.php index ff5e5ac61..0490783df 100644 --- a/formwork/src/Authentication/RateLimiter.php +++ b/formwork/src/Authentication/RateLimiter.php @@ -66,6 +66,10 @@ public function registerAttempt(): void if (isset($this->lastAttemptTime) && time() - $this->lastAttemptTime > $this->resetTime) { $this->resetAttempts(); } + if ($this->hasReachedLimit()) { + // Do not register further attempts if limit is reached + return; + } $this->registry->set($this->attemptHash, [++$this->attempts, time()]); } From b327953938d6828c456f46a4e027408c560d3eff Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Sat, 3 Jan 2026 23:00:07 +0100 Subject: [PATCH 7/9] Reset attempts on successful login --- formwork/src/Authentication/Authenticator.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/formwork/src/Authentication/Authenticator.php b/formwork/src/Authentication/Authenticator.php index 98c255f3a..2bf6c4132 100644 --- a/formwork/src/Authentication/Authenticator.php +++ b/formwork/src/Authentication/Authenticator.php @@ -53,6 +53,8 @@ public function login( $user->set('lastAccess', time()); $user->save(); + $this->rateLimiter->resetAttempts(); + return $user; } From aac97eb5660e810b71fec3a23b4ade5cf9c3a037 Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Sat, 3 Jan 2026 23:00:48 +0100 Subject: [PATCH 8/9] Remove after session regeneration to ensure no logged user key persists --- formwork/src/Authentication/Authenticator.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/formwork/src/Authentication/Authenticator.php b/formwork/src/Authentication/Authenticator.php index 2bf6c4132..d84f1d542 100644 --- a/formwork/src/Authentication/Authenticator.php +++ b/formwork/src/Authentication/Authenticator.php @@ -74,8 +74,8 @@ public function logout(): void if (!$this->isLoggedIn()) { throw new UserNotLoggedException('Cannot logout, no user is logged in'); } - $this->session->remove(self::SESSION_LOGGED_USER_KEY); $this->session->regenerate(); + $this->session->remove(self::SESSION_LOGGED_USER_KEY); } /** From cacb26fba8f59d2cb3ee572639d2c21f948267a2 Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Sat, 3 Jan 2026 23:27:48 +0100 Subject: [PATCH 9/9] Ensure rate limiter is reset even on user update failure --- formwork/src/Authentication/Authenticator.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/formwork/src/Authentication/Authenticator.php b/formwork/src/Authentication/Authenticator.php index d84f1d542..96b1696a1 100644 --- a/formwork/src/Authentication/Authenticator.php +++ b/formwork/src/Authentication/Authenticator.php @@ -47,14 +47,14 @@ public function login( throw $e; } + $this->rateLimiter->resetAttempts(); + $this->session->regenerate(); $this->session->set(self::SESSION_LOGGED_USER_KEY, $user->username()); $user->set('lastAccess', time()); $user->save(); - $this->rateLimiter->resetAttempts(); - return $user; }