diff --git a/application/Espo/Core/Api/Auth.php b/application/Espo/Core/Api/Auth.php index 127e244a16..9f88f8a6e3 100644 --- a/application/Espo/Core/Api/Auth.php +++ b/application/Espo/Core/Api/Auth.php @@ -31,6 +31,7 @@ namespace Espo\Core\Api; use Espo\Core\Exceptions\BadRequest; use Espo\Core\Exceptions\ServiceUnavailable; +use Espo\Core\Exceptions\Forbidden; use Espo\Core\Api\Request; use Espo\Core\Api\Response; @@ -202,7 +203,7 @@ class Auth if ( $e instanceof BadRequest || $e instanceof ServiceUnavailable || - $e instanceof BadRequest + $e instanceof Forbidden ) { $reason = $e->getMessage(); diff --git a/application/Espo/Core/Authentication/Authentication.php b/application/Espo/Core/Authentication/Authentication.php index 50e24bde86..abe367c9c7 100644 --- a/application/Espo/Core/Authentication/Authentication.php +++ b/application/Espo/Core/Authentication/Authentication.php @@ -48,6 +48,7 @@ use Espo\Core\Authentication\{ AuthToken\AuthTokenManager, AuthToken\AuthTokenData, AuthToken\AuthToken, + Hook\Manager as HookManager, }; use Espo\Core\{ @@ -59,13 +60,13 @@ use Espo\Core\{ Utils\Log, }; -use DateTime; - /** * Handles authentication. The entry point of the auth process. */ class Authentication { + private const LOGOUT_USERNAME = '**logout'; + private $allowAnyAccess; private $portal; @@ -82,6 +83,8 @@ class Authentication private $auth2FAFactory; + private $hookManager; + private $log; public function __construct( @@ -92,6 +95,7 @@ class Authentication LoginFactory $authLoginFactory, TwoFAFactory $auth2FAFactory, AuthTokenManager $authTokenManager, + HookManager $hookManager, Log $log, bool $allowAnyAccess = false ) { @@ -99,12 +103,12 @@ class Authentication $this->applicationUser = $applicationUser; $this->applicationState = $applicationState; - $this->configDataProvider = $configDataProvider; $this->entityManager = $entityManagerProxy; $this->authLoginFactory = $authLoginFactory; $this->auth2FAFactory = $auth2FAFactory; $this->authTokenManager = $authTokenManager; + $this->hookManager = $hookManager; $this->log = $log; } @@ -130,24 +134,23 @@ class Authentication "AUTH: Trying to use not allowed authentication method '{$authenticationMethod}'." ); - return Result::fail('Not allowed authentication method'); + return $this->processFail( + Result::fail(FailReason::METHOD_NOT_ALLOWED), + $data, + $request + ); } - $isByTokenOnly = !$authenticationMethod && $request->getHeader('Espo-Authorization-By-Token') === 'true'; - - if (!$isByTokenOnly) { - $this->checkFailedAttemptsLimit($request); - } - - $authToken = null; - $authTokenIsFound = false; + $this->hookManager->processBeforeLogin($data, $request); if (!$authenticationMethod && $password === null) { $this->log->error("AUTH: Trying to login w/o password."); - return Result::fail('No password'); + return Result::fail(FailReason::NO_PASSWORD); } + $authToken = null; + if (!$authenticationMethod) { $authToken = $this->authTokenManager->get($password); } @@ -160,9 +163,7 @@ class Authentication } } - if ($authToken) { - $authTokenIsFound = true; - } + $authTokenIsFound = $authToken !== null; if ($authToken && !$authToken->isActive()) { $authToken = null; @@ -172,10 +173,12 @@ class Authentication $authTokenCheckResult = $this->processAuthTokenCheck($authToken); if (!$authTokenCheckResult) { - return Result::fail('Denied'); + return Result::fail(FailReason::DENIED); } } + $isByTokenOnly = !$authenticationMethod && $request->getHeader('Espo-Authorization-By-Token') === 'true'; + if ($isByTokenOnly && !$authToken) { if ($username) { $this->log->info( @@ -183,7 +186,11 @@ class Authentication ); } - return Result::fail('Token not found'); + return $this->processFail( + Result::fail(FailReason::TOKEN_NOT_FOUND), + $data, + $request + ); } if (!$authenticationMethod) { @@ -210,11 +217,20 @@ class Authentication } if ($result->isFail()) { - return $result; + return $this->processFail( + $result, + $data, + $request + ); } if (!$user) { - return Result::fail(); + // Supposed not to ever happen. + return $this->processFail( + Result::fail(FailReason::USER_NOT_FOUND), + $data, + $request + ); } if (!$user->isAdmin() && $this->configDataProvider->isMaintenanceMode()) { @@ -222,7 +238,11 @@ class Authentication } if (!$this->processUserCheck($user, $authLogRecord)) { - return Result::fail('Denied'); + return $this->processFail( + Result::fail(FailReason::DENIED), + $data, + $request + ); } if ($this->isPortal()) { @@ -245,7 +265,11 @@ class Authentication $result = $this->processTwoFactor($result, $request); if ($result->isFail()) { - return $result; + return $this->processFail( + $result, + $data, + $request + ); } } @@ -407,7 +431,7 @@ class Authentication if ($code) { if (!$impl->verifyCode($loggedUser, $code)) { - return Result::fail('Code not verified'); + return Result::fail(FailReason::CODE_NOT_VERIFIED); } return $result; @@ -441,47 +465,6 @@ class Authentication return $method; } - private function checkFailedAttemptsLimit(Request $request): void - { - $failedAttemptsPeriod = $this->configDataProvider->getFailedAttemptsPeriod(); - $maxFailedAttempts = $this->configDataProvider->getMaxFailedAttemptNumber(); - - $requestTime = intval($request->getServerParam('REQUEST_TIME_FLOAT')); - - $requestTimeFrom = (new DateTime('@' . $requestTime))->modify('-' . $failedAttemptsPeriod); - - $failAttemptCount = 0; - - $ip = $request->getServerParam('REMOTE_ADDR'); - - $where = [ - 'requestTime>' => $requestTimeFrom->format('U'), - 'ipAddress' => $ip, - 'isDenied' => true, - ]; - - $wasFailed = (bool) $this->entityManager - ->getRDBRepository('AuthLogRecord') - ->select(['id']) - ->where($where) - ->findOne(); - - if ($wasFailed) { - $failAttemptCount = $this->entityManager - ->getRDBRepository('AuthLogRecord') - ->where($where) - ->count(); - } - - if ($failAttemptCount > $maxFailedAttempts) { - $this->log->warning( - "AUTH: Max failed login attempts exceeded for IP '{$ip}'." - ); - - throw new Forbidden("Max failed login attempts exceeded."); - } - } - private function createAuthToken(User $user, Request $request, Response $response): AuthToken { $createSecret = $request->getHeader('Espo-Authorization-Create-Token-Secret') === 'true'; @@ -560,7 +543,7 @@ class Authentication ?string $authenticationMethod = null ): ?AuthLogRecord { - if ($username === '**logout') { + if ($username === self::LOGOUT_USERNAME) { return null; } @@ -627,4 +610,11 @@ class Authentication $response->addHeader('Set-Cookie', $headerValue); } + + private function processFail(Result $result, AuthenticationData $data, Request $request): Result + { + $this->hookManager->processOnFail($result, $data, $request); + + return $result; + } } diff --git a/application/Espo/Core/Authentication/FailReason.php b/application/Espo/Core/Authentication/FailReason.php new file mode 100644 index 0000000000..86453669d8 --- /dev/null +++ b/application/Espo/Core/Authentication/FailReason.php @@ -0,0 +1,51 @@ +configDataProvider = $configDataProvider; + $this->entityManager = $entityManager; + $this->log = $log; + } + + public function process(AuthenticationData $data, Request $request): void + { + $isByTokenOnly = !$data->getMethod() && $request->getHeader('Espo-Authorization-By-Token') === 'true'; + + if ($isByTokenOnly) { + return; + } + + $failedAttemptsPeriod = $this->configDataProvider->getFailedAttemptsPeriod(); + $maxFailedAttempts = $this->configDataProvider->getMaxFailedAttemptNumber(); + + $requestTime = intval($request->getServerParam('REQUEST_TIME_FLOAT')); + + $requestTimeFrom = (new DateTime('@' . $requestTime))->modify('-' . $failedAttemptsPeriod); + + $ip = $request->getServerParam('REMOTE_ADDR'); + + $where = [ + 'requestTime>' => $requestTimeFrom->format('U'), + 'ipAddress' => $ip, + 'isDenied' => true, + ]; + + $wasFailed = (bool) $this->entityManager + ->getRDBRepository(AuthLogRecord::ENTITY_TYPE) + ->select(['id']) + ->where($where) + ->findOne(); + + if (!$wasFailed) { + return; + } + + $failAttemptCount = $this->entityManager + ->getRDBRepository(AuthLogRecord::ENTITY_TYPE) + ->where($where) + ->count(); + + if ($failAttemptCount <= $maxFailedAttempts) { + return; + } + + $this->log->warning("AUTH: Max failed login attempts exceeded for IP '{$ip}'."); + + throw new Forbidden("Max failed login attempts exceeded."); + } +} diff --git a/application/Espo/Core/Authentication/Hook/Manager.php b/application/Espo/Core/Authentication/Hook/Manager.php new file mode 100644 index 0000000000..cf7688e42b --- /dev/null +++ b/application/Espo/Core/Authentication/Hook/Manager.php @@ -0,0 +1,102 @@ +metadata = $metadata; + $this->injectableFactory = $injectableFactory; + } + + public function processBeforeLogin(AuthenticationData $data, Request $request): void + { + foreach ($this->getBeforeLoginHookList() as $hook) { + $hook->process($data, $request); + } + } + + public function processOnFail(Result $result, AuthenticationData $data, Request $request): void + { + foreach ($this->getOnFailHookList() as $hook) { + $hook->process($result, $data, $request); + } + } + + /** + * @return string[] + */ + private function getHookClassNameList(string $type): array + { + $key = $type . 'HookClassNameList'; + + return $this->metadata->get(['app', 'authentication', $key]) ?? []; + } + + /** + * @return BeforeLogin[] + */ + private function getBeforeLoginHookList(): array + { + $list = []; + + foreach ($this->getHookClassNameList('beforeLogin') as $className) { + $list[] = $this->injectableFactory->create($className); + } + + return $list; + } + + /** + * @return OnFail[] + */ + private function getOnFailHookList(): array + { + $list = []; + + foreach ($this->getHookClassNameList('onFail') as $className) { + $list[] = $this->injectableFactory->create($className); + } + + return $list; + } +} diff --git a/application/Espo/Core/Authentication/Hook/OnFail.php b/application/Espo/Core/Authentication/Hook/OnFail.php new file mode 100644 index 0000000000..b34d3705dc --- /dev/null +++ b/application/Espo/Core/Authentication/Hook/OnFail.php @@ -0,0 +1,42 @@ +userFinder->findApiApiKey($apiKey); if (!$user) { - return Result::fail('User not found'); + return Result::fail(FailReason::WRONG_CREDENTIALS); } return Result::success($user); diff --git a/application/Espo/Core/Authentication/Login/Espo.php b/application/Espo/Core/Authentication/Login/Espo.php index 58803f2c44..e694614c47 100644 --- a/application/Espo/Core/Authentication/Login/Espo.php +++ b/application/Espo/Core/Authentication/Login/Espo.php @@ -36,6 +36,7 @@ use Espo\Core\{ Authentication\LoginData, Authentication\Result, Authentication\Helpers\UserFinder, + Authentication\FailReason, }; class Espo implements Login @@ -57,7 +58,7 @@ class Espo implements Login $authToken = $loginData->getAuthToken(); if (!$password) { - return Result::fail('Empty password'); + return Result::fail(FailReason::NO_PASSWORD); } $hash = $authToken ? @@ -67,11 +68,11 @@ class Espo implements Login $user = $this->userFinder->find($username, $hash); if (!$user) { - return Result::fail('Wrong credentials'); + return Result::fail(FailReason::WRONG_CREDENTIALS); } if ($authToken && $user->id !== $authToken->getUserId()) { - return Result::fail('User and token mismatch'); + return Result::fail(FailReason::USER_TOKEN_MISMATCH); } return Result::success($user); diff --git a/application/Espo/Core/Authentication/Login/Hmac.php b/application/Espo/Core/Authentication/Login/Hmac.php index 7134799859..d76271792f 100644 --- a/application/Espo/Core/Authentication/Login/Hmac.php +++ b/application/Espo/Core/Authentication/Login/Hmac.php @@ -37,6 +37,7 @@ use Espo\Core\{ Authentication\Result, Authentication\Helpers\UserFinder, Exceptions\Error, + Authentication\FailReason, }; class Hmac implements Login @@ -60,13 +61,13 @@ class Hmac implements Login $user = $this->userFinder->findApiHmac($apiKey); if (!$user) { - return Result::fail('User not found'); + return Result::fail(FailReason::WRONG_CREDENTIALS); } - $secretKey = $this->apiKeyUtil->getSecretKeyForUserId($user->id); + $secretKey = $this->apiKeyUtil->getSecretKeyForUserId($user->getId()); if (!$secretKey) { - throw new Error("No secret key for API user '" . $user->id . "'."); + throw new Error("No secret key for API user '" . $user->getId() . "'."); } $string = $request->getMethod() . ' ' . $request->getResourcePath(); @@ -75,6 +76,6 @@ class Hmac implements Login return Result::success($user); } - return Result::fail('Hash not matched'); + return Result::fail(FailReason::HASH_NOT_MATCHED); } } diff --git a/application/Espo/Core/Authentication/Login/LDAP.php b/application/Espo/Core/Authentication/Login/LDAP.php index 0a0618c9ef..16aa627a63 100644 --- a/application/Espo/Core/Authentication/Login/LDAP.php +++ b/application/Espo/Core/Authentication/Login/LDAP.php @@ -42,6 +42,7 @@ use Espo\Core\{ Authentication\LDAP\Utils as LDAPUtils, Authentication\LDAP\Client as LDAPClient, Authentication\AuthToken\AuthToken, + Authentication\FailReason, }; use Exception; @@ -121,12 +122,12 @@ class LDAP implements Login return Result::success($user); } else { - return Result::fail(); + return Result::fail(FailReason::WRONG_CREDENTIALS); } } if (!$password || $username == '**logout') { - return Result::fail(); + return Result::fail(FailReason::NO_PASSWORD); } if ($isPortal) { @@ -211,7 +212,7 @@ class LDAP implements Login "LDAP: Authentication success for user {$username}, but user is not created in EspoCRM." ); - return Result::fail(); + return Result::fail(FailReason::USER_NOT_FOUND); } $userData = $ldapClient->getEntry($userDn); diff --git a/application/Espo/Resources/metadata/app/authentication.json b/application/Espo/Resources/metadata/app/authentication.json new file mode 100644 index 0000000000..11f57444c9 --- /dev/null +++ b/application/Espo/Resources/metadata/app/authentication.json @@ -0,0 +1,6 @@ +{ + "beforeLoginHookClassNameList": [ + "Espo\\Core\\Authentication\\Hook\\Hooks\\FailedAttemptsLimit" + ], + "onFailHookClassNameList": [] +} diff --git a/application/Espo/Resources/metadata/app/metadata.json b/application/Espo/Resources/metadata/app/metadata.json index e9be2be546..ed2505598b 100644 --- a/application/Espo/Resources/metadata/app/metadata.json +++ b/application/Espo/Resources/metadata/app/metadata.json @@ -12,6 +12,7 @@ ["app", "templateHelpers"], ["app", "appParams"], ["app", "cleanup"], + ["app", "authentication"], ["app", "pdfEngines", "__ANY__", "implementationClassNameMap"], ["app", "addressFormats", "__ANY__", "formatterClassName"], ["app", "auth2FAMethods", "__ANY__", "implementationClassName"],