diff --git a/application/Espo/Core/Authentication/ConfigDataProvider.php b/application/Espo/Core/Authentication/ConfigDataProvider.php index 4b57af6dae..e0e5d47831 100644 --- a/application/Espo/Core/Authentication/ConfigDataProvider.php +++ b/application/Espo/Core/Authentication/ConfigDataProvider.php @@ -39,6 +39,8 @@ class ConfigDataProvider private const FAILED_ATTEMPTS_PERIOD = '60 seconds'; private const FAILED_CODE_ATTEMPTS_PERIOD = '5 minutes'; private const MAX_FAILED_ATTEMPT_NUMBER = 10; + private const int MAX_USERNAME_FAILED_ATTEMPT_NUMBER = 30; + private const int USERNAME_FAILED_ATTEMPT_DELAY = 2; public function __construct(private Config $config, private Metadata $metadata) {} @@ -67,6 +69,24 @@ class ConfigDataProvider return $this->config->get('authMaxFailedAttemptNumber', self::MAX_FAILED_ATTEMPT_NUMBER); } + /** + * Max failed log in attempts for a specific username regardless of the IP address. + */ + public function getMaxUsernameFailedAttemptNumber(): int + { + return $this->config->get('authMaxUsernameFailedAttemptNumber', self::MAX_USERNAME_FAILED_ATTEMPT_NUMBER); + } + + public function isUsernameFailedAttemptsLimitEnabled(): bool + { + return (bool) $this->config->get('authUsernameFailedAttemptsLimitEnabled'); + } + + public function isUsernameFailedAttemptsDelay(): int + { + return $this->config->get('authUsernameFailedAttemptsDelay', self::USERNAME_FAILED_ATTEMPT_DELAY); + } + /** * Auth token secret won't be created. Can be reasonable for a custom AuthTokenManager implementation. */ diff --git a/application/Espo/Core/Authentication/Hook/Hooks/UsernameFailedAttemptsLimit.php b/application/Espo/Core/Authentication/Hook/Hooks/UsernameFailedAttemptsLimit.php new file mode 100644 index 0000000000..be5428bbfd --- /dev/null +++ b/application/Espo/Core/Authentication/Hook/Hooks/UsernameFailedAttemptsLimit.php @@ -0,0 +1,130 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Authentication\Hook\Hooks; + +use DateTime; +use Espo\Core\Api\Request; +use Espo\Core\Api\Util; +use Espo\Core\Authentication\AuthenticationData; +use Espo\Core\Authentication\ConfigDataProvider; +use Espo\Core\Authentication\HeaderKey; +use Espo\Core\Authentication\Hook\BeforeLogin; +use Espo\Core\Authentication\Util\DelayUtil; +use Espo\Entities\AuthLogRecord; +use Espo\ORM\EntityManager; +use Espo\ORM\Name\Attribute; +use Exception; +use RuntimeException; + +/** + * @noinspection PhpUnused + */ +class UsernameFailedAttemptsLimit implements BeforeLogin +{ + public function __construct( + private ConfigDataProvider $configDataProvider, + private EntityManager $entityManager, + private Util $util, + private DelayUtil $delayUtil, + ) {} + + public function process(AuthenticationData $data, Request $request): void + { + $isByTokenOnly = !$data->getMethod() && $request->getHeader(HeaderKey::AUTHORIZATION_BY_TOKEN) === 'true'; + + if ( + $isByTokenOnly || + $this->configDataProvider->isAuthLogDisabled() || + !$this->configDataProvider->isUsernameFailedAttemptsLimitEnabled() || + $data->getUsername() === null + ) { + return; + } + + $failedAttemptsPeriod = $this->configDataProvider->getFailedAttemptsPeriod(); + $delay = $this->configDataProvider->isUsernameFailedAttemptsDelay(); + + $ipAddress = $this->util->obtainIpFromRequest($request); + + $repo = $this->entityManager->getRDBRepositoryByClass(AuthLogRecord::class); + + $where = [ + 'username' => $data->getUsername(), + 'requestTime>' => $this->getTimeFrom($request, $failedAttemptsPeriod)->format('U'), + 'isDenied' => true, + ]; + + $wasFailed = (bool) $repo + ->where($where) + ->findOne(); + + if (!$wasFailed) { + return; + } + + $failAttemptCount = $repo + ->where($where) + ->count(); + + if ($failAttemptCount < $this->configDataProvider->getMaxUsernameFailedAttemptNumber()) { + return; + } + + if ( + // Prevent blocking for an IP address that has been logged in before. + $ipAddress !== null && + $repo + ->select([Attribute::ID]) + ->where([ + 'username' => $data->getUsername(), + 'ipAddress' => $ipAddress, + 'isDenied' => false, + ]) + ->findOne() + ) { + return; + } + + $this->delayUtil->delay($delay * 1000); + } + + private function getTimeFrom(Request $request, string $failedAttemptsPeriod): DateTime + { + $requestTime = intval($request->getServerParam('REQUEST_TIME_FLOAT')); + + try { + $requestTimeFrom = (new DateTime('@' . $requestTime))->modify('-' . $failedAttemptsPeriod); + } catch (Exception $e) { + throw new RuntimeException($e->getMessage()); + } + + return $requestTimeFrom; + } +} diff --git a/application/Espo/Core/Authentication/Util/DelayUtil.php b/application/Espo/Core/Authentication/Util/DelayUtil.php new file mode 100644 index 0000000000..19d89036c6 --- /dev/null +++ b/application/Espo/Core/Authentication/Util/DelayUtil.php @@ -0,0 +1,38 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Authentication\Util; + +class DelayUtil +{ + public function delay(int $milliseconds): void + { + sleep($milliseconds * 1000); + } +} diff --git a/application/Espo/Resources/defaults/systemConfig.php b/application/Espo/Resources/defaults/systemConfig.php index 0cfa7b97aa..21ed4fdad5 100644 --- a/application/Espo/Resources/defaults/systemConfig.php +++ b/application/Espo/Resources/defaults/systemConfig.php @@ -106,6 +106,9 @@ return [ 'authFailedAttemptsPeriod', 'authFailedCodeAttemptsPeriod', 'authMaxFailedAttemptNumber', + 'authMaxUsernameFailedAttemptNumber', + 'authUsernameFailedAttemptsLimitEnabled', + 'authUsernameFailedAttemptsDelay', 'ipAddressServerParam', 'jobNoTableLocking', 'passwordRecoveryRequestLifetime', diff --git a/application/Espo/Resources/metadata/app/authentication.json b/application/Espo/Resources/metadata/app/authentication.json index 50ce40c273..8484c6f92f 100644 --- a/application/Espo/Resources/metadata/app/authentication.json +++ b/application/Espo/Resources/metadata/app/authentication.json @@ -1,6 +1,7 @@ { "beforeLoginHookClassNameList": [ "Espo\\Core\\Authentication\\Hook\\Hooks\\FailedAttemptsLimit", + "Espo\\Core\\Authentication\\Hook\\Hooks\\UsernameFailedAttemptsLimit", "Espo\\Core\\Authentication\\Hook\\Hooks\\FailedCodeAttemptsLimit" ], "onLoginHookClassNameList": [ diff --git a/application/Espo/Resources/metadata/entityDefs/AuthLogRecord.json b/application/Espo/Resources/metadata/entityDefs/AuthLogRecord.json index f1df7eef9d..a2131bb4fb 100644 --- a/application/Espo/Resources/metadata/entityDefs/AuthLogRecord.json +++ b/application/Espo/Resources/metadata/entityDefs/AuthLogRecord.json @@ -105,6 +105,9 @@ }, "requestTime": { "columns": ["requestTime"] + }, + "usernameIpAddress": { + "columns": ["username", "ipAddress"] } }, "hooksDisabled": true diff --git a/tests/integration/Espo/Core/Authentication/FailedLoginAttemptsTest.php b/tests/integration/Espo/Core/Authentication/FailedLoginAttemptsTest.php new file mode 100644 index 0000000000..1c5fefa125 --- /dev/null +++ b/tests/integration/Espo/Core/Authentication/FailedLoginAttemptsTest.php @@ -0,0 +1,120 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace tests\integration\Espo\Core\Authentication; + +use Espo\Core\Api\RequestWrapper; +use Espo\Core\Api\Response; +use Espo\Core\Authentication\Authentication; +use Espo\Core\Authentication\AuthenticationData; +use Espo\Core\Authentication\HeaderKey; +use Espo\Core\Authentication\Util\DelayUtil; +use Espo\Core\Binding\Binder; +use Espo\Core\Binding\BindingProcessor; +use Espo\Core\Utils\Config\ConfigWriter; +use Slim\Psr7\Factory\ServerRequestFactory; +use tests\integration\Core\BaseTestCase; + +class FailedLoginAttemptsTest extends BaseTestCase +{ + /** + * @noinspection PhpUnhandledExceptionInspection + */ + public function testUsernameFailedLogin(): void + { + $delay = 5; + + $configWriter = $this->getInjectableFactory()->create(ConfigWriter::class); + $configWriter->setMultiple([ + 'authUsernameFailedAttemptsLimitEnabled' => true, + 'authUsernameFailedAttemptsDelay' => $delay, + 'authMaxUsernameFailedAttemptNumber' => 3, + ]); + $configWriter->save(); + + $delayUtil = $this->createMock(DelayUtil::class); + + $app = $this->createApplication( + binding: new class ($delayUtil) implements BindingProcessor { + + public function __construct(private DelayUtil $delayUtil) {} + + public function process(Binder $binder): void + { + $binder->bindInstance(DelayUtil::class, $this->delayUtil); + } + }, + ); + $this->setApplication($app); + + $delayUtil->expects($this->once()) + ->method('delay') + ->with($delay * 1000); + + $username = 'test'; + + $data = AuthenticationData::create() + ->withUsername($username) + ->withPassword('1'); + + $time = microtime(true); + + $authentication = $this->getInjectableFactory()->create(Authentication::class); + + $request = $this->createApiRequest($time, '1.0.0.1', $username); + $response = $this->createMock(Response::class); + $authentication->login($data, $request, $response); + + $request = $this->createApiRequest($time, '1.0.0.2', $username); + $response = $this->createMock(Response::class); + $authentication->login($data, $request, $response); + + $request = $this->createApiRequest($time, '1.0.0.3', $username); + $response = $this->createMock(Response::class); + $authentication->login($data, $request, $response); + + $request = $this->createApiRequest($time, '1.0.0.4', $username); + $response = $this->createMock(Response::class); + $authentication->login($data, $request, $response); + } + + private function createApiRequest(float $time, string $ipAddress, string $username): RequestWrapper + { + $authorization = 'Basic ' . base64_encode($username . ':1'); + + $request = (new ServerRequestFactory())->createServerRequest('POST', 'http://localhost/api/v1/App/user', [ + 'REMOTE_ADDR' => $ipAddress, + 'REQUEST_TIME_FLOAT' => $time, + ]); + + $request = $request->withHeader(HeaderKey::AUTHORIZATION, $authorization); + + return new RequestWrapper($request); + } +}