Skip to content
8 changes: 6 additions & 2 deletions formwork/config/system.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
authentication:
registryPath: '${%ROOT_PATH%}/site/auth/registry/'
limits:
maxAttempts: 10
resetTime: 300

backup:
path: '${%ROOT_PATH%}/backup'
name: formwork-backup
Expand Down Expand Up @@ -91,8 +97,6 @@ panel:
root: panel
path: '${%ROOT_PATH%}/panel'
translation: en
loginAttempts: 10
loginResetTime: 300
logoutRedirect: login
userImageSize: 512
colorScheme: auto
Expand Down
89 changes: 89 additions & 0 deletions formwork/src/Authentication/Authenticator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<?php

namespace Formwork\Authentication;

use Formwork\Authentication\Exceptions\AuthenticationFailedException;
use Formwork\Authentication\Exceptions\RateLimitExceededException;
use Formwork\Authentication\Exceptions\UserNotLoggedException;
use Formwork\Http\Session\Session;
use Formwork\Users\User;
use Formwork\Users\Users;
use SensitiveParameter;

class Authenticator
{
public const string SESSION_LOGGED_USER_KEY = '_formwork_logged_user';

public function __construct(
protected Users $users,
protected Session $session,
protected RateLimiter $rateLimiter
) {}

/**
* Login a user with given credentials
*
* @throws AuthenticationFailedException If authentication fails
* @throws RateLimitExceededException If rate limit is exceeded
*/
public function login(
string $login,
#[SensitiveParameter]
string $password
): User {
try {
$this->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);
Copy link

Copilot AI Jan 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardcoded magic numbers 500 and 1000 are used for the delay. These should be extracted as named constants or configuration values to improve maintainability and make it clear what these values represent (e.g., MIN_DELAY_MS = 500, MAX_DELAY_MS = 1000).

Copilot uses AI. Check for mistakes.

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);
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?php

namespace Formwork\Users\Exceptions;
namespace Formwork\Authentication\Exceptions;

use RuntimeException;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

namespace Formwork\Authentication\Exceptions;

use RuntimeException;
use Throwable;

class RateLimitExceededException extends RuntimeException
{
/**
* @param string $message Exception message
* @param int $resetTime Time (in seconds) until the rate limit is reset
* @param int $code Exception code
*/
public function __construct(
string $message = '',
protected int $resetTime = 0,
int $code = 0,
?Throwable $previous = null
) {
parent::__construct($message, $code, $previous);
}

/**
* Get reset time
*/
public function getResetTime(): int
{
return $this->resetTime;
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?php

namespace Formwork\Users\Exceptions;
namespace Formwork\Authentication\Exceptions;

use RuntimeException;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
<?php

namespace Formwork\Panel\Security;
namespace Formwork\Authentication;

use Formwork\Authentication\Exceptions\RateLimitExceededException;
use Formwork\Http\Request;
use Formwork\Log\Registry;

final class AccessLimiter
final class RateLimiter
{
/**
* Hash which identifies the visitor which make attempts
* Hash which identifies the visitor making the access attempts
*/
private string $attemptHash;

Expand Down Expand Up @@ -36,14 +37,24 @@ public function __construct(
}
}

/**
* Assert that access attempts are allowed
*
* @throws RateLimitExceededException If attempts limit is reached
*/
public function assertAllowed(): void
{
$this->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;
}

Expand All @@ -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()]);
}

Expand All @@ -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;
}
}
8 changes: 8 additions & 0 deletions formwork/src/Cms/App.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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');
Expand Down
2 changes: 1 addition & 1 deletion formwork/src/Panel/Controllers/AbstractController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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),
];
}
}
Loading