auth hooks

This commit is contained in:
Yuri Kuznetsov
2021-09-03 14:13:18 +03:00
parent 768c68df49
commit 3c5dc1672a
13 changed files with 424 additions and 77 deletions
+2 -1
View File
@@ -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();
@@ -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;
}
}
@@ -0,0 +1,51 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM - Open Source CRM application.
* Copyright (C) 2014-2021 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
* Website: https://www.espocrm.com
*
* EspoCRM is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* EspoCRM is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with EspoCRM. If not, see http://www.gnu.org/licenses/.
*
* 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 General Public License version 3.
*
* In accordance with Section 7(b) of the GNU General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Authentication;
class FailReason
{
public const DENIED = 'Denied';
public const CODE_NOT_VERIFIED = 'Code not verified';
public const NO_PASSWORD = 'No password';
public const TOKEN_NOT_FOUND = 'Token not found';
public const USER_NOT_FOUND = 'User not found';
public const WRONG_CREDENTIALS = 'Wrong credentials';
public const USER_TOKEN_MISMATCH = 'User and token mismatch';
public const HASH_NOT_MATCHED = 'Hash not matched';
public const METHOD_NOT_ALLOWED = 'Not allowed authentication method';
}
@@ -0,0 +1,44 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM - Open Source CRM application.
* Copyright (C) 2014-2021 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
* Website: https://www.espocrm.com
*
* EspoCRM is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* EspoCRM is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with EspoCRM. If not, see http://www.gnu.org/licenses/.
*
* 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 General Public License version 3.
*
* In accordance with Section 7(b) of the GNU General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Authentication\Hook;
use Espo\Core\Authentication\AuthenticationData;
use Espo\Core\Api\Request;
/**
* Before logging in, before credentials are checked.
*
* @throws Forbidden
* @throws ServiceUnavailable
*/
interface BeforeLogin
{
public function process(AuthenticationData $data, Request $request): void;
}
@@ -0,0 +1,106 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM - Open Source CRM application.
* Copyright (C) 2014-2021 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
* Website: https://www.espocrm.com
*
* EspoCRM is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* EspoCRM is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with EspoCRM. If not, see http://www.gnu.org/licenses/.
*
* 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 General Public License version 3.
*
* In accordance with Section 7(b) of the GNU General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Authentication\Hook\Hooks;
use Espo\Core\Authentication\Hook\BeforeLogin;
use Espo\Core\Authentication\AuthenticationData;
use Espo\Core\Api\Request;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Authentication\ConfigDataProvider;
use Espo\ORM\EntityManager;
use Espo\Core\Utils\Log;
use Espo\Entities\AuthLogRecord;
use DateTime;
class FailedAttemptsLimit implements BeforeLogin
{
private $configDataProvider;
private $entityManager;
private $log;
public function __construct(ConfigDataProvider $configDataProvider, EntityManager $entityManager, Log $log)
{
$this->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.");
}
}
@@ -0,0 +1,102 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM - Open Source CRM application.
* Copyright (C) 2014-2021 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
* Website: https://www.espocrm.com
*
* EspoCRM is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* EspoCRM is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with EspoCRM. If not, see http://www.gnu.org/licenses/.
*
* 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 General Public License version 3.
*
* In accordance with Section 7(b) of the GNU General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Authentication\Hook;
use Espo\Core\Utils\Metadata;
use Espo\Core\InjectableFactory;
use Espo\Core\Authentication\AuthenticationData;
use Espo\Core\Api\Request;
use Espo\Core\Authentication\Result;
class Manager
{
private $metadata;
private $injectableFactory;
public function __construct(Metadata $metadata, InjectableFactory $injectableFactory)
{
$this->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;
}
}
@@ -0,0 +1,42 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM - Open Source CRM application.
* Copyright (C) 2014-2021 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
* Website: https://www.espocrm.com
*
* EspoCRM is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* EspoCRM is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with EspoCRM. If not, see http://www.gnu.org/licenses/.
*
* 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 General Public License version 3.
*
* In accordance with Section 7(b) of the GNU General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Authentication\Hook;
use Espo\Core\Authentication\AuthenticationData;
use Espo\Core\Api\Request;
use Espo\Core\Authentication\Result;
/**
* Once logging in is failed. Not fired when an exception is thrown.
*/
interface OnFail
{
public function process(Result $result, AuthenticationData $data, Request $request): void;
}
@@ -35,6 +35,7 @@ use Espo\Core\{
Authentication\LoginData,
Authentication\Result,
Authentication\Helpers\UserFinder,
Authentication\FailReason,
};
class ApiKey implements Login
@@ -53,7 +54,7 @@ class ApiKey implements Login
$user = $this->userFinder->findApiApiKey($apiKey);
if (!$user) {
return Result::fail('User not found');
return Result::fail(FailReason::WRONG_CREDENTIALS);
}
return Result::success($user);
@@ -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);
@@ -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);
}
}
@@ -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);
@@ -0,0 +1,6 @@
{
"beforeLoginHookClassNameList": [
"Espo\\Core\\Authentication\\Hook\\Hooks\\FailedAttemptsLimit"
],
"onFailHookClassNameList": []
}
@@ -12,6 +12,7 @@
["app", "templateHelpers"],
["app", "appParams"],
["app", "cleanup"],
["app", "authentication"],
["app", "pdfEngines", "__ANY__", "implementationClassNameMap"],
["app", "addressFormats", "__ANY__", "formatterClassName"],
["app", "auth2FAMethods", "__ANY__", "implementationClassName"],