diff --git a/formwork/config/system.yaml b/formwork/config/system.yaml index d3f038d1b..67eaad7fd 100644 --- a/formwork/config/system.yaml +++ b/formwork/config/system.yaml @@ -1,3 +1,9 @@ +authentication: + registryPath: '${%ROOT_PATH%}/site/auth/registry/' + limits: + maxAttempts: 10 + resetTime: 300 + 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..96b1696a1 --- /dev/null +++ b/formwork/src/Authentication/Authenticator.php @@ -0,0 +1,89 @@ +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->rateLimiter->resetAttempts(); + + $this->session->regenerate(); + $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->regenerate(); + $this->session->remove(self::SESSION_LOGGED_USER_KEY); + } + + /** + * Get currently logged in user if any + */ + 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,13 @@ public function hasReachedLimit(): bool */ 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()]); } @@ -63,4 +81,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..45cbe1b1f 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.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.limits.maxAttempts" instead.', E_USER_DEPRECATED); + } + + if ($this->config->has('system.panel.loginResetTime')) { + 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.limits.maxAttempts'))) + ->parameter('resetTime', $this->config->get('system.panel.loginResetTime', $this->config->get('system.authentication.limits.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); + } }