Initial commit
This commit is contained in:
67
application/Espo/Core/Authentication/AuthToken/AuthToken.php
Normal file
67
application/Espo/Core/Authentication/AuthToken/AuthToken.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\AuthToken;
|
||||
|
||||
/**
|
||||
* An auth token record.
|
||||
*/
|
||||
interface AuthToken
|
||||
{
|
||||
/**
|
||||
* Get a token.
|
||||
*/
|
||||
public function getToken(): string;
|
||||
|
||||
/**
|
||||
* Get a user ID.
|
||||
*/
|
||||
public function getUserId(): string;
|
||||
|
||||
/**
|
||||
* Get a portal ID. If a token belongs to a specific portal.
|
||||
*/
|
||||
public function getPortalId(): ?string;
|
||||
|
||||
/**
|
||||
* Get a token secret. Secret is used as an additional security check.
|
||||
*/
|
||||
public function getSecret(): ?string;
|
||||
|
||||
/**
|
||||
* Whether a token is active.
|
||||
*/
|
||||
public function isActive(): bool;
|
||||
|
||||
/**
|
||||
* Get a password hash. If a password hash is not stored in token, then return NULL.
|
||||
* If you store auth tokens remotely it's reasonable to avoid hashes being sent.
|
||||
*/
|
||||
public function getHash(): ?string;
|
||||
}
|
||||
118
application/Espo/Core/Authentication/AuthToken/Data.php
Normal file
118
application/Espo/Core/Authentication/AuthToken/Data.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\AuthToken;
|
||||
|
||||
use RuntimeException;
|
||||
use SensitiveParameter;
|
||||
|
||||
/**
|
||||
* An auth token data. Used for auth token creation.
|
||||
*
|
||||
* Immutable.
|
||||
*/
|
||||
class Data
|
||||
{
|
||||
private string $userId;
|
||||
private ?string $portalId = null;
|
||||
private ?string $hash = null;
|
||||
private ?string $ipAddress = null;
|
||||
private bool $createSecret = false;
|
||||
|
||||
private function __construct()
|
||||
{}
|
||||
|
||||
/**
|
||||
* A user ID.
|
||||
*/
|
||||
public function getUserId(): string
|
||||
{
|
||||
return $this->userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* A portal ID.
|
||||
*/
|
||||
public function getPortalId(): ?string
|
||||
{
|
||||
return $this->portalId;
|
||||
}
|
||||
|
||||
/**
|
||||
* A hash.
|
||||
*/
|
||||
public function getHash(): ?string
|
||||
{
|
||||
return $this->hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* An ID address.
|
||||
*/
|
||||
public function getIpAddress(): ?string
|
||||
{
|
||||
return $this->ipAddress;
|
||||
}
|
||||
|
||||
/**
|
||||
* To create a secret.
|
||||
*/
|
||||
public function toCreateSecret(): bool
|
||||
{
|
||||
return $this->createSecret;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* userId: string,
|
||||
* portalId?: ?string,
|
||||
* hash?: ?string,
|
||||
* ipAddress?: ?string,
|
||||
* createSecret?: ?bool,
|
||||
* } $data
|
||||
*/
|
||||
public static function create(#[SensitiveParameter] array $data): self
|
||||
{
|
||||
$obj = new self();
|
||||
|
||||
$userId = $data['userId'] ?? null;
|
||||
|
||||
if (!$userId) {
|
||||
throw new RuntimeException("No user ID.");
|
||||
}
|
||||
|
||||
$obj->userId = $userId;
|
||||
$obj->portalId = $data['portalId'] ?? null;
|
||||
$obj->hash = $data['hash'] ?? null;
|
||||
$obj->ipAddress = $data['ipAddress'] ?? null;
|
||||
$obj->createSecret = $data['createSecret'] ?? false;
|
||||
|
||||
return $obj;
|
||||
}
|
||||
}
|
||||
164
application/Espo/Core/Authentication/AuthToken/EspoManager.php
Normal file
164
application/Espo/Core/Authentication/AuthToken/EspoManager.php
Normal file
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\AuthToken;
|
||||
|
||||
use Espo\Core\Name\Field;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\ORM\Repository\RDBRepository;
|
||||
use Espo\Entities\AuthToken as AuthTokenEntity;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* A default auth token manager. Auth tokens are stored in database.
|
||||
* Consider creating a custom implementation if you need to store auth tokens
|
||||
* in another storage. E.g. a single Redis data store can be utilized with
|
||||
* multiple Espo replicas (for scalability purposes).
|
||||
* Defined at metadata > app > containerServices > authTokenManager.
|
||||
*
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
class EspoManager implements Manager
|
||||
{
|
||||
/** @var RDBRepository<AuthTokenEntity> */
|
||||
private RDBRepository $repository;
|
||||
|
||||
private const TOKEN_RANDOM_LENGTH = 16;
|
||||
|
||||
public function __construct(EntityManager $entityManager)
|
||||
{
|
||||
$this->repository = $entityManager->getRDBRepositoryByClass(AuthTokenEntity::class);
|
||||
}
|
||||
|
||||
public function get(string $token): ?AuthToken
|
||||
{
|
||||
return $this->repository
|
||||
->select([
|
||||
'id',
|
||||
'isActive',
|
||||
'token',
|
||||
'secret',
|
||||
'userId',
|
||||
'portalId',
|
||||
'hash',
|
||||
Field::CREATED_AT,
|
||||
'lastAccess',
|
||||
Field::MODIFIED_AT,
|
||||
])
|
||||
->where(['token' => $token])
|
||||
->findOne();
|
||||
}
|
||||
|
||||
public function create(Data $data): AuthToken
|
||||
{
|
||||
$authToken = $this->repository->getNew();
|
||||
|
||||
$authToken
|
||||
->setUserId($data->getUserId())
|
||||
->setPortalId($data->getPortalId())
|
||||
->setHash($data->getHash())
|
||||
->setIpAddress($data->getIpAddress())
|
||||
->setToken($this->generateToken())
|
||||
->setLastAccessNow();
|
||||
|
||||
if ($data->toCreateSecret()) {
|
||||
$authToken->setSecret($this->generateToken());
|
||||
}
|
||||
|
||||
$this->validate($authToken);
|
||||
|
||||
$this->repository->save($authToken);
|
||||
|
||||
return $authToken;
|
||||
}
|
||||
|
||||
public function inactivate(AuthToken $authToken): void
|
||||
{
|
||||
/** @noinspection PhpConditionAlreadyCheckedInspection */
|
||||
if (!$authToken instanceof AuthTokenEntity) {
|
||||
throw new RuntimeException();
|
||||
}
|
||||
|
||||
$this->validateNotChanged($authToken);
|
||||
|
||||
$authToken->setIsActive(false);
|
||||
|
||||
$this->repository->save($authToken);
|
||||
}
|
||||
|
||||
public function renew(AuthToken $authToken): void
|
||||
{
|
||||
/** @noinspection PhpConditionAlreadyCheckedInspection */
|
||||
if (!$authToken instanceof AuthTokenEntity) {
|
||||
throw new RuntimeException();
|
||||
}
|
||||
|
||||
$this->validateNotChanged($authToken);
|
||||
|
||||
if ($authToken->isNew()) {
|
||||
throw new RuntimeException("Can renew only not new auth token.");
|
||||
}
|
||||
|
||||
$authToken->setLastAccessNow();
|
||||
|
||||
$this->repository->save($authToken);
|
||||
}
|
||||
|
||||
private function validate(AuthToken $authToken): void
|
||||
{
|
||||
if (!$authToken->getToken()) {
|
||||
throw new RuntimeException("Empty token.");
|
||||
}
|
||||
|
||||
if (!$authToken->getUserId()) {
|
||||
throw new RuntimeException("Empty user ID.");
|
||||
}
|
||||
}
|
||||
|
||||
private function validateNotChanged(AuthTokenEntity $authToken): void
|
||||
{
|
||||
if (
|
||||
$authToken->isAttributeChanged('token') ||
|
||||
$authToken->isAttributeChanged('secret') ||
|
||||
$authToken->isAttributeChanged('hash') ||
|
||||
$authToken->isAttributeChanged('userId') ||
|
||||
$authToken->isAttributeChanged('portalId')
|
||||
) {
|
||||
throw new RuntimeException("Auth token was changed.");
|
||||
}
|
||||
}
|
||||
|
||||
private function generateToken(): string
|
||||
{
|
||||
$length = self::TOKEN_RANDOM_LENGTH;
|
||||
|
||||
return bin2hex(random_bytes($length));
|
||||
}
|
||||
}
|
||||
56
application/Espo/Core/Authentication/AuthToken/Manager.php
Normal file
56
application/Espo/Core/Authentication/AuthToken/Manager.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\AuthToken;
|
||||
|
||||
/**
|
||||
* Fetches and stores auth tokens.
|
||||
*/
|
||||
interface Manager
|
||||
{
|
||||
/**
|
||||
* Get an auth token. If it does not exist, then returns NULL.
|
||||
*/
|
||||
public function get(string $token): ?AuthToken;
|
||||
|
||||
/**
|
||||
* Create an auth token and store it.
|
||||
*/
|
||||
public function create(Data $data): AuthToken;
|
||||
|
||||
/**
|
||||
* Make an auth token inactive (invalid).
|
||||
*/
|
||||
public function inactivate(AuthToken $authToken): void;
|
||||
|
||||
/**
|
||||
* Update a last access date. An implementation can be omitted to avoid a writing operation.
|
||||
*/
|
||||
public function renew(AuthToken $authToken): void;
|
||||
}
|
||||
839
application/Espo/Core/Authentication/Authentication.php
Normal file
839
application/Espo/Core/Authentication/Authentication.php
Normal file
@@ -0,0 +1,839 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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;
|
||||
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Exceptions\NotFound;
|
||||
use Espo\Core\Name\Field;
|
||||
use Espo\Core\Utils\Language\LanguageProxy;
|
||||
use Espo\ORM\Name\Attribute;
|
||||
use Espo\Repositories\UserData as UserDataRepository;
|
||||
use Espo\Entities\Portal;
|
||||
use Espo\Entities\User;
|
||||
use Espo\Entities\AuthLogRecord;
|
||||
use Espo\Entities\AuthToken as AuthTokenEntity;
|
||||
use Espo\Entities\UserData;
|
||||
use Espo\Core\Exceptions\Error\Body;
|
||||
use Espo\Core\Authentication\Logout\Params as LogoutParams;
|
||||
use Espo\Core\Authentication\Util\MethodProvider;
|
||||
use Espo\Core\Authentication\Result\FailReason;
|
||||
use Espo\Core\Authentication\TwoFactor\LoginFactory as TwoFactorLoginFactory;
|
||||
use Espo\Core\Authentication\AuthToken\Manager as AuthTokenManager;
|
||||
use Espo\Core\Authentication\AuthToken\Data as AuthTokenData;
|
||||
use Espo\Core\Authentication\AuthToken\AuthToken;
|
||||
use Espo\Core\Authentication\Hook\Manager as HookManager;
|
||||
use Espo\Core\Authentication\Login\Data as LoginData;
|
||||
use Espo\Core\ApplicationUser;
|
||||
use Espo\Core\ApplicationState;
|
||||
use Espo\Core\Api\Request;
|
||||
use Espo\Core\Api\Response;
|
||||
use Espo\Core\Api\Util;
|
||||
use Espo\Core\Utils\Log;
|
||||
use Espo\Core\ORM\EntityManagerProxy;
|
||||
use Espo\Core\Exceptions\ServiceUnavailable;
|
||||
|
||||
use LogicException;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Handles authentication. The entry point of the auth process.
|
||||
*/
|
||||
class Authentication
|
||||
{
|
||||
private const LOGOUT_USERNAME = '**logout';
|
||||
|
||||
private const HEADER_CREATE_TOKEN_SECRET = 'Espo-Authorization-Create-Token-Secret';
|
||||
private const HEADER_ANOTHER_USER = 'X-Another-User';
|
||||
private const HEADER_LOGOUT_REDIRECT_URL = 'X-Logout-Redirect-Url';
|
||||
|
||||
private const COOKIE_AUTH_TOKEN_SECRET = 'auth-token-secret';
|
||||
|
||||
public function __construct(
|
||||
private ApplicationUser $applicationUser,
|
||||
private ApplicationState $applicationState,
|
||||
private ConfigDataProvider $configDataProvider,
|
||||
private EntityManagerProxy $entityManager,
|
||||
private LoginFactory $loginFactory,
|
||||
private TwoFactorLoginFactory $twoFactorLoginFactory,
|
||||
private AuthTokenManager $authTokenManager,
|
||||
private HookManager $hookManager,
|
||||
private Log $log,
|
||||
private LogoutFactory $logoutFactory,
|
||||
private MethodProvider $methodProvider,
|
||||
private Util $util,
|
||||
private LanguageProxy $language
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Process logging in.
|
||||
*
|
||||
* @throws ServiceUnavailable
|
||||
* @throws Forbidden
|
||||
*/
|
||||
public function login(AuthenticationData $data, Request $request, Response $response): Result
|
||||
{
|
||||
$username = $data->getUsername();
|
||||
$password = $data->getPassword();
|
||||
$method = $data->getMethod();
|
||||
$byTokenOnly = $data->byTokenOnly();
|
||||
|
||||
if (
|
||||
$method &&
|
||||
!$this->configDataProvider->authenticationMethodIsApi($method)
|
||||
) {
|
||||
$this->log->warning("Auth: Trying to use not allowed authentication method '{method}'.", [
|
||||
'method' => $method,
|
||||
]);
|
||||
|
||||
return $this->processFail(Result::fail(FailReason::METHOD_NOT_ALLOWED), $data, $request);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->hookManager->processBeforeLogin($data, $request);
|
||||
} catch (Forbidden $e) {
|
||||
$this->processForbidden($e);
|
||||
}
|
||||
|
||||
if (!$method && $password === null) {
|
||||
$this->log->error("Auth: Trying to login w/o password.");
|
||||
|
||||
return Result::fail(FailReason::NO_PASSWORD);
|
||||
}
|
||||
|
||||
$authToken = null;
|
||||
|
||||
if (!$method) {
|
||||
$authToken = $this->authTokenManager->get($password);
|
||||
}
|
||||
|
||||
if ($authToken && $authToken->getSecret()) {
|
||||
$sentSecret = $request->getCookieParam(self::COOKIE_AUTH_TOKEN_SECRET);
|
||||
|
||||
if ($sentSecret !== $authToken->getSecret()) {
|
||||
$authToken = null;
|
||||
}
|
||||
}
|
||||
|
||||
$authTokenIsFound = $authToken !== null;
|
||||
|
||||
if ($authToken && !$authToken->isActive()) {
|
||||
$authToken = null;
|
||||
}
|
||||
|
||||
if ($authToken) {
|
||||
$authTokenCheckResult = $this->processAuthTokenCheck($authToken);
|
||||
|
||||
if (!$authTokenCheckResult) {
|
||||
return Result::fail(FailReason::DENIED);
|
||||
}
|
||||
}
|
||||
|
||||
$byTokenAndUsername = $request->getHeader(HeaderKey::AUTHORIZATION_BY_TOKEN) === 'true';
|
||||
|
||||
if ($method && $byTokenAndUsername) {
|
||||
return Result::fail(FailReason::DISCREPANT_DATA);
|
||||
}
|
||||
|
||||
if (($byTokenAndUsername || $byTokenOnly) && !$authToken) {
|
||||
if ($username) {
|
||||
$this->log->info("Auth: Trying to login as user '{username}' by token but token is not found.", [
|
||||
'username' => $username,
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->processFail(Result::fail(FailReason::TOKEN_NOT_FOUND), $data, $request);
|
||||
}
|
||||
|
||||
if ($byTokenOnly) {
|
||||
assert($authToken !== null);
|
||||
|
||||
$username = $this->getUsernameByAuthToken($authToken);
|
||||
|
||||
if (!$username) {
|
||||
return $this->processFail(Result::fail(FailReason::USER_NOT_FOUND), $data, $request);
|
||||
}
|
||||
}
|
||||
|
||||
$method ??= $this->methodProvider->get();
|
||||
|
||||
$login = $this->loginFactory->create($method, $this->isPortal());
|
||||
|
||||
$loginData = LoginData
|
||||
::createBuilder()
|
||||
->setUsername($username)
|
||||
->setPassword($password)
|
||||
->setAuthToken($authToken)
|
||||
->build();
|
||||
|
||||
$result = $login->login($loginData, $request);
|
||||
|
||||
$user = $result->getUser();
|
||||
|
||||
$authLogRecord = !$authTokenIsFound ?
|
||||
$this->createAuthLogRecord($username, $user, $request, $method) :
|
||||
null;
|
||||
|
||||
if ($result->isFail()) {
|
||||
return $this->processFail($result, $data, $request);
|
||||
}
|
||||
|
||||
if (!$user) {
|
||||
// Supposed not to ever happen.
|
||||
return $this->processFail(Result::fail(FailReason::USER_NOT_FOUND), $data, $request);
|
||||
}
|
||||
|
||||
if (!$user->isAdmin() && $this->configDataProvider->isMaintenanceMode()) {
|
||||
throw ServiceUnavailable::createWithBody(
|
||||
"Application is in maintenance mode.",
|
||||
Body::create()
|
||||
->withMessage($this->language->translateLabel('maintenanceModeError', 'messages'))
|
||||
);
|
||||
}
|
||||
|
||||
if (!$this->processUserCheck($user, $authLogRecord)) {
|
||||
return $this->processFail(Result::fail(FailReason::DENIED), $data, $request);
|
||||
}
|
||||
|
||||
$this->prepareUser($user, $request);
|
||||
|
||||
[$loggedUser, $anotherUserFailReason] = $this->getLoggedUser($request, $user);
|
||||
|
||||
if (!$loggedUser) {
|
||||
$anotherUserFailReason = $anotherUserFailReason ?? FailReason::ANOTHER_USER_NOT_FOUND;
|
||||
|
||||
return $this->processFail(Result::fail($anotherUserFailReason), $data, $request);
|
||||
}
|
||||
|
||||
$this->applicationUser->setUser($loggedUser);
|
||||
|
||||
if (
|
||||
!$result->bypassSecondStep() &&
|
||||
!$result->isSecondStepRequired() &&
|
||||
!$authToken &&
|
||||
$this->configDataProvider->isTwoFactorEnabled()
|
||||
) {
|
||||
$result = $this->processTwoFactor($result, $request);
|
||||
|
||||
if ($result->isFail()) {
|
||||
return $this->processTwoFactorFail($result, $data, $request, $authLogRecord);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$this->hookManager->processOnLogin($result, $data, $request);
|
||||
} catch (Forbidden $e) {
|
||||
$this->processForbidden($e, $authLogRecord);
|
||||
}
|
||||
|
||||
if (
|
||||
!$result->isSecondStepRequired() &&
|
||||
$request->getHeader(HeaderKey::AUTHORIZATION)
|
||||
) {
|
||||
$authToken = $this->processAuthTokenFinal(
|
||||
$authToken,
|
||||
$authLogRecord,
|
||||
$user,
|
||||
$loggedUser,
|
||||
$request,
|
||||
$response
|
||||
);
|
||||
}
|
||||
|
||||
$this->processAuthLogRecord($authLogRecord, $authToken, $loggedUser);
|
||||
|
||||
if ($result->isSuccess()) {
|
||||
return $this->processSuccess($result, $data, $request, $authTokenIsFound);
|
||||
}
|
||||
|
||||
if ($result->isSecondStepRequired()) {
|
||||
return $this->processSecondStepRequired($result, $data, $request);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function processAuthTokenFinal(
|
||||
?AuthToken $authToken,
|
||||
?AuthLogRecord $authLogRecord,
|
||||
User $user,
|
||||
User $loggedUser,
|
||||
Request $request,
|
||||
Response $response
|
||||
): AuthToken {
|
||||
|
||||
if ($authToken) {
|
||||
$this->authTokenManager->renew($authToken);
|
||||
}
|
||||
|
||||
if (!$authToken) {
|
||||
$authToken = $this->createAuthToken($user, $request, $response);
|
||||
}
|
||||
|
||||
$authTokenId = null;
|
||||
|
||||
if ($authToken instanceof AuthTokenEntity) {
|
||||
$authTokenId = $authToken->hasId() ? $authToken->getId() : null;
|
||||
}
|
||||
|
||||
$loggedUser->set('token', $authToken->getToken());
|
||||
$loggedUser->set('authTokenId', $authTokenId);
|
||||
|
||||
$authLogRecord?->setAuthTokenId($authTokenId);
|
||||
|
||||
return $authToken;
|
||||
}
|
||||
|
||||
private function processAuthLogRecord(
|
||||
?AuthLogRecord $authLogRecord,
|
||||
?AuthToken $authToken,
|
||||
User $loggedUser
|
||||
): void {
|
||||
|
||||
if ($authLogRecord) {
|
||||
$this->entityManager->saveEntity($authLogRecord);
|
||||
}
|
||||
|
||||
if (
|
||||
!$authLogRecord &&
|
||||
$authToken instanceof AuthTokenEntity &&
|
||||
$authToken->hasId()
|
||||
) {
|
||||
$authLogRecord = $this->entityManager
|
||||
->getRDBRepository(AuthLogRecord::ENTITY_TYPE)
|
||||
->select([Attribute::ID])
|
||||
->where(['authTokenId' => $authToken->getId()])
|
||||
->order('requestTime', true)
|
||||
->findOne();
|
||||
}
|
||||
|
||||
if ($authLogRecord) {
|
||||
$loggedUser->set('authLogRecordId', $authLogRecord->getId());
|
||||
}
|
||||
}
|
||||
|
||||
private function isPortal(): bool
|
||||
{
|
||||
return $this->applicationState->isPortal();
|
||||
}
|
||||
|
||||
private function getPortal(): Portal
|
||||
{
|
||||
return $this->applicationState->getPortal();
|
||||
}
|
||||
|
||||
private function processAuthTokenCheck(AuthToken $authToken): bool
|
||||
{
|
||||
if ($this->isPortal() && $authToken->getPortalId() !== $this->getPortal()->getId()) {
|
||||
$this->log->info("Auth: Trying to login to portal with a token not related to portal.");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$this->isPortal() && $authToken->getPortalId()) {
|
||||
$this->log->info("Auth: Trying to login to crm with a token related to portal.");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function processUserCheck(User $user, ?AuthLogRecord $authLogRecord): bool
|
||||
{
|
||||
if (!$user->isActive()) {
|
||||
$this->log->info("Auth: Trying to login as user '{username}' which is not active.", [
|
||||
'username' => $user->getUserName(),
|
||||
]);
|
||||
|
||||
$this->logDenied($authLogRecord, AuthLogRecord::DENIAL_REASON_INACTIVE_USER);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($user->isSystem()) {
|
||||
$this->log->info("Auth: Trying to login to crm as a system user '{username}'.", [
|
||||
'username' => $user->getUserName(),
|
||||
]);
|
||||
|
||||
$this->logDenied($authLogRecord, AuthLogRecord::DENIAL_REASON_IS_SYSTEM_USER);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$user->isAdmin() && !$this->isPortal() && $user->isPortal()) {
|
||||
$this->log->info("Auth: Trying to login to crm as a portal user '{username}'.", [
|
||||
'username' => $user->getUserName(),
|
||||
]);
|
||||
|
||||
$this->logDenied($authLogRecord, AuthLogRecord::DENIAL_REASON_IS_PORTAL_USER);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->isPortal() && !$user->isPortal()) {
|
||||
$this->log->info("Auth: Trying to login to portal as user '{username}' which is not portal user.", [
|
||||
'username' => $user->getUserName(),
|
||||
]);
|
||||
|
||||
$this->logDenied($authLogRecord, AuthLogRecord::DENIAL_REASON_IS_NOT_PORTAL_USER);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->isPortal()) {
|
||||
$isPortalRelatedToUser = $this->entityManager
|
||||
->getRDBRepository(Portal::ENTITY_TYPE)
|
||||
->getRelation($this->getPortal(), 'users')
|
||||
->isRelated($user);
|
||||
|
||||
if (!$isPortalRelatedToUser) {
|
||||
$msg = "Auth: Trying to login to portal as user '{username}' " .
|
||||
"which is portal user but does not belong to portal.";
|
||||
|
||||
$this->log->info($msg, [
|
||||
'username' => $user->getUserName(),
|
||||
]);
|
||||
|
||||
$this->logDenied($authLogRecord, AuthLogRecord::DENIAL_REASON_USER_IS_NOT_IN_PORTAL);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function processTwoFactor(Result $result, Request $request): Result
|
||||
{
|
||||
$user = $result->getUser();
|
||||
|
||||
if (!$user) {
|
||||
throw new RuntimeException("No user.");
|
||||
}
|
||||
|
||||
$method = $this->getUser2FAMethod($user);
|
||||
|
||||
if (!$method) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$login = $this->twoFactorLoginFactory->create($method);
|
||||
|
||||
return $login->login($result, $request);
|
||||
}
|
||||
|
||||
private function getUser2FAMethod(User $user): ?string
|
||||
{
|
||||
$userData = $this->getUserDataRepository()->getByUserId($user->getId());
|
||||
|
||||
if (!$userData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!$userData->getAuth2FA()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$method = $userData->getAuth2FAMethod();
|
||||
|
||||
if (!$method) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!in_array($method, $this->configDataProvider->getTwoFactorMethodList())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $method;
|
||||
}
|
||||
|
||||
private function createAuthToken(User $user, Request $request, Response $response): AuthToken
|
||||
{
|
||||
$createSecret =
|
||||
$request->getHeader(self::HEADER_CREATE_TOKEN_SECRET) === 'true' &&
|
||||
!$this->configDataProvider->isAuthTokenSecretDisabled();
|
||||
|
||||
$password = $user->getPassword();
|
||||
$ipAddress = $this->util->obtainIpFromRequest($request);
|
||||
|
||||
$authTokenData = AuthTokenData::create([
|
||||
'hash' => $password,
|
||||
'ipAddress' => $ipAddress,
|
||||
'userId' => $user->getId(),
|
||||
'portalId' => $this->isPortal() ? $this->getPortal()->getId() : null,
|
||||
'createSecret' => $createSecret,
|
||||
]);
|
||||
|
||||
$authToken = $this->authTokenManager->create($authTokenData);
|
||||
|
||||
if ($createSecret) {
|
||||
$this->setSecretInCookie($authToken->getSecret(), $response, $request);
|
||||
}
|
||||
|
||||
/** @noinspection PhpConditionAlreadyCheckedInspection */
|
||||
if (
|
||||
$this->configDataProvider->preventConcurrentAuthToken() &&
|
||||
$authToken instanceof AuthTokenEntity
|
||||
) {
|
||||
$concurrentAuthTokenList = $this->entityManager
|
||||
->getRDBRepository(AuthTokenEntity::ENTITY_TYPE)
|
||||
->select([Attribute::ID])
|
||||
->where([
|
||||
'userId' => $user->getId(),
|
||||
'isActive' => true,
|
||||
'id!=' => $authToken->getId(),
|
||||
])
|
||||
->find();
|
||||
|
||||
foreach ($concurrentAuthTokenList as $concurrentAuthToken) {
|
||||
$concurrentAuthToken->set('isActive', false);
|
||||
|
||||
$this->entityManager->saveEntity($concurrentAuthToken);
|
||||
}
|
||||
}
|
||||
|
||||
return $authToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy an auth token.
|
||||
*
|
||||
* @param string $token A token to destroy.
|
||||
* @param Request $request A request.
|
||||
* @param Response $response A response.
|
||||
* @throws Forbidden
|
||||
* @throws NotFound
|
||||
*/
|
||||
public function destroyAuthToken(string $token, Request $request, Response $response): void
|
||||
{
|
||||
$authToken = $this->authTokenManager->get($token);
|
||||
|
||||
if (!$authToken) {
|
||||
throw new NotFound("Auth token not found.");
|
||||
}
|
||||
|
||||
if (!$this->applicationState->hasUser()) {
|
||||
throw new LogicException("No logged user.");
|
||||
}
|
||||
|
||||
$user = $this->applicationState->getUser();
|
||||
|
||||
$this->authTokenManager->inactivate($authToken);
|
||||
|
||||
if ($authToken->getSecret()) {
|
||||
$sentSecret = $request->getCookieParam(self::COOKIE_AUTH_TOKEN_SECRET);
|
||||
|
||||
if (
|
||||
// Still need the ability to destroy auth tokens of another users
|
||||
// for login-as-another-user feature.
|
||||
$authToken->getUserId() !== $user->getId() &&
|
||||
$sentSecret !== $authToken->getSecret()
|
||||
) {
|
||||
throw new Forbidden("Can't destroy auth token.");
|
||||
}
|
||||
|
||||
if ($sentSecret === $authToken->getSecret()) {
|
||||
$this->setSecretInCookie(null, $response);
|
||||
}
|
||||
}
|
||||
|
||||
$method = $this->methodProvider->get();
|
||||
|
||||
if (!$this->logoutFactory->isCreatable($method)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$result = $this->logoutFactory
|
||||
->create($method)
|
||||
->logout($authToken, LogoutParams::create());
|
||||
|
||||
$redirectUrl = $result->getRedirectUrl();
|
||||
|
||||
if ($redirectUrl) {
|
||||
$response->setHeader(self::HEADER_LOGOUT_REDIRECT_URL, $redirectUrl);
|
||||
}
|
||||
}
|
||||
|
||||
private function createAuthLogRecord(
|
||||
?string $username,
|
||||
?User $user,
|
||||
Request $request,
|
||||
?string $method = null
|
||||
): ?AuthLogRecord {
|
||||
|
||||
if ($username === self::LOGOUT_USERNAME) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->configDataProvider->isAuthLogDisabled()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var AuthLogRecord $authLogRecord */
|
||||
$authLogRecord = $this->entityManager->getNewEntity(AuthLogRecord::ENTITY_TYPE);
|
||||
|
||||
$requestUrl =
|
||||
$request->getUri()->getScheme() . '://' .
|
||||
$request->getUri()->getHost() .
|
||||
$request->getUri()->getPath();
|
||||
|
||||
if (!$username && $user) {
|
||||
$username = $user->getUserName();
|
||||
}
|
||||
|
||||
$authLogRecord
|
||||
->setUsername($username)
|
||||
->setIpAddress($this->util->obtainIpFromRequest($request))
|
||||
->setRequestTime($request->getServerParam('REQUEST_TIME_FLOAT'))
|
||||
->setRequestMethod($request->getMethod())
|
||||
->setRequestUrl($requestUrl)
|
||||
->setAuthenticationMethod($method)
|
||||
->setPortalId($this->isPortal() ? $this->getPortal()->getId() : null);
|
||||
|
||||
if ($user && $user->isApi() && $this->configDataProvider->isApiUserAuthLogDisabled()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($user) {
|
||||
$authLogRecord->setUserId($user->hasId() ? $user->getId() : null);
|
||||
|
||||
return $authLogRecord;
|
||||
}
|
||||
|
||||
$authLogRecord
|
||||
->setIsDenied()
|
||||
->setDenialReason(AuthLogRecord::DENIAL_REASON_CREDENTIALS);
|
||||
|
||||
$this->entityManager->saveEntity($authLogRecord);
|
||||
|
||||
return $authLogRecord;
|
||||
}
|
||||
|
||||
private function logDenied(?AuthLogRecord $authLogRecord, string $denialReason): void
|
||||
{
|
||||
if (!$authLogRecord) {
|
||||
return;
|
||||
}
|
||||
|
||||
$authLogRecord
|
||||
->setIsDenied()
|
||||
->setDenialReason($denialReason);
|
||||
|
||||
$this->entityManager->saveEntity($authLogRecord);
|
||||
}
|
||||
|
||||
private function setSecretInCookie(?string $secret, Response $response, ?Request $request = null): void
|
||||
{
|
||||
$time = $secret ? strtotime('+1000 days') : 1;
|
||||
|
||||
$value = $secret ?? 'deleted';
|
||||
|
||||
$headerValue =
|
||||
self::COOKIE_AUTH_TOKEN_SECRET . '=' . urlencode($value) .
|
||||
'; path=/' .
|
||||
'; expires=' . gmdate('D, d M Y H:i:s T', $time) .
|
||||
'; HttpOnly' .
|
||||
'; SameSite=Lax';
|
||||
|
||||
if ($request && self::isSecureRequest($request)) {
|
||||
$headerValue .= "; Secure";
|
||||
}
|
||||
|
||||
$response->addHeader('Set-Cookie', $headerValue);
|
||||
}
|
||||
|
||||
private static function isSecureRequest(Request $request): bool
|
||||
{
|
||||
$https = $request->getServerParam('HTTPS');
|
||||
|
||||
if ($https === 'on') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$scheme = $request->getServerParam('REQUEST_SCHEME');
|
||||
|
||||
if ($scheme === 'https') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$forwardedProto = $request->getServerParam('HTTP_X_FORWARDED_PROTO');
|
||||
|
||||
if ($forwardedProto === 'https') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function processFail(Result $result, AuthenticationData $data, Request $request): Result
|
||||
{
|
||||
$this->hookManager->processOnFail($result, $data, $request);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function processSuccess(
|
||||
Result $result,
|
||||
AuthenticationData $data,
|
||||
Request $request,
|
||||
bool $byToken
|
||||
): Result {
|
||||
|
||||
if ($byToken) {
|
||||
$this->hookManager->processOnSuccessByToken($result, $data, $request);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
$this->hookManager->processOnSuccess($result, $data, $request);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function processSecondStepRequired(
|
||||
Result $result,
|
||||
AuthenticationData $data,
|
||||
Request $request
|
||||
): Result {
|
||||
|
||||
$this->hookManager->processOnSecondStepRequired($result, $data, $request);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function getUserDataRepository(): UserDataRepository
|
||||
{
|
||||
/** @var UserDataRepository */
|
||||
return $this->entityManager->getRepository(UserData::ENTITY_TYPE);
|
||||
}
|
||||
|
||||
private function getUsernameByAuthToken(AuthToken $authToken): ?string
|
||||
{
|
||||
/** @var ?User $user */
|
||||
$user = $this->entityManager
|
||||
->getRDBRepository(User::ENTITY_TYPE)
|
||||
->select(['userName'])
|
||||
->where([Attribute::ID => $authToken->getUserId()])
|
||||
->findOne();
|
||||
|
||||
return $user?->getUserName();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{?User, (FailReason::*)|null}
|
||||
*/
|
||||
private function getLoggedUser(Request $request, User $user): array
|
||||
{
|
||||
$username = $request->getHeader(self::HEADER_ANOTHER_USER);
|
||||
|
||||
if (!$username) {
|
||||
return [$user, null];
|
||||
}
|
||||
|
||||
if ($this->configDataProvider->isAnotherUserDisabled()) {
|
||||
return [null, FailReason::ANOTHER_USER_NOT_ALLOWED];
|
||||
}
|
||||
|
||||
// Important check.
|
||||
if (!$user->isAdmin()) {
|
||||
return [null, FailReason::ANOTHER_USER_NOT_ALLOWED];
|
||||
}
|
||||
|
||||
/** @var ?User $loggedUser */
|
||||
$loggedUser = $this->entityManager
|
||||
->getRDBRepository(User::ENTITY_TYPE)
|
||||
->where(['userName' => $username])
|
||||
->findOne();
|
||||
|
||||
if (!$loggedUser) {
|
||||
return [null, FailReason::ANOTHER_USER_NOT_FOUND];
|
||||
}
|
||||
|
||||
if (!$loggedUser->isRegular()) {
|
||||
return [null, FailReason::ANOTHER_USER_NOT_ALLOWED];
|
||||
}
|
||||
|
||||
$loggedUser->loadLinkMultipleField(Field::TEAMS);
|
||||
|
||||
return [$loggedUser, null];
|
||||
}
|
||||
|
||||
private function prepareUser(User $user, Request $request): void
|
||||
{
|
||||
if ($this->isPortal()) {
|
||||
$user->set('portalId', $this->getPortal()->getId());
|
||||
}
|
||||
|
||||
if (!$this->isPortal()) {
|
||||
$user->loadLinkMultipleField(Field::TEAMS);
|
||||
}
|
||||
|
||||
$user->set('ipAddress', $this->util->obtainIpFromRequest($request));
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
*/
|
||||
private function processForbidden(Forbidden $exception, ?AuthLogRecord $authLogRecord = null): never
|
||||
{
|
||||
$this->log->warning('Auth: Forbidden. {message}', [
|
||||
'message' => $exception->getMessage(),
|
||||
'exception' => $exception,
|
||||
]);
|
||||
|
||||
if ($authLogRecord) {
|
||||
$authLogRecord
|
||||
->setIsDenied()
|
||||
->setDenialReason(AuthLogRecord::DENIAL_REASON_FORBIDDEN);
|
||||
|
||||
$this->entityManager->saveEntity($authLogRecord);
|
||||
}
|
||||
|
||||
throw new Forbidden();
|
||||
}
|
||||
|
||||
private function processTwoFactorFail(
|
||||
Result $result,
|
||||
AuthenticationData $data,
|
||||
Request $request,
|
||||
?AuthLogRecord $authLogRecord
|
||||
): Result {
|
||||
|
||||
if ($authLogRecord) {
|
||||
$authLogRecord
|
||||
->setIsDenied()
|
||||
->setDenialReason(AuthLogRecord::DENIAL_REASON_WRONG_CODE);
|
||||
|
||||
$this->entityManager->saveEntity($authLogRecord);
|
||||
}
|
||||
|
||||
return $this->processFail($result, $data, $request);
|
||||
}
|
||||
}
|
||||
115
application/Espo/Core/Authentication/AuthenticationData.php
Normal file
115
application/Espo/Core/Authentication/AuthenticationData.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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;
|
||||
|
||||
use SensitiveParameter;
|
||||
|
||||
/**
|
||||
* Immutable.
|
||||
*/
|
||||
class AuthenticationData
|
||||
{
|
||||
private bool $byTokenOnly = false;
|
||||
|
||||
public function __construct(
|
||||
private ?string $username = null,
|
||||
private ?string $password = null,
|
||||
private ?string $method = null
|
||||
) {}
|
||||
|
||||
public static function create(): self
|
||||
{
|
||||
return new self();
|
||||
}
|
||||
|
||||
/**
|
||||
* A username.
|
||||
*/
|
||||
public function getUsername(): ?string
|
||||
{
|
||||
return $this->username;
|
||||
}
|
||||
|
||||
/**
|
||||
* A password or auth-token.
|
||||
*/
|
||||
public function getPassword(): ?string
|
||||
{
|
||||
return $this->password;
|
||||
}
|
||||
|
||||
/**
|
||||
* A method.
|
||||
*/
|
||||
public function getMethod(): ?string
|
||||
{
|
||||
return $this->method;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate by auth-token only. No username check.
|
||||
*/
|
||||
public function byTokenOnly(): bool
|
||||
{
|
||||
return $this->byTokenOnly;
|
||||
}
|
||||
|
||||
public function withUsername(?string $username): self
|
||||
{
|
||||
$obj = clone $this;
|
||||
$obj->username = $username;
|
||||
|
||||
return $obj;
|
||||
}
|
||||
|
||||
public function withPassword(#[SensitiveParameter] ?string $password): self
|
||||
{
|
||||
$obj = clone $this;
|
||||
$obj->password = $password;
|
||||
|
||||
return $obj;
|
||||
}
|
||||
|
||||
public function withMethod(?string $method): self
|
||||
{
|
||||
$obj = clone $this;
|
||||
$obj->method = $method;
|
||||
|
||||
return $obj;
|
||||
}
|
||||
|
||||
public function withByTokenOnly(bool $byTokenOnly): self
|
||||
{
|
||||
$obj = clone $this;
|
||||
$obj->byTokenOnly = $byTokenOnly;
|
||||
|
||||
return $obj;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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;
|
||||
|
||||
use Espo\Core\InjectableFactory;
|
||||
|
||||
class AuthenticationFactory
|
||||
{
|
||||
public function __construct(private InjectableFactory $injectableFactory)
|
||||
{}
|
||||
|
||||
public function create(): Authentication
|
||||
{
|
||||
return $this->injectableFactory->create(Authentication::class);
|
||||
}
|
||||
}
|
||||
180
application/Espo/Core/Authentication/ConfigDataProvider.php
Normal file
180
application/Espo/Core/Authentication/ConfigDataProvider.php
Normal file
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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;
|
||||
|
||||
use Espo\Core\Authentication\Login\MetadataParams;
|
||||
use Espo\Core\Authentication\Logins\Espo;
|
||||
use Espo\Core\Utils\Config;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
|
||||
class ConfigDataProvider
|
||||
{
|
||||
private const FAILED_ATTEMPTS_PERIOD = '60 seconds';
|
||||
private const FAILED_CODE_ATTEMPTS_PERIOD = '5 minutes';
|
||||
private const MAX_FAILED_ATTEMPT_NUMBER = 10;
|
||||
|
||||
public function __construct(private Config $config, private Metadata $metadata)
|
||||
{}
|
||||
|
||||
/**
|
||||
* A period for max failed attempts checking.
|
||||
*/
|
||||
public function getFailedAttemptsPeriod(): string
|
||||
{
|
||||
return $this->config->get('authFailedAttemptsPeriod', self::FAILED_ATTEMPTS_PERIOD);
|
||||
}
|
||||
|
||||
/**
|
||||
* A period for max failed 2FA code attempts checking.
|
||||
*/
|
||||
public function getFailedCodeAttemptsPeriod(): string
|
||||
{
|
||||
return $this->config->get('authFailedCodeAttemptsPeriod', self::FAILED_CODE_ATTEMPTS_PERIOD);
|
||||
}
|
||||
|
||||
/**
|
||||
* Max failed log in attempts.
|
||||
*/
|
||||
public function getMaxFailedAttemptNumber(): int
|
||||
{
|
||||
return $this->config->get('authMaxFailedAttemptNumber', self::MAX_FAILED_ATTEMPT_NUMBER);
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth token secret won't be created. Can be reasonable for a custom AuthTokenManager implementation.
|
||||
*/
|
||||
public function isAuthTokenSecretDisabled(): bool
|
||||
{
|
||||
return (bool) $this->config->get('authTokenSecretDisabled');
|
||||
}
|
||||
|
||||
/**
|
||||
* A maintenance mode. Only admin can log in.
|
||||
*/
|
||||
public function isMaintenanceMode(): bool
|
||||
{
|
||||
return (bool) $this->config->get('maintenanceMode');
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether 2FA is enabled.
|
||||
*/
|
||||
public function isTwoFactorEnabled(): bool
|
||||
{
|
||||
return (bool) $this->config->get('auth2FA');
|
||||
}
|
||||
|
||||
/**
|
||||
* Allowed methods of 2FA.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function getTwoFactorMethodList(): array
|
||||
{
|
||||
return $this->config->get('auth2FAMethodList') ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* A user won't be able to have multiple active auth tokens simultaneously.
|
||||
*/
|
||||
public function preventConcurrentAuthToken(): bool
|
||||
{
|
||||
return (bool) $this->config->get('authTokenPreventConcurrent');
|
||||
}
|
||||
|
||||
/**
|
||||
* A default authentication method.
|
||||
*/
|
||||
public function getDefaultAuthenticationMethod(): string
|
||||
{
|
||||
return $this->config->get('authenticationMethod', Espo::NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an authentication method can be defined by request itself (in a header).
|
||||
*/
|
||||
public function authenticationMethodIsApi(string $authenticationMethod): bool
|
||||
{
|
||||
return (bool) $this->metadata->get(['authenticationMethods', $authenticationMethod, 'api']);
|
||||
}
|
||||
|
||||
public function isAnotherUserDisabled(): bool
|
||||
{
|
||||
return (bool) $this->config->get('authAnotherUserDisabled');
|
||||
}
|
||||
|
||||
public function isAuthLogDisabled(): bool
|
||||
{
|
||||
return (bool) $this->config->get('authLogDisabled');
|
||||
}
|
||||
|
||||
public function isApiUserAuthLogDisabled(): bool
|
||||
{
|
||||
return (bool) $this->config->get('authApiUserLogDisabled');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return MetadataParams[]
|
||||
*/
|
||||
public function getLoginMetadataParamsList(): array
|
||||
{
|
||||
$list = [];
|
||||
|
||||
/** @var array<string, array<string, mixed>> $data */
|
||||
$data = $this->metadata->get(['authenticationMethods']) ?? [];
|
||||
|
||||
foreach ($data as $method => $item) {
|
||||
$list[] = MetadataParams::fromRaw($method, $item);
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
public function ipAddressCheck(): bool
|
||||
{
|
||||
return (bool) $this->config->get('authIpAddressCheck');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getIpAddressWhitelist(): array
|
||||
{
|
||||
return $this->config->get('authIpAddressWhitelist') ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getIpAddressCheckExcludedUserIdList(): array
|
||||
{
|
||||
return $this->config->get('authIpAddressCheckExcludedUsersIds') ?? [];
|
||||
}
|
||||
}
|
||||
37
application/Espo/Core/Authentication/HeaderKey.php
Normal file
37
application/Espo/Core/Authentication/HeaderKey.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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;
|
||||
|
||||
class HeaderKey
|
||||
{
|
||||
public const AUTHORIZATION_BY_TOKEN = 'Espo-Authorization-By-Token';
|
||||
public const AUTHORIZATION_CODE = 'Espo-Authorization-Code';
|
||||
public const AUTHORIZATION = 'Espo-Authorization';
|
||||
}
|
||||
94
application/Espo/Core/Authentication/Helper/UserFinder.php
Normal file
94
application/Espo/Core/Authentication/Helper/UserFinder.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\Helper;
|
||||
|
||||
use Espo\Core\Authentication\Logins\ApiKey;
|
||||
use Espo\Core\Authentication\Logins\Hmac;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\Entities\User;
|
||||
|
||||
class UserFinder
|
||||
{
|
||||
public function __construct(private EntityManager $entityManager)
|
||||
{}
|
||||
|
||||
public function find(string $username): ?User
|
||||
{
|
||||
return $this->entityManager
|
||||
->getRDBRepositoryByClass(User::class)
|
||||
->where([
|
||||
'userName' => $username,
|
||||
'type!=' => [User::TYPE_API, User::TYPE_SYSTEM],
|
||||
])
|
||||
->findOne();
|
||||
}
|
||||
|
||||
public function findByIdAndHash(string $username, string $id, ?string $hash): ?User
|
||||
{
|
||||
$where = [
|
||||
'userName' => $username,
|
||||
'id' => $id,
|
||||
'type!=' => [User::TYPE_API, User::TYPE_SYSTEM],
|
||||
];
|
||||
|
||||
if ($hash) {
|
||||
$where['password'] = $hash;
|
||||
}
|
||||
|
||||
return $this->entityManager
|
||||
->getRDBRepositoryByClass(User::class)
|
||||
->where($where)
|
||||
->findOne();
|
||||
}
|
||||
|
||||
public function findApiHmac(string $apiKey): ?User
|
||||
{
|
||||
return $this->entityManager
|
||||
->getRDBRepositoryByClass(User::class)
|
||||
->where([
|
||||
'type' => User::TYPE_API,
|
||||
'apiKey' => $apiKey,
|
||||
'authMethod' => Hmac::NAME,
|
||||
])
|
||||
->findOne();
|
||||
}
|
||||
|
||||
public function findApiApiKey(string $apiKey): ?User
|
||||
{
|
||||
return $this->entityManager
|
||||
->getRDBRepositoryByClass(User::class)
|
||||
->where([
|
||||
'type' => User::TYPE_API,
|
||||
'apiKey' => $apiKey,
|
||||
'authMethod' => ApiKey::NAME,
|
||||
])
|
||||
->findOne();
|
||||
}
|
||||
}
|
||||
47
application/Espo/Core/Authentication/Hook/BeforeLogin.php
Normal file
47
application/Espo/Core/Authentication/Hook/BeforeLogin.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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;
|
||||
|
||||
use Espo\Core\Authentication\AuthenticationData;
|
||||
use Espo\Core\Api\Request;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Exceptions\ServiceUnavailable;
|
||||
|
||||
/**
|
||||
* Before logging in, before credentials are checked.
|
||||
*/
|
||||
interface BeforeLogin
|
||||
{
|
||||
/**
|
||||
* @throws Forbidden
|
||||
* @throws ServiceUnavailable
|
||||
*/
|
||||
public function process(AuthenticationData $data, Request $request): void;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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 Espo\Core\Api\Util;
|
||||
use Espo\Core\Authentication\HeaderKey;
|
||||
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\Entities\AuthLogRecord;
|
||||
|
||||
use DateTime;
|
||||
use Espo\ORM\Name\Attribute;
|
||||
use Exception;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
class FailedAttemptsLimit implements BeforeLogin
|
||||
{
|
||||
public function __construct(
|
||||
private ConfigDataProvider $configDataProvider,
|
||||
private EntityManager $entityManager,
|
||||
private Util $util
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
*/
|
||||
public function process(AuthenticationData $data, Request $request): void
|
||||
{
|
||||
$isByTokenOnly = !$data->getMethod() && $request->getHeader(HeaderKey::AUTHORIZATION_BY_TOKEN) === 'true';
|
||||
|
||||
if ($isByTokenOnly || $this->configDataProvider->isAuthLogDisabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$failedAttemptsPeriod = $this->configDataProvider->getFailedAttemptsPeriod();
|
||||
|
||||
$ipAddress = $this->util->obtainIpFromRequest($request);
|
||||
|
||||
$where = [
|
||||
'requestTime>' => $this->getTimeFrom($request, $failedAttemptsPeriod)->format('U'),
|
||||
'isDenied' => true,
|
||||
'ipAddress' => $ipAddress,
|
||||
];
|
||||
|
||||
$wasFailed = (bool) $this->entityManager
|
||||
->getRDBRepository(AuthLogRecord::ENTITY_TYPE)
|
||||
->select([Attribute::ID])
|
||||
->where($where)
|
||||
->findOne();
|
||||
|
||||
if (!$wasFailed) {
|
||||
return;
|
||||
}
|
||||
|
||||
$failAttemptCount = $this->entityManager
|
||||
->getRDBRepository(AuthLogRecord::ENTITY_TYPE)
|
||||
->where($where)
|
||||
->count();
|
||||
|
||||
if ($failAttemptCount <= $this->configDataProvider->getMaxFailedAttemptNumber()) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Forbidden("Max failed login attempts exceeded for IP address $ipAddress.");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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 Espo\Core\Api\Request;
|
||||
use Espo\Core\Authentication\AuthenticationData;
|
||||
use Espo\Core\Authentication\ConfigDataProvider;
|
||||
use Espo\Core\Authentication\Hook\BeforeLogin;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Entities\AuthLogRecord;
|
||||
use Espo\ORM\EntityManager;
|
||||
|
||||
use DateTime;
|
||||
use Espo\ORM\Name\Attribute;
|
||||
use Exception;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
class FailedCodeAttemptsLimit implements BeforeLogin
|
||||
{
|
||||
public function __construct(
|
||||
private ConfigDataProvider $configDataProvider,
|
||||
private EntityManager $entityManager,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
*/
|
||||
public function process(AuthenticationData $data, Request $request): void
|
||||
{
|
||||
if (
|
||||
$request->getHeader('Espo-Authorization-Code') === null ||
|
||||
$this->configDataProvider->isAuthLogDisabled()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
$isByTokenOnly = !$data->getMethod() && $request->getHeader('Espo-Authorization-By-Token') === 'true';
|
||||
|
||||
if ($isByTokenOnly) {
|
||||
return;
|
||||
}
|
||||
|
||||
$failedAttemptsPeriod = $this->configDataProvider->getFailedCodeAttemptsPeriod();
|
||||
|
||||
$where = [
|
||||
'requestTime>' => $this->getTimeFrom($request, $failedAttemptsPeriod)->format('U'),
|
||||
'isDenied' => true,
|
||||
'username' => $data->getUsername(),
|
||||
'denialReason' => AuthLogRecord::DENIAL_REASON_WRONG_CODE,
|
||||
];
|
||||
|
||||
$wasFailed = (bool) $this->entityManager
|
||||
->getRDBRepository(AuthLogRecord::ENTITY_TYPE)
|
||||
->select([Attribute::ID])
|
||||
->where($where)
|
||||
->findOne();
|
||||
|
||||
if (!$wasFailed) {
|
||||
return;
|
||||
}
|
||||
|
||||
$failAttemptCount = $this->entityManager
|
||||
->getRDBRepository(AuthLogRecord::ENTITY_TYPE)
|
||||
->where($where)
|
||||
->count();
|
||||
|
||||
if ($failAttemptCount <= $this->configDataProvider->getMaxFailedAttemptNumber()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$username = $data->getUsername() ?? '';
|
||||
|
||||
throw new Forbidden("Max failed 2FA login attempts exceeded for username '$username'.");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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 Espo\Core\Api\Request;
|
||||
use Espo\Core\Api\Util;
|
||||
use Espo\Core\Authentication\AuthenticationData;
|
||||
use Espo\Core\Authentication\ConfigDataProvider;
|
||||
use Espo\Core\Authentication\Hook\OnLogin;
|
||||
use Espo\Core\Authentication\Result;
|
||||
use Espo\Core\Authentication\Util\IpAddressUtil;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Utils\Config;
|
||||
|
||||
class IpAddressWhitelist implements OnLogin
|
||||
{
|
||||
public function __construct(
|
||||
private ConfigDataProvider $configDataProvider,
|
||||
private Util $util,
|
||||
private Config $config,
|
||||
private IpAddressUtil $ipAddressUtil
|
||||
) {}
|
||||
|
||||
public function process(Result $result, AuthenticationData $data, Request $request): void
|
||||
{
|
||||
if (!$this->configDataProvider->ipAddressCheck()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$ipAddress = $this->util->obtainIpFromRequest($request);
|
||||
|
||||
if (
|
||||
$ipAddress &&
|
||||
$this->ipAddressUtil->isInWhitelist($ipAddress, $this->configDataProvider->getIpAddressWhitelist())
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user = $result->getUser();
|
||||
|
||||
if ($user && $user->isPortal()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($user && $user->isSuperAdmin() && $this->config->get('restrictedMode')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
$user &&
|
||||
in_array($user->getId(), $this->configDataProvider->getIpAddressCheckExcludedUserIdList())
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
$username = $user ? $user->getUserName() : '?';
|
||||
|
||||
throw new Forbidden("Not allowed IP address $ipAddress, user: $username.");
|
||||
}
|
||||
}
|
||||
195
application/Espo/Core/Authentication/Hook/Manager.php
Normal file
195
application/Espo/Core/Authentication/Hook/Manager.php
Normal file
@@ -0,0 +1,195 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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;
|
||||
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Exceptions\ServiceUnavailable;
|
||||
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
|
||||
{
|
||||
|
||||
public function __construct(private Metadata $metadata, private InjectableFactory $injectableFactory)
|
||||
{}
|
||||
|
||||
/**
|
||||
* @throws ServiceUnavailable
|
||||
* @throws Forbidden
|
||||
*/
|
||||
public function processBeforeLogin(AuthenticationData $data, Request $request): void
|
||||
{
|
||||
foreach ($this->getBeforeLoginHookList() as $hook) {
|
||||
$hook->process($data, $request);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
*/
|
||||
public function processOnLogin(Result $result, AuthenticationData $data, Request $request): void
|
||||
{
|
||||
foreach ($this->getOnLoginHookList() as $hook) {
|
||||
$hook->process($result, $data, $request);
|
||||
}
|
||||
}
|
||||
|
||||
public function processOnFail(Result $result, AuthenticationData $data, Request $request): void
|
||||
{
|
||||
foreach ($this->getOnFailHookList() as $hook) {
|
||||
$hook->process($result, $data, $request);
|
||||
}
|
||||
}
|
||||
|
||||
public function processOnSuccess(Result $result, AuthenticationData $data, Request $request): void
|
||||
{
|
||||
foreach ($this->getOnSuccessHookList() as $hook) {
|
||||
$hook->process($result, $data, $request);
|
||||
}
|
||||
}
|
||||
|
||||
public function processOnSuccessByToken(Result $result, AuthenticationData $data, Request $request): void
|
||||
{
|
||||
foreach ($this->getOnSuccessByTokenHookList() as $hook) {
|
||||
$hook->process($result, $data, $request);
|
||||
}
|
||||
}
|
||||
|
||||
public function processOnSecondStepRequired(Result $result, AuthenticationData $data, Request $request): void
|
||||
{
|
||||
foreach ($this->getOnSecondStepRequiredHookList() as $hook) {
|
||||
$hook->process($result, $data, $request);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return class-string<BeforeLogin|OnResult>[]
|
||||
*/
|
||||
private function getHookClassNameList(string $type): array
|
||||
{
|
||||
$key = $type . 'HookClassNameList';
|
||||
|
||||
/** @var class-string<BeforeLogin|OnResult>[] */
|
||||
return $this->metadata->get(['app', 'authentication', $key]) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BeforeLogin[]
|
||||
*/
|
||||
private function getBeforeLoginHookList(): array
|
||||
{
|
||||
$list = [];
|
||||
|
||||
foreach ($this->getHookClassNameList('beforeLogin') as $className) {
|
||||
/** @var class-string<BeforeLogin> $className */
|
||||
$list[] = $this->injectableFactory->create($className);
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return OnLogin[]
|
||||
*/
|
||||
private function getOnLoginHookList(): array
|
||||
{
|
||||
$list = [];
|
||||
|
||||
foreach ($this->getHookClassNameList('onLogin') as $className) {
|
||||
/** @var class-string<OnLogin> $className */
|
||||
$list[] = $this->injectableFactory->create($className);
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return OnResult[]
|
||||
*/
|
||||
private function getOnFailHookList(): array
|
||||
{
|
||||
$list = [];
|
||||
|
||||
foreach ($this->getHookClassNameList('onFail') as $className) {
|
||||
/** @var class-string<OnResult> $className */
|
||||
$list[] = $this->injectableFactory->create($className);
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return OnResult[]
|
||||
*/
|
||||
private function getOnSuccessHookList(): array
|
||||
{
|
||||
$list = [];
|
||||
|
||||
foreach ($this->getHookClassNameList('onSuccess') as $className) {
|
||||
/** @var class-string<OnResult> $className */
|
||||
$list[] = $this->injectableFactory->create($className);
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return OnResult[]
|
||||
*/
|
||||
private function getOnSuccessByTokenHookList(): array
|
||||
{
|
||||
$list = [];
|
||||
|
||||
foreach ($this->getHookClassNameList('onSuccessByToken') as $className) {
|
||||
/** @var class-string<OnResult> $className */
|
||||
$list[] = $this->injectableFactory->create($className);
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return OnResult[]
|
||||
*/
|
||||
private function getOnSecondStepRequiredHookList(): array
|
||||
{
|
||||
$list = [];
|
||||
|
||||
foreach ($this->getHookClassNameList('onSecondStepRequired') as $className) {
|
||||
/** @var class-string<OnResult> $className */
|
||||
$list[] = $this->injectableFactory->create($className);
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
}
|
||||
46
application/Espo/Core/Authentication/Hook/OnLogin.php
Normal file
46
application/Espo/Core/Authentication/Hook/OnLogin.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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;
|
||||
|
||||
use Espo\Core\Authentication\AuthenticationData;
|
||||
use Espo\Core\Api\Request;
|
||||
use Espo\Core\Authentication\Result;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
|
||||
/**
|
||||
* Once a login result is ready.
|
||||
*/
|
||||
interface OnLogin
|
||||
{
|
||||
/**
|
||||
* @throws Forbidden
|
||||
*/
|
||||
public function process(Result $result, AuthenticationData $data, Request $request): void;
|
||||
}
|
||||
42
application/Espo/Core/Authentication/Hook/OnResult.php
Normal file
42
application/Espo/Core/Authentication/Hook/OnResult.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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;
|
||||
|
||||
use Espo\Core\Authentication\AuthenticationData;
|
||||
use Espo\Core\Api\Request;
|
||||
use Espo\Core\Authentication\Result;
|
||||
|
||||
/**
|
||||
* Once a login result is ready.
|
||||
*/
|
||||
interface OnResult
|
||||
{
|
||||
public function process(Result $result, AuthenticationData $data, Request $request): void;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\Jwt;
|
||||
|
||||
use Espo\Core\Authentication\Jwt\Exceptions\UnsupportedKey;
|
||||
use Espo\Core\Authentication\Jwt\Keys\Rsa;
|
||||
use stdClass;
|
||||
|
||||
class DefaultKeyFactory implements KeyFactory
|
||||
{
|
||||
private const TYPE_RSA = 'RSA';
|
||||
|
||||
public function create(stdClass $raw): Key
|
||||
{
|
||||
$kty = $raw->kty ?? null;
|
||||
|
||||
if ($kty === self::TYPE_RSA) {
|
||||
return Rsa::fromRaw($raw);
|
||||
}
|
||||
|
||||
throw new UnsupportedKey();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\Jwt\Exceptions;
|
||||
|
||||
class Expired extends Invalid {}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\Jwt\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
class Invalid extends Exception {}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\Jwt\Exceptions;
|
||||
|
||||
class NotBefore extends Invalid {}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\Jwt\Exceptions;
|
||||
|
||||
class SignatureNotVerified extends Invalid {}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\Jwt\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
class UnsupportedKey extends Exception {}
|
||||
39
application/Espo/Core/Authentication/Jwt/Key.php
Normal file
39
application/Espo/Core/Authentication/Jwt/Key.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\Jwt;
|
||||
|
||||
interface Key
|
||||
{
|
||||
public function getKid(): string;
|
||||
|
||||
public function getKty(): string;
|
||||
|
||||
public function getAlg(): ?string;
|
||||
}
|
||||
41
application/Espo/Core/Authentication/Jwt/KeyFactory.php
Normal file
41
application/Espo/Core/Authentication/Jwt/KeyFactory.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\Jwt;
|
||||
|
||||
use Espo\Core\Authentication\Jwt\Exceptions\UnsupportedKey;
|
||||
use stdClass;
|
||||
|
||||
interface KeyFactory
|
||||
{
|
||||
/**
|
||||
* @throws UnsupportedKey
|
||||
*/
|
||||
public function create(stdClass $raw): Key;
|
||||
}
|
||||
99
application/Espo/Core/Authentication/Jwt/Keys/Rsa.php
Normal file
99
application/Espo/Core/Authentication/Jwt/Keys/Rsa.php
Normal file
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\Jwt\Keys;
|
||||
|
||||
use Espo\Core\Authentication\Jwt\Key;
|
||||
use UnexpectedValueException;
|
||||
use stdClass;
|
||||
|
||||
/**
|
||||
* Immutable.
|
||||
*/
|
||||
class Rsa implements Key
|
||||
{
|
||||
private string $kid;
|
||||
private string $kty;
|
||||
private ?string $alg;
|
||||
private string $n;
|
||||
private string $e;
|
||||
|
||||
private function __construct(stdClass $raw)
|
||||
{
|
||||
$kid = $raw->kid ?? null;
|
||||
$kty = $raw->kty ?? null;
|
||||
$alg = $raw->alg ?? null;
|
||||
$n = $raw->n ?? null;
|
||||
$e = $raw->e ?? null;
|
||||
|
||||
if ($kid === null || $kty === null) {
|
||||
throw new UnexpectedValueException("Bad JWK value.");
|
||||
}
|
||||
|
||||
if ($n === null || $e === null) {
|
||||
throw new UnexpectedValueException("Bad JWK RSE key. No `n` or `e` values.");
|
||||
}
|
||||
|
||||
$this->kid = $kid;
|
||||
$this->kty = $kty;
|
||||
$this->alg = $alg;
|
||||
$this->n = $n;
|
||||
$this->e = $e;
|
||||
}
|
||||
|
||||
public static function fromRaw(stdClass $raw): self
|
||||
{
|
||||
return new self($raw);
|
||||
}
|
||||
|
||||
public function getKid(): string
|
||||
{
|
||||
return $this->kid;
|
||||
}
|
||||
|
||||
public function getKty(): string
|
||||
{
|
||||
return $this->kty;
|
||||
}
|
||||
|
||||
public function getAlg(): ?string
|
||||
{
|
||||
return $this->alg;
|
||||
}
|
||||
|
||||
public function getN(): string
|
||||
{
|
||||
return $this->n;
|
||||
}
|
||||
|
||||
public function getE(): string
|
||||
{
|
||||
return $this->e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\Jwt;
|
||||
|
||||
interface SignatureVerifier
|
||||
{
|
||||
public function verify(Token $token): bool;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\Jwt;
|
||||
|
||||
interface SignatureVerifierFactory
|
||||
{
|
||||
public function create(string $algorithm): SignatureVerifier;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\Jwt\SignatureVerifiers;
|
||||
|
||||
use Espo\Core\Authentication\Jwt\Token;
|
||||
use Espo\Core\Authentication\Jwt\SignatureVerifier;
|
||||
use LogicException;
|
||||
use RuntimeException;
|
||||
|
||||
class Hmac implements SignatureVerifier
|
||||
{
|
||||
private const SUPPORTED_ALGORITHM_LIST = [
|
||||
self::HS256,
|
||||
self::HS384,
|
||||
self::HS512,
|
||||
];
|
||||
|
||||
private const ALGORITHM_MAP = [
|
||||
self::HS256 => 'SHA256',
|
||||
self::HS384 => 'SHA384',
|
||||
self::HS512 => 'SHA512',
|
||||
];
|
||||
|
||||
private const HS256 = 'HS256';
|
||||
private const HS384 = 'HS384';
|
||||
private const HS512 = 'HS512';
|
||||
|
||||
private string $algorithm;
|
||||
private string $key;
|
||||
|
||||
public function __construct(
|
||||
string $algorithm,
|
||||
string $key
|
||||
) {
|
||||
$this->algorithm = $algorithm;
|
||||
$this->key = $key;
|
||||
|
||||
if (!in_array($algorithm, self::SUPPORTED_ALGORITHM_LIST)) {
|
||||
throw new RuntimeException("Unsupported algorithm $algorithm.");
|
||||
}
|
||||
}
|
||||
|
||||
public function verify(Token $token): bool
|
||||
{
|
||||
$input = $token->getSigningInput();
|
||||
$signature = $token->getSignature();
|
||||
|
||||
$functionAlgorithm = self::ALGORITHM_MAP[$this->algorithm] ?? null;
|
||||
|
||||
if (!$functionAlgorithm) {
|
||||
throw new LogicException();
|
||||
}
|
||||
|
||||
$hash = hash_hmac($functionAlgorithm, $input, $this->key, true);
|
||||
|
||||
return $hash === $signature;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\Jwt\SignatureVerifiers;
|
||||
|
||||
use Espo\Core\Authentication\Jwt\Key;
|
||||
use Espo\Core\Authentication\Jwt\Keys\Rsa as RsaKey;
|
||||
use Espo\Core\Authentication\Jwt\Token;
|
||||
use Espo\Core\Authentication\Jwt\SignatureVerifier;
|
||||
use Espo\Core\Authentication\Jwt\Util;
|
||||
use LogicException;
|
||||
use phpseclib3\Crypt\PublicKeyLoader;
|
||||
use phpseclib3\Math\BigInteger;
|
||||
use RuntimeException;
|
||||
|
||||
class Rsa implements SignatureVerifier
|
||||
{
|
||||
private const SUPPORTED_ALGORITHM_LIST = [
|
||||
self::RS256,
|
||||
self::RS384,
|
||||
self::RS512,
|
||||
];
|
||||
|
||||
private const ALGORITHM_MAP = [
|
||||
self::RS256 => 'SHA256',
|
||||
self::RS384 => 'SHA384',
|
||||
self::RS512 => 'SHA512',
|
||||
];
|
||||
|
||||
private const RS256 = 'RS256';
|
||||
private const RS384 = 'RS384';
|
||||
private const RS512 = 'RS512';
|
||||
|
||||
private string $algorithm;
|
||||
/** @var Key[] */
|
||||
private array $keys;
|
||||
|
||||
/**
|
||||
* @param Key[] $keys
|
||||
*/
|
||||
public function __construct(string $algorithm, array $keys)
|
||||
{
|
||||
$this->algorithm = $algorithm;
|
||||
$this->keys = $keys;
|
||||
|
||||
if (!in_array($algorithm, self::SUPPORTED_ALGORITHM_LIST)) {
|
||||
throw new RuntimeException("Unsupported algorithm $algorithm.");
|
||||
}
|
||||
}
|
||||
|
||||
public function verify(Token $token): bool
|
||||
{
|
||||
$input = $token->getSigningInput();
|
||||
$signature = $token->getSignature();
|
||||
$kid = $token->getHeader()->getKid();
|
||||
|
||||
$functionAlgorithm = self::ALGORITHM_MAP[$this->algorithm] ?? null;
|
||||
|
||||
if (!$functionAlgorithm) {
|
||||
throw new LogicException();
|
||||
}
|
||||
|
||||
$key = array_values(
|
||||
array_filter($this->keys, fn ($key) => $key->getKid() === $kid)
|
||||
)[0] ?? null;
|
||||
|
||||
if (!$key) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$key instanceof RsaKey) {
|
||||
throw new RuntimeException("Wrong key.");
|
||||
}
|
||||
|
||||
$publicKey = openssl_pkey_get_public($this->getPemFromKey($key));
|
||||
|
||||
if ($publicKey === false) {
|
||||
throw new RuntimeException("Bad RSA public key.");
|
||||
}
|
||||
|
||||
$result = openssl_verify($input, $signature, $publicKey, $functionAlgorithm);
|
||||
|
||||
if ($result === false) {
|
||||
throw new RuntimeException("RSA public key verify error: " . openssl_error_string());
|
||||
}
|
||||
|
||||
return $result === 1;
|
||||
}
|
||||
|
||||
private function getPemFromKey(RsaKey $key): string
|
||||
{
|
||||
$publicKey = PublicKeyLoader::load([
|
||||
'n' => new BigInteger('0x' . bin2hex(Util::base64UrlDecode($key->getN())), 16),
|
||||
'e' => new BigInteger('0x' . bin2hex(Util::base64UrlDecode($key->getE())), 16),
|
||||
]);
|
||||
|
||||
$pem = $publicKey->toString('PKCS8');
|
||||
|
||||
if (!is_string($pem)) {
|
||||
throw new RuntimeException();
|
||||
}
|
||||
|
||||
return $pem;
|
||||
}
|
||||
}
|
||||
112
application/Espo/Core/Authentication/Jwt/Token.php
Normal file
112
application/Espo/Core/Authentication/Jwt/Token.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\Jwt;
|
||||
|
||||
use Espo\Core\Authentication\Jwt\Token\Header;
|
||||
use Espo\Core\Authentication\Jwt\Token\Payload;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* JWT token.
|
||||
*
|
||||
* Immutable.
|
||||
*/
|
||||
class Token
|
||||
{
|
||||
private string $token;
|
||||
private string $headerPart;
|
||||
private string $payloadPart;
|
||||
private string $signaturePart;
|
||||
private string $headerRaw;
|
||||
private string $payloadRaw;
|
||||
private string $signatureRaw;
|
||||
private Header $header;
|
||||
private Payload $payload;
|
||||
|
||||
private function __construct(string $token)
|
||||
{
|
||||
$this->token = $token;
|
||||
|
||||
$parts = explode('.', $token);
|
||||
|
||||
if (count($parts) < 3) {
|
||||
throw new RuntimeException("Too few JWT parts.");
|
||||
}
|
||||
|
||||
list($this->headerPart, $this->payloadPart, $this->signaturePart) = $parts;
|
||||
|
||||
$this->headerRaw = Util::base64UrlDecode($this->headerPart);
|
||||
$this->payloadRaw = Util::base64UrlDecode($this->payloadPart);
|
||||
$this->signatureRaw = Util::base64UrlDecode($this->signaturePart);
|
||||
|
||||
$this->header = Header::fromRaw($this->headerRaw);
|
||||
$this->payload = Payload::fromRaw($this->payloadRaw);
|
||||
}
|
||||
|
||||
public static function create(string $token): self
|
||||
{
|
||||
return new self($token);
|
||||
}
|
||||
|
||||
public function getToken(): string
|
||||
{
|
||||
return $this->token;
|
||||
}
|
||||
|
||||
public function getSigningInput(): string
|
||||
{
|
||||
return $this->headerPart . '.' . $this->payloadPart;
|
||||
}
|
||||
|
||||
public function getHeader(): Header
|
||||
{
|
||||
return $this->header;
|
||||
}
|
||||
|
||||
public function getPayload(): Payload
|
||||
{
|
||||
return $this->payload;
|
||||
}
|
||||
|
||||
public function getSignature(): string
|
||||
{
|
||||
return $this->signatureRaw;
|
||||
}
|
||||
|
||||
public function getHeaderRaw(): string
|
||||
{
|
||||
return $this->headerRaw;
|
||||
}
|
||||
|
||||
public function getPayloadRaw(): string
|
||||
{
|
||||
return $this->payloadRaw;
|
||||
}
|
||||
}
|
||||
123
application/Espo/Core/Authentication/Jwt/Token/Header.php
Normal file
123
application/Espo/Core/Authentication/Jwt/Token/Header.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\Jwt\Token;
|
||||
|
||||
use Espo\Core\Utils\Json;
|
||||
use RuntimeException;
|
||||
use JsonException;
|
||||
use stdClass;
|
||||
|
||||
/**
|
||||
* Immutable.
|
||||
*/
|
||||
class Header
|
||||
{
|
||||
private string $alg;
|
||||
private ?string $kid;
|
||||
/** @var array<string, mixed> */
|
||||
private array $data;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private function __construct(
|
||||
string $alg,
|
||||
?string $kid,
|
||||
array $data
|
||||
) {
|
||||
$this->alg = $alg;
|
||||
$this->kid = $kid;
|
||||
$this->data = $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return mixed
|
||||
*/
|
||||
public function get(string $name)
|
||||
{
|
||||
return $this->data[$name] ?? null;
|
||||
}
|
||||
|
||||
public static function fromRaw(string $raw): self
|
||||
{
|
||||
$parsed = null;
|
||||
|
||||
try {
|
||||
$parsed = Json::decode($raw);
|
||||
} catch (JsonException) {}
|
||||
|
||||
if (!$parsed instanceof stdClass) {
|
||||
throw new RuntimeException();
|
||||
}
|
||||
|
||||
$alg = self::obtainFromParsedString($parsed, 'alg');
|
||||
$kid = self::obtainFromParsedStringNull($parsed, 'kid');
|
||||
|
||||
return new self(
|
||||
$alg,
|
||||
$kid,
|
||||
get_object_vars($parsed)
|
||||
);
|
||||
}
|
||||
|
||||
/** @noinspection PhpSameParameterValueInspection */
|
||||
private static function obtainFromParsedString(stdClass $parsed, string $name): string
|
||||
{
|
||||
$value = $parsed->$name ?? null;
|
||||
|
||||
if (!is_string($value)) {
|
||||
throw new RuntimeException("No or bad `$name` in JWT header.");
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/** @noinspection PhpSameParameterValueInspection */
|
||||
private static function obtainFromParsedStringNull(stdClass $parsed, string $name): ?string
|
||||
{
|
||||
$value = $parsed->$name ?? null;
|
||||
|
||||
if ($value !== null && !is_string($value)) {
|
||||
throw new RuntimeException("Bad `$name` in JWT header.");
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
public function getAlg(): string
|
||||
{
|
||||
return $this->alg;
|
||||
}
|
||||
|
||||
public function getKid(): ?string
|
||||
{
|
||||
return $this->kid;
|
||||
}
|
||||
}
|
||||
235
application/Espo/Core/Authentication/Jwt/Token/Payload.php
Normal file
235
application/Espo/Core/Authentication/Jwt/Token/Payload.php
Normal file
@@ -0,0 +1,235 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\Jwt\Token;
|
||||
|
||||
use Espo\Core\Utils\Json;
|
||||
use RuntimeException;
|
||||
use JsonException;
|
||||
use stdClass;
|
||||
|
||||
/**
|
||||
* Immutable.
|
||||
*/
|
||||
class Payload
|
||||
{
|
||||
private ?string $sub;
|
||||
private ?string $iss;
|
||||
/** @var string[] */
|
||||
private array $aud;
|
||||
private ?int $exp;
|
||||
private ?int $iat;
|
||||
private ?int $nbf;
|
||||
private ?string $nonce;
|
||||
private ?int $authTime;
|
||||
private ?string $sid;
|
||||
/** @var array<string, mixed> */
|
||||
private array $data;
|
||||
|
||||
/**
|
||||
* @param string[] $aud
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private function __construct(
|
||||
?string $sub,
|
||||
?string $iss,
|
||||
array $aud,
|
||||
?int $exp,
|
||||
?int $iat,
|
||||
?int $nbf,
|
||||
?string $nonce,
|
||||
?int $authTime,
|
||||
?string $sid,
|
||||
array $data
|
||||
) {
|
||||
$this->sub = $sub;
|
||||
$this->iss = $iss;
|
||||
$this->aud = $aud;
|
||||
$this->exp = $exp;
|
||||
$this->iat = $iat;
|
||||
$this->nbf = $nbf;
|
||||
$this->nonce = $nonce;
|
||||
$this->authTime = $authTime;
|
||||
$this->sid = $sid;
|
||||
$this->data = $data;
|
||||
}
|
||||
|
||||
public function getSub(): ?string
|
||||
{
|
||||
return $this->sub;
|
||||
}
|
||||
|
||||
public function getIss(): ?string
|
||||
{
|
||||
return $this->iss;
|
||||
}
|
||||
|
||||
public function getExp(): ?int
|
||||
{
|
||||
return $this->exp;
|
||||
}
|
||||
|
||||
public function getIat(): ?int
|
||||
{
|
||||
return $this->iat;
|
||||
}
|
||||
|
||||
public function getNbf(): ?int
|
||||
{
|
||||
return $this->nbf;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getAud(): array
|
||||
{
|
||||
return $this->aud;
|
||||
}
|
||||
|
||||
public function getNonce(): ?string
|
||||
{
|
||||
return $this->nonce;
|
||||
}
|
||||
|
||||
public function getAuthTime(): ?int
|
||||
{
|
||||
return $this->authTime;
|
||||
}
|
||||
|
||||
/** @noinspection PhpUnused */
|
||||
public function getSid(): ?string
|
||||
{
|
||||
return $this->sid;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return mixed
|
||||
*/
|
||||
public function get(string $name)
|
||||
{
|
||||
return $this->data[$name] ?? null;
|
||||
}
|
||||
|
||||
public static function fromRaw(string $raw): self
|
||||
{
|
||||
$parsed = null;
|
||||
|
||||
try {
|
||||
$parsed = Json::decode($raw);
|
||||
} catch (JsonException) {}
|
||||
|
||||
if (!$parsed instanceof stdClass) {
|
||||
throw new RuntimeException();
|
||||
}
|
||||
|
||||
$sub = $parsed->sub ?? null;
|
||||
$iss = $parsed->iss ?? null;
|
||||
$aud = $parsed->aud ?? null;
|
||||
$exp = $parsed->exp ?? null;
|
||||
$iat = $parsed->iat ?? null;
|
||||
$nbf = $parsed->nbf ?? null;
|
||||
$nonce = $parsed->nonce ?? null;
|
||||
$authTime = $parsed->auth_time ?? null;
|
||||
$sid = $parsed->sid ?? null;
|
||||
|
||||
if (is_string($aud)) {
|
||||
$aud = [$aud];
|
||||
}
|
||||
|
||||
if ($aud === null) {
|
||||
$aud = [];
|
||||
}
|
||||
|
||||
if ($iss !== null && !is_string($sub)) {
|
||||
throw new RuntimeException("Bad `sub`.");
|
||||
}
|
||||
|
||||
if ($iss !== null && !is_string($iss)) {
|
||||
throw new RuntimeException("Bad `iss`.");
|
||||
}
|
||||
|
||||
if (!is_array($aud)) {
|
||||
throw new RuntimeException("Bad `aud`.");
|
||||
}
|
||||
|
||||
if ($exp !== null && !is_numeric($exp)) {
|
||||
throw new RuntimeException("Bad `exp`.");
|
||||
}
|
||||
|
||||
if ($iat !== null && !is_numeric($iat)) {
|
||||
throw new RuntimeException("Bad `iat`.");
|
||||
}
|
||||
|
||||
if ($nbf !== null && !is_numeric($nbf)) {
|
||||
throw new RuntimeException("Bad `nbf`.");
|
||||
}
|
||||
|
||||
if ($nonce !== null && !is_string($nonce)) {
|
||||
throw new RuntimeException("Bad `nonce`.");
|
||||
}
|
||||
|
||||
if ($authTime !== null && !is_numeric($authTime)) {
|
||||
throw new RuntimeException("Bad `auth_time`.");
|
||||
}
|
||||
|
||||
if ($sid !== null && !is_string($sid)) {
|
||||
throw new RuntimeException("Bad `sid`.");
|
||||
}
|
||||
|
||||
if ($exp !== null) {
|
||||
$exp = (int) $exp;
|
||||
}
|
||||
|
||||
if ($iat !== null) {
|
||||
$iat = (int) $iat;
|
||||
}
|
||||
|
||||
if ($nbf !== null) {
|
||||
$nbf = (int) $nbf;
|
||||
}
|
||||
|
||||
if ($authTime !== null) {
|
||||
$authTime = (int) $authTime;
|
||||
}
|
||||
|
||||
return new self(
|
||||
$sub,
|
||||
$iss,
|
||||
$aud,
|
||||
$exp,
|
||||
$iat,
|
||||
$nbf,
|
||||
$nonce,
|
||||
$authTime,
|
||||
$sid,
|
||||
get_object_vars($parsed)
|
||||
);
|
||||
}
|
||||
}
|
||||
51
application/Espo/Core/Authentication/Jwt/Util.php
Normal file
51
application/Espo/Core/Authentication/Jwt/Util.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\Jwt;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class Util
|
||||
{
|
||||
public static function base64UrlDecode(string $string): string
|
||||
{
|
||||
$extra = 4 - strlen($string) % 4;
|
||||
$extra = $extra < 4 ? $extra : 0;
|
||||
|
||||
$preparedString = strtr($string . str_repeat('=', $extra), '-_', '+/');
|
||||
|
||||
$decoded = base64_decode($preparedString, true);
|
||||
|
||||
if ($decoded === false) {
|
||||
throw new RuntimeException("Base64url decoding error.");
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
}
|
||||
68
application/Espo/Core/Authentication/Jwt/Validator.php
Normal file
68
application/Espo/Core/Authentication/Jwt/Validator.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\Jwt;
|
||||
|
||||
use Espo\Core\Authentication\Jwt\Exceptions\Expired;
|
||||
use Espo\Core\Authentication\Jwt\Exceptions\NotBefore;
|
||||
|
||||
class Validator
|
||||
{
|
||||
private const DEFAULT_TIME_LEEWAY = 60 * 4;
|
||||
private int $timeLeeway;
|
||||
private ?int $now;
|
||||
|
||||
public function __construct(
|
||||
?int $timeLeeway = null,
|
||||
?int $now = null
|
||||
) {
|
||||
$this->timeLeeway = $timeLeeway ?? self::DEFAULT_TIME_LEEWAY;
|
||||
$this->now = $now;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Expired
|
||||
* @throws NotBefore
|
||||
*/
|
||||
public function validate(Token $token): void
|
||||
{
|
||||
$exp = $token->getPayload()->getExp();
|
||||
$nbf = $token->getPayload()->getNbf();
|
||||
|
||||
$now = $this->now ?? time();
|
||||
|
||||
if ($exp && $exp + $this->timeLeeway <= $now) {
|
||||
throw new Expired("JWT expired.");
|
||||
}
|
||||
|
||||
if ($nbf && $now < $nbf - $this->timeLeeway) {
|
||||
throw new NotBefore("JWT used before allowed time.");
|
||||
}
|
||||
}
|
||||
}
|
||||
34
application/Espo/Core/Authentication/Ldap/Client.php
Normal file
34
application/Espo/Core/Authentication/Ldap/Client.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\Ldap;
|
||||
|
||||
use Laminas\Ldap\Ldap;
|
||||
|
||||
class Client extends Ldap {}
|
||||
44
application/Espo/Core/Authentication/Ldap/ClientFactory.php
Normal file
44
application/Espo/Core/Authentication/Ldap/ClientFactory.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\Ldap;
|
||||
|
||||
use Laminas\Ldap\Exception\LdapException;
|
||||
|
||||
class ClientFactory
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
* @throws LdapException
|
||||
*/
|
||||
public function create(array $options): Client
|
||||
{
|
||||
return new Client($options);
|
||||
}
|
||||
}
|
||||
488
application/Espo/Core/Authentication/Ldap/LdapLogin.php
Normal file
488
application/Espo/Core/Authentication/Ldap/LdapLogin.php
Normal file
@@ -0,0 +1,488 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\Ldap;
|
||||
|
||||
use Espo\Core\Api\Util;
|
||||
use Espo\Core\FieldProcessing\Relation\LinkMultipleSaver;
|
||||
use Espo\Core\FieldProcessing\EmailAddress\Saver as EmailAddressSaver;
|
||||
use Espo\Core\FieldProcessing\PhoneNumber\Saver as PhoneNumberSaver;
|
||||
use Espo\Core\FieldProcessing\Saver\Params as SaverParams;
|
||||
use Espo\Core\Api\Request;
|
||||
use Espo\Core\Authentication\AuthToken\AuthToken;
|
||||
use Espo\Core\Authentication\Ldap\Client as Client;
|
||||
use Espo\Core\Authentication\Ldap\ClientFactory as ClientFactory;
|
||||
use Espo\Core\Authentication\Ldap\Utils as LDAPUtils;
|
||||
use Espo\Core\Authentication\Login;
|
||||
use Espo\Core\Authentication\Login\Data;
|
||||
use Espo\Core\Authentication\Logins\Espo;
|
||||
use Espo\Core\Authentication\Result;
|
||||
use Espo\Core\Authentication\Result\FailReason;
|
||||
use Espo\Core\Name\Field;
|
||||
use Espo\Core\ORM\EntityManager;
|
||||
use Espo\Core\ORM\Repository\Option\SaveOption;
|
||||
use Espo\Core\Utils\Config;
|
||||
use Espo\Core\Utils\Language;
|
||||
use Espo\Core\Utils\Log;
|
||||
use Espo\Core\Utils\PasswordHash;
|
||||
use Espo\Entities\User;
|
||||
use Exception;
|
||||
use Laminas\Ldap\Exception\LdapException;
|
||||
use Laminas\Ldap\Ldap;
|
||||
use SensitiveParameter;
|
||||
|
||||
/**
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
class LdapLogin implements Login
|
||||
{
|
||||
public const NAME = 'LDAP';
|
||||
|
||||
private LDAPUtils $utils;
|
||||
private ?Client $client = null;
|
||||
|
||||
private Language $language;
|
||||
|
||||
public function __construct(
|
||||
private Config $config,
|
||||
private EntityManager $entityManager,
|
||||
private PasswordHash $passwordHash,
|
||||
Language $defaultLanguage,
|
||||
private Log $log,
|
||||
private Espo $baseLogin,
|
||||
private ClientFactory $clientFactory,
|
||||
private LinkMultipleSaver $linkMultipleSaver,
|
||||
private EmailAddressSaver $emailAddressSaver,
|
||||
private PhoneNumberSaver $phoneNumberSaver,
|
||||
private Util $util,
|
||||
private bool $isPortal = false
|
||||
) {
|
||||
$this->language = $defaultLanguage;
|
||||
|
||||
$this->utils = new LDAPUtils($config);
|
||||
}
|
||||
|
||||
/**
|
||||
* @var array<string, string>
|
||||
* @noinspection PhpUnusedPrivateFieldInspection
|
||||
*/
|
||||
private $ldapFieldMap = [
|
||||
'userName' => 'userNameAttribute',
|
||||
'firstName' => 'userFirstNameAttribute',
|
||||
'lastName' => 'userLastNameAttribute',
|
||||
'title' => 'userTitleAttribute',
|
||||
'emailAddress' => 'userEmailAddressAttribute',
|
||||
'phoneNumber' => 'userPhoneNumberAttribute',
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array<string, string>
|
||||
* @noinspection PhpUnusedPrivateFieldInspection
|
||||
*/
|
||||
private $userFieldMap = [
|
||||
'teamsIds' => 'userTeamsIds',
|
||||
'defaultTeamId' => 'userDefaultTeamId',
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array<string, string>
|
||||
* @noinspection PhpUnusedPrivateFieldInspection
|
||||
*/
|
||||
private $portalUserFieldMap = [
|
||||
'portalsIds' => 'portalUserPortalsIds',
|
||||
'portalRolesIds' => 'portalUserRolesIds',
|
||||
];
|
||||
|
||||
/**
|
||||
* @throws LdapException
|
||||
*/
|
||||
public function login(Data $data, Request $request): Result
|
||||
{
|
||||
$username = $data->getUsername();
|
||||
$password = $data->getPassword();
|
||||
$authToken = $data->getAuthToken();
|
||||
|
||||
$isPortal = $this->isPortal;
|
||||
|
||||
if ($authToken) {
|
||||
$user = $this->loginByToken($username, $authToken, $request);
|
||||
|
||||
if ($user) {
|
||||
return Result::success($user);
|
||||
}
|
||||
|
||||
return Result::fail(FailReason::WRONG_CREDENTIALS);
|
||||
}
|
||||
|
||||
if (!$password || $username == '**logout') {
|
||||
return Result::fail(FailReason::NO_PASSWORD);
|
||||
}
|
||||
|
||||
if ($isPortal) {
|
||||
$useLdapAuthForPortalUser = $this->utils->getOption('portalUserLdapAuth');
|
||||
|
||||
if (!$useLdapAuthForPortalUser) {
|
||||
return $this->baseLogin->login($data, $request);
|
||||
}
|
||||
}
|
||||
|
||||
$ldapClient = $this->getLdapClient();
|
||||
|
||||
/* Login LDAP system user (ldapUsername, ldapPassword) */
|
||||
try {
|
||||
$ldapClient->bind();
|
||||
} catch (Exception $e) {
|
||||
$options = $this->utils->getLdapClientOptions();
|
||||
|
||||
$this->log->error("LDAP: Could not connect to LDAP server host. {message}", [
|
||||
'host' => $options['host'],
|
||||
'message' => $e->getMessage()
|
||||
]);
|
||||
|
||||
/** @var string $username */
|
||||
|
||||
$adminUser = $this->adminLogin($username, $password);
|
||||
|
||||
if (!isset($adminUser)) {
|
||||
return Result::fail();
|
||||
}
|
||||
|
||||
$this->log->info("LDAP: Administrator '{username}' was logged in by Espo method.", [
|
||||
'username' => $username,
|
||||
]);
|
||||
}
|
||||
|
||||
$userDn = null;
|
||||
|
||||
if (!isset($adminUser)) {
|
||||
/** @var string $username */
|
||||
|
||||
try {
|
||||
$userDn = $this->findLdapUserDnByUsername($username);
|
||||
} catch (Exception $e) {
|
||||
$this->log->error("Error while finding DN for '{username}'. {message}", [
|
||||
'username' => $username,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
if (!isset($userDn)) {
|
||||
$this->log->error("LDAP: Authentication failed for '{username}'; user is not found.", [
|
||||
'username' => $username,
|
||||
]);
|
||||
|
||||
$adminUser = $this->adminLogin($username, $password);
|
||||
|
||||
if (!isset($adminUser)) {
|
||||
return Result::fail();
|
||||
}
|
||||
|
||||
$this->log->info("LDAP: Administrator '{username}' was logged in by Espo method.", [
|
||||
'username' => $username,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->log->debug("User '{username}' with DN '{dn}' is found .", [
|
||||
'username' => $username,
|
||||
'dn' => $userDn,
|
||||
]);
|
||||
|
||||
try {
|
||||
$ldapClient->bind($userDn, $password);
|
||||
} catch (Exception $e) {
|
||||
$this->log->error("LDAP: Authentication failed for '{username}'. {message}", [
|
||||
'username' => $username,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return Result::fail();
|
||||
}
|
||||
}
|
||||
|
||||
$user = $this->entityManager
|
||||
->getRDBRepository(User::ENTITY_TYPE)
|
||||
->where([
|
||||
'userName' => $username,
|
||||
'type!=' => [
|
||||
User::TYPE_API,
|
||||
User::TYPE_SYSTEM,
|
||||
User::TYPE_SUPER_ADMIN,
|
||||
],
|
||||
])
|
||||
->findOne();
|
||||
|
||||
if (!isset($user)) {
|
||||
if (!$this->utils->getOption('createEspoUser')) {
|
||||
$this->log->warning("LDAP: '{username}' authenticated, but user is not created in Espo.", [
|
||||
'username' => $username,
|
||||
]);
|
||||
|
||||
return Result::fail(FailReason::USER_NOT_FOUND);
|
||||
}
|
||||
|
||||
/** @var string $userDn */
|
||||
|
||||
$userData = $ldapClient->getEntry($userDn);
|
||||
|
||||
$user = $this->createUser($userData, $isPortal);
|
||||
}
|
||||
|
||||
if (!$user) {
|
||||
return Result::fail();
|
||||
}
|
||||
|
||||
return Result::success($user);
|
||||
}
|
||||
|
||||
private function getLdapClient(): Client
|
||||
{
|
||||
if (!isset($this->client)) {
|
||||
$options = $this->utils->getLdapClientOptions();
|
||||
|
||||
try {
|
||||
$this->client = $this->clientFactory->create($options);
|
||||
} catch (Exception $e) {
|
||||
$this->log->error("LDAP error. {message}", ['message' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
/** @var Client */
|
||||
return $this->client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Login by authorization token.
|
||||
*/
|
||||
private function loginByToken(?string $username, AuthToken $authToken, Request $request): ?User
|
||||
{
|
||||
if ($username === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$userId = $authToken->getUserId();
|
||||
|
||||
/** @var ?User $user */
|
||||
$user = $this->entityManager->getEntityById(User::ENTITY_TYPE, $userId);
|
||||
|
||||
if (!$user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tokenUsername = $user->getUserName() ?? '';
|
||||
|
||||
if (strtolower($username) !== strtolower($tokenUsername)) {
|
||||
$ip = $this->util->obtainIpFromRequest($request);
|
||||
|
||||
$this->log->alert("Unauthorized access attempt for user '{username}' from IP '{ip}'.", [
|
||||
'username' => $username,
|
||||
'ip' => $ip,
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var ?User */
|
||||
return $this->entityManager
|
||||
->getRDBRepository(User::ENTITY_TYPE)
|
||||
->where([
|
||||
'userName' => $username,
|
||||
])
|
||||
->findOne();
|
||||
}
|
||||
|
||||
private function adminLogin(string $username, #[SensitiveParameter] string $password): ?User
|
||||
{
|
||||
$user = $this->entityManager
|
||||
->getRDBRepositoryByClass(User::class)
|
||||
->where([
|
||||
'userName' => $username,
|
||||
'type' => [User::TYPE_ADMIN, User::TYPE_SUPER_ADMIN],
|
||||
])
|
||||
->findOne();
|
||||
|
||||
if (!$user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!$this->passwordHash->verify($password, $user->getPassword())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Espo user with data gets from LDAP server.
|
||||
*
|
||||
* @param array<string, mixed> $userData
|
||||
*/
|
||||
private function createUser(array $userData, bool $isPortal = false): ?User
|
||||
{
|
||||
$this->log->info("LDAP: Creating new user.");
|
||||
$this->log->debug("LDAP: user data: {userData}", ['userData' => print_r($userData, true)]);
|
||||
|
||||
$data = [];
|
||||
|
||||
$ldapFields = $this->loadFields('ldap');
|
||||
|
||||
foreach ($ldapFields as $espo => $ldap) {
|
||||
$ldap = strtolower($ldap);
|
||||
|
||||
if (isset($userData[$ldap][0])) {
|
||||
$this->log->debug("LDAP: Create a user with [{user1}] = [{user2}].", [
|
||||
'user1' => $espo,
|
||||
'user2' => $userData[$ldap][0],
|
||||
]);
|
||||
|
||||
$data[$espo] = $userData[$ldap][0];
|
||||
}
|
||||
}
|
||||
|
||||
if ($isPortal) {
|
||||
$userAttributes = $this->loadFields('portalUser');
|
||||
|
||||
$userAttributes['type'] = User::TYPE_PORTAL;
|
||||
} else {
|
||||
$userAttributes = $this->loadFields('user');
|
||||
}
|
||||
|
||||
foreach ($userAttributes as $key => $value) {
|
||||
$data[$key] = $value;
|
||||
}
|
||||
|
||||
$user = $this->entityManager->getRDBRepositoryByClass(User::class)->getNew();
|
||||
|
||||
$user->setMultiple($data);
|
||||
|
||||
$this->entityManager->saveEntity($user, [
|
||||
// Prevent `user` service being loaded by hooks.
|
||||
SaveOption::SKIP_HOOKS => true,
|
||||
SaveOption::KEEP_NEW => true,
|
||||
]);
|
||||
|
||||
$saverParams = SaverParams::create()->withRawOptions(['skipLinkMultipleHooks' => true]);
|
||||
|
||||
$this->linkMultipleSaver->process($user, Field::TEAMS, $saverParams);
|
||||
$this->linkMultipleSaver->process($user, 'portals', $saverParams);
|
||||
$this->linkMultipleSaver->process($user, 'portalRoles', $saverParams);
|
||||
$this->emailAddressSaver->process($user, $saverParams);
|
||||
$this->phoneNumberSaver->process($user, $saverParams);
|
||||
|
||||
$user->setAsNotNew();
|
||||
$user->updateFetchedValues();
|
||||
|
||||
return $this->entityManager->getRDBRepositoryByClass(User::class)->getById($user->getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Find LDAP user DN by their username.
|
||||
*
|
||||
* @throws LdapException
|
||||
*/
|
||||
private function findLdapUserDnByUsername(string $username): ?string
|
||||
{
|
||||
$ldapClient = $this->getLdapClient();
|
||||
$options = $this->utils->getOptions();
|
||||
|
||||
$filterString = !empty($options['userLoginFilter']) ?
|
||||
$this->convertToFilterFormat($options['userLoginFilter']) : '';
|
||||
|
||||
$objectClass = $options['userObjectClass'];
|
||||
$attribute = $options['userNameAttribute'];
|
||||
$usernameEscaped = $this->escapeUsernameFilter($username);
|
||||
|
||||
$searchString = "(&(objectClass=$objectClass)($attribute=$usernameEscaped)$filterString)";
|
||||
|
||||
/** @var array<int, array{dn: string}> $result */
|
||||
/** @noinspection PhpRedundantOptionalArgumentInspection */
|
||||
$result = $ldapClient->search($searchString, null, Ldap::SEARCH_SCOPE_SUB);
|
||||
|
||||
$this->log->debug("LDAP: user search string: {string}.", ['string' => $searchString]);
|
||||
|
||||
foreach ($result as $item) {
|
||||
return $item['dn'];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function escapeUsernameFilter(string $username): string
|
||||
{
|
||||
$map = [
|
||||
'\\' => '\\5c',
|
||||
'*' => '\\2a',
|
||||
'(' => '\\28',
|
||||
')' => '\\29',
|
||||
"\x00" => '\\00',
|
||||
];
|
||||
|
||||
return strtr($username, $map);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check and convert filter item into LDAP format.
|
||||
*/
|
||||
private function convertToFilterFormat(string $filter): string
|
||||
{
|
||||
$filter = trim($filter);
|
||||
|
||||
if (!str_starts_with($filter, '(')) {
|
||||
$filter = '(' . $filter;
|
||||
}
|
||||
|
||||
if (!str_ends_with($filter, ')')) {
|
||||
$filter = $filter . ')';
|
||||
}
|
||||
|
||||
return $filter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load fields for a user.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function loadFields(string $type): array
|
||||
{
|
||||
$options = $this->utils->getOptions();
|
||||
|
||||
$typeMap = $type . 'FieldMap';
|
||||
|
||||
$fields = [];
|
||||
|
||||
foreach ($this->$typeMap as $fieldName => $fieldValue) {
|
||||
/** @var string $fieldName */
|
||||
if (isset($options[$fieldValue])) {
|
||||
$fields[$fieldName] = $options[$fieldValue];
|
||||
}
|
||||
}
|
||||
|
||||
return $fields;
|
||||
}
|
||||
}
|
||||
193
application/Espo/Core/Authentication/Ldap/Utils.php
Normal file
193
application/Espo/Core/Authentication/Ldap/Utils.php
Normal file
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\Ldap;
|
||||
|
||||
use Espo\Core\Utils\Config;
|
||||
|
||||
class Utils
|
||||
{
|
||||
private Config $config;
|
||||
|
||||
/**
|
||||
* @var ?array<string, mixed>
|
||||
*/
|
||||
private ?array $options = null;
|
||||
|
||||
/**
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private $fieldMap = [
|
||||
'host' => 'ldapHost',
|
||||
'port' => 'ldapPort',
|
||||
'useSsl' => 'ldapSecurity',
|
||||
'useStartTls' => 'ldapSecurity',
|
||||
'username' => 'ldapUsername',
|
||||
'password' => 'ldapPassword',
|
||||
'bindRequiresDn' => 'ldapBindRequiresDn',
|
||||
'baseDn' => 'ldapBaseDn',
|
||||
'accountCanonicalForm' => 'ldapAccountCanonicalForm',
|
||||
'accountDomainName' => 'ldapAccountDomainName',
|
||||
'accountDomainNameShort' => 'ldapAccountDomainNameShort',
|
||||
'accountFilterFormat' => 'ldapAccountFilterFormat',
|
||||
'optReferrals' => 'ldapOptReferrals',
|
||||
'tryUsernameSplit' => 'ldapTryUsernameSplit',
|
||||
'networkTimeout' => 'ldapNetworkTimeout',
|
||||
'createEspoUser' => 'ldapCreateEspoUser',
|
||||
'userNameAttribute' => 'ldapUserNameAttribute',
|
||||
'userTitleAttribute' => 'ldapUserTitleAttribute',
|
||||
'userFirstNameAttribute' => 'ldapUserFirstNameAttribute',
|
||||
'userLastNameAttribute' => 'ldapUserLastNameAttribute',
|
||||
'userEmailAddressAttribute' => 'ldapUserEmailAddressAttribute',
|
||||
'userPhoneNumberAttribute' => 'ldapUserPhoneNumberAttribute',
|
||||
'userLoginFilter' => 'ldapUserLoginFilter',
|
||||
'userTeamsIds' => 'ldapUserTeamsIds',
|
||||
'userDefaultTeamId' => 'ldapUserDefaultTeamId',
|
||||
'userObjectClass' => 'ldapUserObjectClass',
|
||||
'portalUserLdapAuth' => 'ldapPortalUserLdapAuth',
|
||||
'portalUserPortalsIds' => 'ldapPortalUserPortalsIds',
|
||||
'portalUserRolesIds' => 'ldapPortalUserRolesIds',
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private $permittedEspoOptions = [
|
||||
'createEspoUser',
|
||||
'userNameAttribute',
|
||||
'userObjectClass',
|
||||
'userTitleAttribute',
|
||||
'userFirstNameAttribute',
|
||||
'userLastNameAttribute',
|
||||
'userEmailAddressAttribute',
|
||||
'userPhoneNumberAttribute',
|
||||
'userLoginFilter',
|
||||
'userTeamsIds',
|
||||
'userDefaultTeamId',
|
||||
'portalUserLdapAuth',
|
||||
'portalUserPortalsIds',
|
||||
'portalUserRolesIds',
|
||||
];
|
||||
|
||||
/**
|
||||
* AccountCanonicalForm Map between Espo and Laminas value.
|
||||
*
|
||||
* @var array<string, int>
|
||||
*/
|
||||
private $accountCanonicalFormMap = [
|
||||
'Dn' => 1,
|
||||
'Username' => 2,
|
||||
'Backslash' => 3,
|
||||
'Principal' => 4,
|
||||
];
|
||||
|
||||
public function __construct(?Config $config = null)
|
||||
{
|
||||
if (isset($config)) {
|
||||
$this->config = $config;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Options from espo config according to $this->fieldMap.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getOptions(): array
|
||||
{
|
||||
if (isset($this->options)) {
|
||||
return $this->options;
|
||||
}
|
||||
|
||||
$options = [];
|
||||
|
||||
foreach ($this->fieldMap as $ldapName => $espoName) {
|
||||
$option = $this->config->get($espoName);
|
||||
|
||||
if (isset($option)) {
|
||||
$options[$ldapName] = $option;
|
||||
}
|
||||
}
|
||||
|
||||
$this->options = $this->normalizeOptions($options);
|
||||
|
||||
return $this->options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize options to LDAP client format
|
||||
*
|
||||
* @param array<string, mixed> $options
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function normalizeOptions(array $options): array
|
||||
{
|
||||
$useSsl = ($options['useSsl'] ?? null) == 'SSL';
|
||||
$useStartTls = ($options['useStartTls'] ?? null) == 'TLS';
|
||||
$accountCanonicalFormKey = $options['accountCanonicalForm'] ?? 'Dn';
|
||||
|
||||
$options['useSsl'] = $useSsl;
|
||||
$options['useStartTls'] = $useStartTls;
|
||||
$options['accountCanonicalForm'] = $this->accountCanonicalFormMap[$accountCanonicalFormKey] ?? 1;
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an LDAP option.
|
||||
*
|
||||
* @param string $name
|
||||
* @param mixed $returns A default value.
|
||||
* @return mixed
|
||||
*/
|
||||
public function getOption($name, $returns = null)
|
||||
{
|
||||
if (!isset($this->options)) {
|
||||
$this->getOptions();
|
||||
}
|
||||
|
||||
if (isset($this->options[$name])) {
|
||||
return $this->options[$name];
|
||||
}
|
||||
|
||||
return $returns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Laminas options for using Laminas\Ldap.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getLdapClientOptions(): array
|
||||
{
|
||||
$options = $this->getOptions();
|
||||
|
||||
return array_diff_key($options, array_flip($this->permittedEspoOptions));
|
||||
}
|
||||
}
|
||||
45
application/Espo/Core/Authentication/Login.php
Normal file
45
application/Espo/Core/Authentication/Login.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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;
|
||||
|
||||
use Espo\Core\Api\Request;
|
||||
use Espo\Core\Authentication\Login\Data;
|
||||
|
||||
/**
|
||||
* Performs credentials checking. For the basic authorization a username & password are used.
|
||||
* For other authorization methods credentials must be fetched from the request.
|
||||
*/
|
||||
interface Login
|
||||
{
|
||||
/**
|
||||
* Check credentials.
|
||||
*/
|
||||
public function login(Data $data, Request $request): Result;
|
||||
}
|
||||
88
application/Espo/Core/Authentication/Login/Data.php
Normal file
88
application/Espo/Core/Authentication/Login/Data.php
Normal file
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\Login;
|
||||
|
||||
use Espo\Core\Authentication\AuthToken\AuthToken;
|
||||
use SensitiveParameter;
|
||||
|
||||
/**
|
||||
* Login data to be passed to the 'login' method.
|
||||
*/
|
||||
class Data
|
||||
{
|
||||
private ?string $username;
|
||||
private ?string $password;
|
||||
private ?AuthToken $authToken;
|
||||
|
||||
public function __construct(
|
||||
?string $username,
|
||||
#[SensitiveParameter] ?string $password,
|
||||
?AuthToken $authToken = null
|
||||
) {
|
||||
$this->username = $username;
|
||||
$this->password = $password;
|
||||
$this->authToken = $authToken;
|
||||
}
|
||||
|
||||
public function getUsername(): ?string
|
||||
{
|
||||
return $this->username;
|
||||
}
|
||||
|
||||
public function getPassword(): ?string
|
||||
{
|
||||
return $this->password;
|
||||
}
|
||||
|
||||
public function getAuthToken(): ?AuthToken
|
||||
{
|
||||
return $this->authToken;
|
||||
}
|
||||
|
||||
public function hasUsername(): bool
|
||||
{
|
||||
return !is_null($this->username);
|
||||
}
|
||||
|
||||
public function hasPassword(): bool
|
||||
{
|
||||
return !is_null($this->password);
|
||||
}
|
||||
|
||||
public function hasAuthToken(): bool
|
||||
{
|
||||
return !is_null($this->authToken);
|
||||
}
|
||||
|
||||
public static function createBuilder(): DataBuilder
|
||||
{
|
||||
return new DataBuilder();
|
||||
}
|
||||
}
|
||||
66
application/Espo/Core/Authentication/Login/DataBuilder.php
Normal file
66
application/Espo/Core/Authentication/Login/DataBuilder.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\Login;
|
||||
|
||||
use Espo\Core\Authentication\AuthToken\AuthToken;
|
||||
use SensitiveParameter;
|
||||
|
||||
class DataBuilder
|
||||
{
|
||||
private ?string $username = null;
|
||||
private ?string $password = null;
|
||||
private ?AuthToken $authToken = null;
|
||||
|
||||
public function setUsername(?string $username): self
|
||||
{
|
||||
$this->username = $username;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setPassword(#[SensitiveParameter] ?string $password): self
|
||||
{
|
||||
$this->password = $password;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setAuthToken(?AuthToken $authToken): self
|
||||
{
|
||||
$this->authToken = $authToken;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function build(): Data
|
||||
{
|
||||
return new Data($this->username, $this->password, $this->authToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\Login;
|
||||
|
||||
/**
|
||||
* Immutable.
|
||||
*/
|
||||
class MetadataParams
|
||||
{
|
||||
private string $method;
|
||||
private ?string $credentialsHeader;
|
||||
private bool $api;
|
||||
|
||||
public function __construct(
|
||||
string $method,
|
||||
?string $credentialsHeader = null,
|
||||
bool $api = false
|
||||
) {
|
||||
$this->method = $method;
|
||||
$this->credentialsHeader = $credentialsHeader;
|
||||
$this->api = $api;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public static function fromRaw(string $method, array $data): self
|
||||
{
|
||||
return new self(
|
||||
$method,
|
||||
$data['credentialsHeader'] ?? null,
|
||||
$data['api'] ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
public function getMethod(): string
|
||||
{
|
||||
return $this->method;
|
||||
}
|
||||
|
||||
public function getCredentialsHeader(): ?string
|
||||
{
|
||||
return $this->credentialsHeader;
|
||||
}
|
||||
|
||||
public function isApi(): bool
|
||||
{
|
||||
return $this->api;
|
||||
}
|
||||
}
|
||||
66
application/Espo/Core/Authentication/LoginFactory.php
Normal file
66
application/Espo/Core/Authentication/LoginFactory.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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;
|
||||
|
||||
use Espo\Core\InjectableFactory;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
|
||||
class LoginFactory
|
||||
{
|
||||
public function __construct(
|
||||
private InjectableFactory $injectableFactory,
|
||||
private Metadata $metadata,
|
||||
private ConfigDataProvider $configDataProvider
|
||||
) {}
|
||||
|
||||
public function create(string $method, bool $isPortal = false): Login
|
||||
{
|
||||
/** @var class-string<Login> $className */
|
||||
$className = $this->metadata->get(['authenticationMethods', $method, 'implementationClassName']);
|
||||
|
||||
if (!$className) {
|
||||
$sanitizedName = preg_replace('/[^a-zA-Z0-9]+/', '', $method);
|
||||
|
||||
/** @var class-string<Login> $className */
|
||||
$className = "Espo\\Core\\Authentication\\Logins\\" . $sanitizedName;
|
||||
}
|
||||
|
||||
return $this->injectableFactory->createWith($className, [
|
||||
'isPortal' => $isPortal,
|
||||
]);
|
||||
}
|
||||
|
||||
public function createDefault(): Login
|
||||
{
|
||||
$method = $this->configDataProvider->getDefaultAuthenticationMethod();
|
||||
|
||||
return $this->create($method);
|
||||
}
|
||||
}
|
||||
66
application/Espo/Core/Authentication/Logins/ApiKey.php
Normal file
66
application/Espo/Core/Authentication/Logins/ApiKey.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\Logins;
|
||||
|
||||
use Espo\Core\Api\Request;
|
||||
use Espo\Core\Authentication\Helper\UserFinder;
|
||||
use Espo\Core\Authentication\Login;
|
||||
use Espo\Core\Authentication\Login\Data;
|
||||
use Espo\Core\Authentication\Result;
|
||||
use Espo\Core\Authentication\Result\FailReason;
|
||||
|
||||
class ApiKey implements Login
|
||||
{
|
||||
public const NAME = 'ApiKey';
|
||||
|
||||
private UserFinder $userFinder;
|
||||
|
||||
public function __construct(UserFinder $userFinder)
|
||||
{
|
||||
$this->userFinder = $userFinder;
|
||||
}
|
||||
|
||||
public function login(Data $data, Request $request): Result
|
||||
{
|
||||
$apiKey = $request->getHeader('X-Api-Key');
|
||||
|
||||
if (!$apiKey) {
|
||||
return Result::fail(FailReason::WRONG_CREDENTIALS);
|
||||
}
|
||||
|
||||
$user = $this->userFinder->findApiApiKey($apiKey);
|
||||
|
||||
if (!$user) {
|
||||
return Result::fail(FailReason::WRONG_CREDENTIALS);
|
||||
}
|
||||
|
||||
return Result::success($user);
|
||||
}
|
||||
}
|
||||
83
application/Espo/Core/Authentication/Logins/Espo.php
Normal file
83
application/Espo/Core/Authentication/Logins/Espo.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\Logins;
|
||||
|
||||
use Espo\Core\Api\Request;
|
||||
use Espo\Core\Authentication\Helper\UserFinder;
|
||||
use Espo\Core\Authentication\Login;
|
||||
use Espo\Core\Authentication\Login\Data;
|
||||
use Espo\Core\Authentication\Result;
|
||||
use Espo\Core\Authentication\Result\FailReason;
|
||||
use Espo\Core\Utils\PasswordHash;
|
||||
|
||||
class Espo implements Login
|
||||
{
|
||||
public const NAME = 'Espo';
|
||||
|
||||
public function __construct(
|
||||
private UserFinder $userFinder,
|
||||
private PasswordHash $passwordHash
|
||||
) {}
|
||||
|
||||
public function login(Data $data, Request $request): Result
|
||||
{
|
||||
$username = $data->getUsername();
|
||||
$password = $data->getPassword();
|
||||
$authToken = $data->getAuthToken();
|
||||
|
||||
if (!$username) {
|
||||
return Result::fail(FailReason::NO_USERNAME);
|
||||
}
|
||||
|
||||
if (!$password) {
|
||||
return Result::fail(FailReason::NO_PASSWORD);
|
||||
}
|
||||
|
||||
if ($authToken) {
|
||||
$user = $this->userFinder->findByIdAndHash($username, $authToken->getUserId(), $authToken->getHash());
|
||||
} else {
|
||||
$user = $this->userFinder->find($username);
|
||||
|
||||
if ($user && !$this->passwordHash->verify($password, $user->getPassword())) {
|
||||
$user = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$user) {
|
||||
return Result::fail(FailReason::WRONG_CREDENTIALS);
|
||||
}
|
||||
|
||||
if ($authToken && $user->getId() !== $authToken->getUserId()) {
|
||||
return Result::fail(FailReason::USER_TOKEN_MISMATCH);
|
||||
}
|
||||
|
||||
return Result::success($user);
|
||||
}
|
||||
}
|
||||
84
application/Espo/Core/Authentication/Logins/Hmac.php
Normal file
84
application/Espo/Core/Authentication/Logins/Hmac.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\Logins;
|
||||
|
||||
use Espo\Core\Api\Request;
|
||||
use Espo\Core\Authentication\Helper\UserFinder;
|
||||
use Espo\Core\Authentication\Login;
|
||||
use Espo\Core\Authentication\Login\Data;
|
||||
use Espo\Core\Authentication\Result;
|
||||
use Espo\Core\Authentication\Result\FailReason;
|
||||
use Espo\Core\Utils\ApiKey;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class Hmac implements Login
|
||||
{
|
||||
public const NAME = 'Hmac';
|
||||
|
||||
public function __construct(private UserFinder $userFinder, private ApiKey $apiKeyUtil)
|
||||
{}
|
||||
|
||||
public function login(Data $data, Request $request): Result
|
||||
{
|
||||
$authString = base64_decode($request->getHeader('X-Hmac-Authorization') ?? '');
|
||||
|
||||
[$apiKey, $hash] = explode(':', $authString, 2);
|
||||
|
||||
if (!$apiKey) {
|
||||
return Result::fail(FailReason::WRONG_CREDENTIALS);
|
||||
}
|
||||
|
||||
$user = $this->userFinder->findApiHmac($apiKey);
|
||||
|
||||
if (!$user) {
|
||||
return Result::fail(FailReason::WRONG_CREDENTIALS);
|
||||
}
|
||||
|
||||
$secretKey = $this->apiKeyUtil->getSecretKeyForUserId($user->getId());
|
||||
|
||||
if (!$secretKey) {
|
||||
throw new RuntimeException("No secret key for API user '" . $user->getId() . "'.");
|
||||
}
|
||||
|
||||
$string = $request->getMethod() . ' ' . $request->getResourcePath();
|
||||
|
||||
// As of v8.4.1.
|
||||
if ($hash === ApiKey::hash($secretKey, $string)) {
|
||||
return Result::success($user);
|
||||
}
|
||||
|
||||
if ($hash === ApiKey::hashLegacy($secretKey, $string)) {
|
||||
return Result::success($user);
|
||||
}
|
||||
|
||||
return Result::fail(FailReason::HASH_NOT_MATCHED);
|
||||
}
|
||||
}
|
||||
42
application/Espo/Core/Authentication/Logout.php
Normal file
42
application/Espo/Core/Authentication/Logout.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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;
|
||||
|
||||
use Espo\Core\Authentication\AuthToken\AuthToken;
|
||||
use Espo\Core\Authentication\Logout\Params;
|
||||
use Espo\Core\Authentication\Logout\Result as Result;
|
||||
|
||||
/**
|
||||
* Called on auth-token destroy.
|
||||
*/
|
||||
interface Logout
|
||||
{
|
||||
public function logout(AuthToken $authToken, Params $params): Result;
|
||||
}
|
||||
43
application/Espo/Core/Authentication/Logout/Params.php
Normal file
43
application/Espo/Core/Authentication/Logout/Params.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\Logout;
|
||||
|
||||
/**
|
||||
* Immutable.
|
||||
*/
|
||||
class Params
|
||||
{
|
||||
private function __construct() {}
|
||||
|
||||
public static function create(): self
|
||||
{
|
||||
return new self();
|
||||
}
|
||||
}
|
||||
58
application/Espo/Core/Authentication/Logout/Result.php
Normal file
58
application/Espo/Core/Authentication/Logout/Result.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\Logout;
|
||||
|
||||
/**
|
||||
* Immutable.
|
||||
*/
|
||||
class Result
|
||||
{
|
||||
private ?string $redirectUrl = null;
|
||||
|
||||
private function __construct() {}
|
||||
|
||||
public static function create(): self
|
||||
{
|
||||
return new self();
|
||||
}
|
||||
|
||||
public function withRedirectUrl(?string $redirectUrl): self
|
||||
{
|
||||
$obj = clone $this;
|
||||
$obj->redirectUrl = $redirectUrl;
|
||||
|
||||
return $obj;
|
||||
}
|
||||
|
||||
public function getRedirectUrl(): ?string
|
||||
{
|
||||
return $this->redirectUrl;
|
||||
}
|
||||
}
|
||||
65
application/Espo/Core/Authentication/LogoutFactory.php
Normal file
65
application/Espo/Core/Authentication/LogoutFactory.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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;
|
||||
|
||||
use Espo\Core\InjectableFactory;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class LogoutFactory
|
||||
{
|
||||
public function __construct(private InjectableFactory $injectableFactory, private Metadata $metadata)
|
||||
{}
|
||||
|
||||
public function create(string $method): Logout
|
||||
{
|
||||
$className = $this->getClassName($method);
|
||||
|
||||
if (!$className) {
|
||||
throw new RuntimeException();
|
||||
}
|
||||
|
||||
return $this->injectableFactory->create($className);
|
||||
}
|
||||
|
||||
public function isCreatable(string $method): bool
|
||||
{
|
||||
return (bool) $this->getClassName($method);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ?class-string<Logout>
|
||||
*/
|
||||
private function getClassName(string $method): ?string
|
||||
{
|
||||
return $this->metadata->get(['authenticationMethods', $method, 'logoutClassName']);
|
||||
}
|
||||
}
|
||||
107
application/Espo/Core/Authentication/Oidc/BackchannelLogout.php
Normal file
107
application/Espo/Core/Authentication/Oidc/BackchannelLogout.php
Normal file
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\Oidc;
|
||||
|
||||
use Espo\Core\Authentication\AuthToken\AuthToken;
|
||||
use Espo\Core\Authentication\AuthToken\Manager as AuthTokenManager;
|
||||
use Espo\Core\Authentication\Jwt\Exceptions\Invalid;
|
||||
use Espo\Core\Authentication\Jwt\Exceptions\SignatureNotVerified;
|
||||
use Espo\Core\Authentication\Jwt\Token;
|
||||
use Espo\Core\Authentication\Jwt\Validator;
|
||||
use Espo\Core\Authentication\Oidc\UserProvider\UserRepository;
|
||||
use Espo\Core\Utils\Log;
|
||||
use Espo\Entities\AuthToken as AuthTokenEntity;
|
||||
use Espo\ORM\EntityManager;
|
||||
|
||||
/**
|
||||
* Compatible only with default Espo auth tokens.
|
||||
*
|
||||
* @todo Use a token-sessionId map to retrieve tokens. Send sid claim in id_token.
|
||||
*/
|
||||
class BackchannelLogout
|
||||
{
|
||||
public function __construct(
|
||||
private Log $log,
|
||||
private Validator $validator,
|
||||
private TokenValidator $tokenValidator,
|
||||
private ConfigDataProvider $configDataProvider,
|
||||
private UserRepository $userRepository,
|
||||
private EntityManager $entityManager,
|
||||
private AuthTokenManager $authTokenManger
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws SignatureNotVerified
|
||||
* @throws Invalid
|
||||
*/
|
||||
public function logout(string $rawToken): void
|
||||
{
|
||||
$token = Token::create($rawToken);
|
||||
|
||||
$this->log->debug("OIDC logout: JWT header: " . $token->getHeaderRaw());
|
||||
$this->log->debug("OIDC logout: JWT payload: " . $token->getPayloadRaw());
|
||||
|
||||
$this->validator->validate($token);
|
||||
$this->tokenValidator->validateSignature($token);
|
||||
$this->tokenValidator->validateFields($token);
|
||||
|
||||
$usernameClaim = $this->configDataProvider->getUsernameClaim();
|
||||
|
||||
if (!$usernameClaim) {
|
||||
throw new Invalid("No username claim in config.");
|
||||
}
|
||||
|
||||
$username = $token->getPayload()->get($usernameClaim);
|
||||
|
||||
if (!$username) {
|
||||
throw new Invalid("No username claim `$usernameClaim` in token.");
|
||||
}
|
||||
|
||||
$user = $this->userRepository->findByUsername($username);
|
||||
|
||||
if (!$user) {
|
||||
return;
|
||||
}
|
||||
|
||||
$authTokenList = $this->entityManager
|
||||
->getRDBRepositoryByClass(AuthTokenEntity::class)
|
||||
->where([
|
||||
'userId' => $user->getId(),
|
||||
'isActive' => true,
|
||||
])
|
||||
->find();
|
||||
|
||||
foreach ($authTokenList as $authToken) {
|
||||
assert($authToken instanceof AuthToken);
|
||||
|
||||
$this->authTokenManger->inactivate($authToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
234
application/Espo/Core/Authentication/Oidc/ConfigDataProvider.php
Normal file
234
application/Espo/Core/Authentication/Oidc/ConfigDataProvider.php
Normal file
@@ -0,0 +1,234 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\Oidc;
|
||||
|
||||
use Espo\Core\ApplicationState;
|
||||
use Espo\Core\ORM\EntityManagerProxy;
|
||||
use Espo\Core\Utils\Config;
|
||||
use Espo\Entities\AuthenticationProvider;
|
||||
use stdClass;
|
||||
|
||||
class ConfigDataProvider
|
||||
{
|
||||
private const JWKS_CACHE_PERIOD = '10 minutes';
|
||||
|
||||
private Config|AuthenticationProvider $object;
|
||||
|
||||
public function __construct(
|
||||
private Config $config,
|
||||
private ApplicationState $applicationState,
|
||||
private EntityManagerProxy $entityManager
|
||||
) {
|
||||
$this->object = $this->getAuthenticationProvider() ?? $this->config;
|
||||
}
|
||||
|
||||
private function isAuthenticationProvider(): bool
|
||||
{
|
||||
return $this->object instanceof AuthenticationProvider;
|
||||
}
|
||||
|
||||
private function getAuthenticationProvider(): ?AuthenticationProvider
|
||||
{
|
||||
if (!$this->applicationState->isPortal()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$link = $this->applicationState->getPortal()->getAuthenticationProvider();
|
||||
|
||||
if (!$link) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var ?AuthenticationProvider */
|
||||
return $this->entityManager->getEntityById(AuthenticationProvider::ENTITY_TYPE, $link->getId());
|
||||
}
|
||||
|
||||
public function getSiteUrl(): string
|
||||
{
|
||||
$siteUrl = $this->isAuthenticationProvider() ?
|
||||
$this->applicationState->getPortal()->getUrl() :
|
||||
$this->config->get('siteUrl');
|
||||
|
||||
return rtrim($siteUrl, '/');
|
||||
}
|
||||
|
||||
public function getRedirectUri(): string
|
||||
{
|
||||
return $this->getSiteUrl() . '/oauth-callback.php';
|
||||
}
|
||||
|
||||
public function getClientId(): ?string
|
||||
{
|
||||
return $this->object->get('oidcClientId');
|
||||
}
|
||||
|
||||
public function getClientSecret(): ?string
|
||||
{
|
||||
return $this->object->get('oidcClientSecret');
|
||||
}
|
||||
|
||||
public function getAuthorizationEndpoint(): ?string
|
||||
{
|
||||
return $this->object->get('oidcAuthorizationEndpoint');
|
||||
}
|
||||
|
||||
public function getTokenEndpoint(): ?string
|
||||
{
|
||||
return $this->object->get('oidcTokenEndpoint');
|
||||
}
|
||||
|
||||
public function getUserInfoEndpoint(): ?string
|
||||
{
|
||||
return $this->object->get('oidcUserInfoEndpoint');
|
||||
}
|
||||
|
||||
public function getJwksEndpoint(): ?string
|
||||
{
|
||||
return $this->object->get('oidcJwksEndpoint');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getJwtSignatureAlgorithmList(): array
|
||||
{
|
||||
return $this->object->get('oidcJwtSignatureAlgorithmList') ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getScopes(): array
|
||||
{
|
||||
/** @var string[] */
|
||||
return $this->object->get('oidcScopes') ?? [];
|
||||
}
|
||||
|
||||
public function getLogoutUrl(): ?string
|
||||
{
|
||||
return $this->object->get('oidcLogoutUrl');
|
||||
}
|
||||
|
||||
public function getUsernameClaim(): ?string
|
||||
{
|
||||
return $this->object->get('oidcUsernameClaim');
|
||||
}
|
||||
|
||||
public function createUser(): bool
|
||||
{
|
||||
return (bool) $this->object->get('oidcCreateUser');
|
||||
}
|
||||
|
||||
public function sync(): bool
|
||||
{
|
||||
return (bool) $this->object->get('oidcSync');
|
||||
}
|
||||
|
||||
public function syncTeams(): bool
|
||||
{
|
||||
if ($this->isAuthenticationProvider()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (bool) $this->config->get('oidcSyncTeams');
|
||||
}
|
||||
|
||||
public function fallback(): bool
|
||||
{
|
||||
if ($this->isAuthenticationProvider()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (bool) $this->config->get('oidcFallback');
|
||||
}
|
||||
|
||||
public function allowRegularUserFallback(): bool
|
||||
{
|
||||
if ($this->isAuthenticationProvider()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (bool) $this->config->get('oidcAllowRegularUserFallback');
|
||||
}
|
||||
|
||||
public function allowAdminUser(): bool
|
||||
{
|
||||
if ($this->isAuthenticationProvider()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (bool) $this->config->get('oidcAllowAdminUser');
|
||||
}
|
||||
|
||||
public function getGroupClaim(): ?string
|
||||
{
|
||||
if ($this->isAuthenticationProvider()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->config->get('oidcGroupClaim');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ?string[]
|
||||
*/
|
||||
public function getTeamIds(): ?array
|
||||
{
|
||||
if ($this->isAuthenticationProvider()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->config->get('oidcTeamsIds') ?? [];
|
||||
}
|
||||
|
||||
public function getTeamColumns(): ?stdClass
|
||||
{
|
||||
if ($this->isAuthenticationProvider()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->config->get('oidcTeamsColumns') ?? (object) [];
|
||||
}
|
||||
|
||||
public function getAuthorizationPrompt(): string
|
||||
{
|
||||
return $this->object->get('oidcAuthorizationPrompt') ?? 'consent';
|
||||
}
|
||||
|
||||
public function getAuthorizationMaxAge(): ?int
|
||||
{
|
||||
return $this->config->get('oidcAuthorizationMaxAge');
|
||||
}
|
||||
|
||||
public function getJwksCachePeriod(): string
|
||||
{
|
||||
return $this->config->get('oidcJwksCachePeriod') ?? self::JWKS_CACHE_PERIOD;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\Oidc;
|
||||
|
||||
use Espo\Core\Authentication\Jwt\SignatureVerifier;
|
||||
use Espo\Core\Authentication\Jwt\SignatureVerifierFactory;
|
||||
use Espo\Core\Authentication\Jwt\SignatureVerifiers\Hmac;
|
||||
use Espo\Core\Authentication\Jwt\SignatureVerifiers\Rsa;
|
||||
use RuntimeException;
|
||||
|
||||
class DefaultSignatureVerifierFactory implements SignatureVerifierFactory
|
||||
{
|
||||
private const RS256 = 'RS256';
|
||||
private const RS384 = 'RS384';
|
||||
private const RS512 = 'RS512';
|
||||
private const HS256 = 'HS256';
|
||||
private const HS384 = 'HS384';
|
||||
private const HS512 = 'HS512';
|
||||
|
||||
private const ALGORITHM_VERIFIER_CLASS_NAME_MAP = [
|
||||
self::RS256 => Rsa::class,
|
||||
self::RS384 => Rsa::class,
|
||||
self::RS512 => Rsa::class,
|
||||
self::HS256 => Hmac::class,
|
||||
self::HS384 => Hmac::class,
|
||||
self::HS512 => Hmac::class,
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private KeysProvider $keysProvider,
|
||||
private ConfigDataProvider $configDataProvider
|
||||
) {}
|
||||
|
||||
public function create(string $algorithm): SignatureVerifier
|
||||
{
|
||||
/** @var ?class-string<SignatureVerifier> $className */
|
||||
$className = self::ALGORITHM_VERIFIER_CLASS_NAME_MAP[$algorithm] ?? null;
|
||||
|
||||
if (!$className) {
|
||||
throw new RuntimeException("Not supported algorithm $algorithm.");
|
||||
}
|
||||
|
||||
if ($className === Rsa::class) {
|
||||
$keys = $this->keysProvider->get();
|
||||
|
||||
return new Rsa($algorithm, $keys);
|
||||
}
|
||||
|
||||
if ($className === Hmac::class) {
|
||||
$key = $this->configDataProvider->getClientSecret();
|
||||
|
||||
if (!$key) {
|
||||
throw new RuntimeException("No client secret.");
|
||||
}
|
||||
|
||||
return new Hmac($algorithm, $key);
|
||||
}
|
||||
|
||||
throw new RuntimeException();
|
||||
}
|
||||
}
|
||||
203
application/Espo/Core/Authentication/Oidc/KeysProvider.php
Normal file
203
application/Espo/Core/Authentication/Oidc/KeysProvider.php
Normal file
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\Oidc;
|
||||
|
||||
use Espo\Core\Authentication\Jwt\Exceptions\UnsupportedKey;
|
||||
use Espo\Core\Authentication\Jwt\Key;
|
||||
use Espo\Core\Authentication\Jwt\KeyFactory;
|
||||
use Espo\Core\Field\DateTime;
|
||||
use Espo\Core\Utils\Config\SystemConfig;
|
||||
use Espo\Core\Utils\DataCache;
|
||||
use Espo\Core\Utils\Json;
|
||||
use Espo\Core\Utils\Log;
|
||||
use JsonException;
|
||||
use RuntimeException;
|
||||
use stdClass;
|
||||
|
||||
class KeysProvider
|
||||
{
|
||||
private const CACHE_KEY = 'oidcJwks';
|
||||
private const REQUEST_TIMEOUT = 10;
|
||||
|
||||
public function __construct(
|
||||
private DataCache $dataCache,
|
||||
private ConfigDataProvider $configDataProvider,
|
||||
private KeyFactory $factory,
|
||||
private Log $log,
|
||||
private SystemConfig $systemConfig,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return Key[]
|
||||
*/
|
||||
public function get(): array
|
||||
{
|
||||
$list = [];
|
||||
|
||||
$rawKeys = $this->getRaw();
|
||||
|
||||
foreach ($rawKeys as $raw) {
|
||||
try {
|
||||
$list[] = $this->factory->create($raw);
|
||||
} catch (UnsupportedKey) {
|
||||
$this->log->debug("OIDC: Unsupported key " . print_r($raw, true));
|
||||
}
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return stdClass[]
|
||||
*/
|
||||
private function getRaw(): array
|
||||
{
|
||||
$raw = $this->getRawFromCache();
|
||||
|
||||
if (!$raw) {
|
||||
$raw = $this->load();
|
||||
|
||||
$this->storeRawToCache($raw);
|
||||
}
|
||||
|
||||
return $raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return stdClass[]
|
||||
*/
|
||||
private function load(): array
|
||||
{
|
||||
$endpoint = $this->configDataProvider->getJwksEndpoint();
|
||||
|
||||
if (!$endpoint) {
|
||||
throw new RuntimeException("JSON Web Key Set endpoint not specified in settings.");
|
||||
}
|
||||
|
||||
$curl = curl_init();
|
||||
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_URL => $endpoint,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_ENCODING => '',
|
||||
CURLOPT_MAXREDIRS => 10,
|
||||
CURLOPT_TIMEOUT => self::REQUEST_TIMEOUT,
|
||||
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
|
||||
CURLOPT_CUSTOMREQUEST => 'GET',
|
||||
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS | CURLPROTO_HTTP,
|
||||
]);
|
||||
|
||||
/** @var string|false $response */
|
||||
$response = curl_exec($curl);
|
||||
$error = curl_error($curl);
|
||||
$status = curl_getinfo($curl, CURLINFO_HTTP_CODE);
|
||||
|
||||
curl_close($curl);
|
||||
|
||||
if ($response === false) {
|
||||
$response = '';
|
||||
}
|
||||
|
||||
if ($error) {
|
||||
throw new RuntimeException("OIDC: JWKS request error. Status: $status.");
|
||||
}
|
||||
|
||||
$parsedResponse = null;
|
||||
|
||||
try {
|
||||
$parsedResponse = Json::decode($response);
|
||||
} catch (JsonException) {}
|
||||
|
||||
if (!$parsedResponse instanceof stdClass || !isset($parsedResponse->keys)) {
|
||||
throw new RuntimeException("OIDC: JWKS bad response.");
|
||||
}
|
||||
|
||||
return $parsedResponse->keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ?stdClass[]
|
||||
*/
|
||||
private function getRawFromCache(): ?array
|
||||
{
|
||||
if (!$this->systemConfig->useCache()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!$this->dataCache->has(self::CACHE_KEY)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = $this->dataCache->get(self::CACHE_KEY);
|
||||
|
||||
if (!$data instanceof stdClass) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var ?int $timestamp */
|
||||
$timestamp = $data->timestamp;
|
||||
|
||||
if (!$timestamp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$period = '-' . $this->configDataProvider->getJwksCachePeriod();
|
||||
|
||||
if ($timestamp < DateTime::createNow()->modify($period)->toTimestamp()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var ?stdClass[] $keys */
|
||||
$keys = $data->keys ?? null;
|
||||
|
||||
if ($keys === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param stdClass[] $raw
|
||||
*/
|
||||
private function storeRawToCache(array $raw): void
|
||||
{
|
||||
if (!$this->systemConfig->useCache()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$data = (object) [
|
||||
'timestamp' => time(),
|
||||
'keys' => $raw,
|
||||
];
|
||||
|
||||
$this->dataCache->store(self::CACHE_KEY, $data);
|
||||
}
|
||||
}
|
||||
322
application/Espo/Core/Authentication/Oidc/Login.php
Normal file
322
application/Espo/Core/Authentication/Oidc/Login.php
Normal file
@@ -0,0 +1,322 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\Oidc;
|
||||
|
||||
use Espo\Core\Api\Request;
|
||||
use Espo\Core\ApplicationState;
|
||||
use Espo\Core\Authentication\Login as LoginInterface;
|
||||
use Espo\Core\Authentication\Login\Data;
|
||||
use Espo\Core\Authentication\Jwt\Token;
|
||||
use Espo\Core\Authentication\Logins\Espo;
|
||||
use Espo\Core\Authentication\Jwt\Exceptions\Invalid;
|
||||
use Espo\Core\Authentication\Jwt\Exceptions\SignatureNotVerified;
|
||||
use Espo\Core\Authentication\Jwt\Validator;
|
||||
use Espo\Core\Authentication\Oidc\UserProvider\UserInfo;
|
||||
use Espo\Core\Authentication\Result;
|
||||
use Espo\Core\Authentication\Result\FailReason;
|
||||
use Espo\Core\Utils\Json;
|
||||
use Espo\Core\Utils\Log;
|
||||
use JsonException;
|
||||
use LogicException;
|
||||
use RuntimeException;
|
||||
use SensitiveParameter;
|
||||
use stdClass;
|
||||
|
||||
class Login implements LoginInterface
|
||||
{
|
||||
public const NAME = 'Oidc';
|
||||
|
||||
private const OIDC_USERNAME = '**oidc';
|
||||
private const REQUEST_TIMEOUT = 10;
|
||||
private const NONCE_HEADER = 'X-Oidc-Authorization-Nonce';
|
||||
|
||||
public function __construct(
|
||||
private Espo $espoLogin,
|
||||
private Log $log,
|
||||
private ConfigDataProvider $configDataProvider,
|
||||
private Validator $validator,
|
||||
private TokenValidator $tokenValidator,
|
||||
private UserProvider $userProvider,
|
||||
private ApplicationState $applicationState,
|
||||
private UserInfoDataProvider $userInfoDataProvider,
|
||||
) {}
|
||||
|
||||
public function login(Data $data, Request $request): Result
|
||||
{
|
||||
if ($data->getUsername() !== self::OIDC_USERNAME) {
|
||||
return $this->loginFallback($data, $request);
|
||||
}
|
||||
|
||||
$code = $data->getPassword();
|
||||
|
||||
if (!$code) {
|
||||
return Result::fail(FailReason::NO_PASSWORD);
|
||||
}
|
||||
|
||||
return $this->loginWithCode($code, $request);
|
||||
}
|
||||
|
||||
private function loginWithCode(string $code, Request $request): Result
|
||||
{
|
||||
$endpoint = $this->configDataProvider->getTokenEndpoint();
|
||||
$clientId = $this->configDataProvider->getClientId();
|
||||
$clientSecret = $this->configDataProvider->getClientSecret();
|
||||
$redirectUri = $this->configDataProvider->getRedirectUri();
|
||||
|
||||
if (!$endpoint) {
|
||||
throw new RuntimeException("No token endpoint.");
|
||||
}
|
||||
|
||||
if (!$clientId) {
|
||||
throw new RuntimeException("No client ID.");
|
||||
}
|
||||
|
||||
if (!$clientSecret) {
|
||||
throw new RuntimeException("No client secret.");
|
||||
}
|
||||
|
||||
[$rawToken, $failResult, $accessToken] =
|
||||
$this->requestToken($endpoint, $clientId, $code, $redirectUri, $clientSecret);
|
||||
|
||||
if ($failResult) {
|
||||
return $failResult;
|
||||
}
|
||||
|
||||
if (!$rawToken) {
|
||||
throw new LogicException();
|
||||
}
|
||||
|
||||
try {
|
||||
$token = Token::create($rawToken);
|
||||
} catch (RuntimeException $e) {
|
||||
$message = self::composeLogMessage('JWT parsing error.');
|
||||
|
||||
if ($e->getMessage()) {
|
||||
$message .= " " . $e->getMessage();
|
||||
}
|
||||
|
||||
$this->log->error($message);
|
||||
|
||||
throw new RuntimeException("JWT parsing error.");
|
||||
}
|
||||
|
||||
$this->log->debug("OIDC: JWT header: " . $token->getHeaderRaw());
|
||||
$this->log->debug("OIDC: JWT payload: " . $token->getPayloadRaw());
|
||||
|
||||
try {
|
||||
$this->validateToken($token);
|
||||
} catch (Invalid $e) {
|
||||
$this->log->error("OIDC: " . $e->getMessage());
|
||||
|
||||
return Result::fail(FailReason::DENIED);
|
||||
}
|
||||
|
||||
$tokenPayload = $token->getPayload();
|
||||
|
||||
$nonce = $request->getHeader(self::NONCE_HEADER);
|
||||
|
||||
if ($nonce && $nonce !== $tokenPayload->getNonce()) {
|
||||
$this->log->warning(self::composeLogMessage('JWT nonce mismatch.'));
|
||||
|
||||
return Result::fail(FailReason::DENIED);
|
||||
}
|
||||
|
||||
$userInfo = $this->getUserInfo($tokenPayload, $accessToken);
|
||||
|
||||
$user = $this->userProvider->get($userInfo);
|
||||
|
||||
if (!$user) {
|
||||
return Result::fail(FailReason::USER_NOT_FOUND);
|
||||
}
|
||||
|
||||
return Result::success($user)->withBypassSecondStep();
|
||||
}
|
||||
|
||||
private function loginFallback(Data $data, Request $request): Result
|
||||
{
|
||||
if (
|
||||
!$data->getAuthToken() &&
|
||||
!$this->configDataProvider->fallback()
|
||||
) {
|
||||
return Result::fail(FailReason::METHOD_NOT_ALLOWED);
|
||||
}
|
||||
|
||||
if (
|
||||
!$data->getAuthToken() &&
|
||||
$this->applicationState->isPortal()
|
||||
) {
|
||||
return Result::fail(FailReason::METHOD_NOT_ALLOWED);
|
||||
}
|
||||
|
||||
$result = $this->espoLogin->login($data, $request);
|
||||
|
||||
$user = $result->getUser();
|
||||
|
||||
if (!$user) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
if ($data->getAuthToken()) {
|
||||
// Allow fallback when logged by auth token.
|
||||
return $result;
|
||||
}
|
||||
|
||||
if (
|
||||
$user->isRegular() &&
|
||||
!$this->configDataProvider->allowRegularUserFallback()
|
||||
// Portal users are allowed.
|
||||
) {
|
||||
return Result::fail(FailReason::METHOD_NOT_ALLOWED);
|
||||
}
|
||||
|
||||
if ($user->isPortal()) {
|
||||
return Result::fail(FailReason::METHOD_NOT_ALLOWED);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{?string, ?Result, ?string}
|
||||
*/
|
||||
private function requestToken(
|
||||
string $endpoint,
|
||||
string $clientId,
|
||||
string $code,
|
||||
string $redirectUri,
|
||||
string $clientSecret
|
||||
): array {
|
||||
$params = [
|
||||
'grant_type' => 'authorization_code',
|
||||
'client_id' => $clientId,
|
||||
'client_secret' => $clientSecret,
|
||||
'code' => $code,
|
||||
'redirect_uri' => $redirectUri,
|
||||
];
|
||||
|
||||
$curl = curl_init();
|
||||
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_URL => $endpoint,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_ENCODING => '',
|
||||
CURLOPT_MAXREDIRS => 10,
|
||||
CURLOPT_TIMEOUT => self::REQUEST_TIMEOUT,
|
||||
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
|
||||
CURLOPT_CUSTOMREQUEST => 'POST',
|
||||
CURLOPT_POSTFIELDS => http_build_query($params),
|
||||
CURLOPT_HTTPHEADER => ['content-type: application/x-www-form-urlencoded'],
|
||||
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS | CURLPROTO_HTTP,
|
||||
]);
|
||||
|
||||
/** @var string|false $response */
|
||||
$response = curl_exec($curl);
|
||||
$error = curl_error($curl);
|
||||
$status = curl_getinfo($curl, CURLINFO_HTTP_CODE);
|
||||
|
||||
curl_close($curl);
|
||||
|
||||
if ($response === false) {
|
||||
$response = '';
|
||||
}
|
||||
|
||||
if ($error || is_int($status) && ($status >= 400 && $status < 500)) {
|
||||
if ($status === 400) {
|
||||
$this->log->error(self::composeLogMessage('Bad token request.', $status, $response));
|
||||
|
||||
throw new RuntimeException();
|
||||
}
|
||||
|
||||
$this->log->warning(self::composeLogMessage('Token request error.', $status, $response));
|
||||
|
||||
return [null, Result::fail(FailReason::DENIED), null];
|
||||
}
|
||||
|
||||
$parsedResponse = null;
|
||||
|
||||
try {
|
||||
$parsedResponse = Json::decode($response);
|
||||
} catch (JsonException) {}
|
||||
|
||||
if (!$parsedResponse instanceof stdClass) {
|
||||
$this->log->error(self::composeLogMessage('Bad token response.', $status, $response));
|
||||
|
||||
throw new RuntimeException();
|
||||
}
|
||||
|
||||
$token = $parsedResponse->id_token ?? null;
|
||||
$accessToken = $parsedResponse->access_token ?? null;
|
||||
|
||||
if (!$token || !is_string($token)) {
|
||||
$this->log->error(self::composeLogMessage('Bad token response.', $status, $response));
|
||||
|
||||
throw new RuntimeException();
|
||||
}
|
||||
|
||||
return [$token, null, $accessToken];
|
||||
}
|
||||
|
||||
private static function composeLogMessage(string $text, ?int $status = null, ?string $response = null): string
|
||||
{
|
||||
if ($status === null) {
|
||||
return "OIDC: $text";
|
||||
}
|
||||
|
||||
return "OIDC: $text; Status: $status; Response: $response";
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws SignatureNotVerified
|
||||
* @throws Invalid
|
||||
*/
|
||||
private function validateToken(Token $token): void
|
||||
{
|
||||
$this->validator->validate($token);
|
||||
$this->tokenValidator->validateFields($token);
|
||||
$this->tokenValidator->validateSignature($token);
|
||||
}
|
||||
|
||||
private function getUserInfo(Token\Payload $payload, #[SensitiveParameter] ?string $accessToken): UserInfo
|
||||
{
|
||||
$endpoint = $this->configDataProvider->getUserInfoEndpoint();
|
||||
|
||||
if (!$endpoint) {
|
||||
return new UserInfo($payload, []);
|
||||
}
|
||||
|
||||
if (!$accessToken) {
|
||||
throw new RuntimeException("OIDC: No access token received.");
|
||||
}
|
||||
|
||||
$data = $this->userInfoDataProvider->get($accessToken);
|
||||
|
||||
return new UserInfo($payload, $data);
|
||||
}
|
||||
}
|
||||
61
application/Espo/Core/Authentication/Oidc/Logout.php
Normal file
61
application/Espo/Core/Authentication/Oidc/Logout.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\Oidc;
|
||||
|
||||
use Espo\Core\Authentication\AuthToken\AuthToken;
|
||||
use Espo\Core\Authentication\Logout as LogoutInterface;
|
||||
use Espo\Core\Authentication\Logout\Params;
|
||||
use Espo\Core\Authentication\Logout\Result;
|
||||
|
||||
/**
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
class Logout implements LogoutInterface
|
||||
{
|
||||
public function __construct(
|
||||
private ConfigDataProvider $configDataProvider
|
||||
) {}
|
||||
|
||||
public function logout(AuthToken $authToken, Params $params): Result
|
||||
{
|
||||
$url = $this->configDataProvider->getLogoutUrl();
|
||||
$clientId = $this->configDataProvider->getClientId() ?? '';
|
||||
$siteUrl = $this->configDataProvider->getSiteUrl();
|
||||
|
||||
if ($url) {
|
||||
$url = str_replace('{clientId}', urlencode($clientId), $url);
|
||||
$url = str_replace('{siteUrl}', urlencode($siteUrl), $url);
|
||||
}
|
||||
|
||||
// @todo Check session is set in auth token to bypass fallback logins.
|
||||
|
||||
return Result::create()->withRedirectUrl($url);
|
||||
}
|
||||
}
|
||||
89
application/Espo/Core/Authentication/Oidc/TokenValidator.php
Normal file
89
application/Espo/Core/Authentication/Oidc/TokenValidator.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\Oidc;
|
||||
|
||||
use Espo\Core\Authentication\Jwt\Exceptions\Invalid;
|
||||
use Espo\Core\Authentication\Jwt\Exceptions\SignatureNotVerified;
|
||||
use Espo\Core\Authentication\Jwt\SignatureVerifierFactory;
|
||||
use Espo\Core\Authentication\Jwt\Token;
|
||||
use RuntimeException;
|
||||
|
||||
class TokenValidator
|
||||
{
|
||||
public function __construct(
|
||||
private ConfigDataProvider $configDataProvider,
|
||||
private SignatureVerifierFactory $signatureVerifierFactory
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws SignatureNotVerified
|
||||
* @throws Invalid
|
||||
*/
|
||||
public function validateSignature(Token $token): void
|
||||
{
|
||||
$algorithm = $token->getHeader()->getAlg();
|
||||
|
||||
$allowedAlgorithmList = $this->configDataProvider->getJwtSignatureAlgorithmList();
|
||||
|
||||
if (!in_array($algorithm, $allowedAlgorithmList)) {
|
||||
throw new Invalid("JWT signing algorithm `$algorithm` not allowed.");
|
||||
}
|
||||
|
||||
$verifier = $this->signatureVerifierFactory->create($algorithm);
|
||||
|
||||
if (!$verifier->verify($token)) {
|
||||
throw new SignatureNotVerified("JWT signature not verified.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Invalid
|
||||
*/
|
||||
public function validateFields(Token $token): void
|
||||
{
|
||||
$oidcClientId = $this->configDataProvider->getClientId();
|
||||
|
||||
if (!$oidcClientId) {
|
||||
throw new RuntimeException("OIDC: No client ID.");
|
||||
}
|
||||
|
||||
if (!in_array($oidcClientId, $token->getPayload()->getAud())) {
|
||||
throw new Invalid("JWT the `aud` field does not contain matching client ID.");
|
||||
}
|
||||
|
||||
if (!$token->getPayload()->getSub()) {
|
||||
throw new Invalid("JWT does not contain the `sub` value.");
|
||||
}
|
||||
|
||||
if (!$token->getPayload()->getIss()) {
|
||||
throw new Invalid("JWT does not contain the `iss` value.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\Oidc;
|
||||
|
||||
use Espo\Core\Utils\Json;
|
||||
use Espo\Core\Utils\Log;
|
||||
use JsonException;
|
||||
use RuntimeException;
|
||||
use SensitiveParameter;
|
||||
|
||||
class UserInfoDataProvider
|
||||
{
|
||||
private const REQUEST_TIMEOUT = 10;
|
||||
|
||||
public function __construct(
|
||||
private ConfigDataProvider $configDataProvider,
|
||||
private Log $log,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function get(#[SensitiveParameter] string $accessToken): array
|
||||
{
|
||||
return $this->load($accessToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function load(#[SensitiveParameter] string $accessToken): array
|
||||
{
|
||||
$endpoint = $this->configDataProvider->getUserInfoEndpoint();
|
||||
|
||||
if (!$endpoint) {
|
||||
throw new RuntimeException("No userinfo endpoint.");
|
||||
}
|
||||
|
||||
$curl = curl_init();
|
||||
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_URL => $endpoint,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_ENCODING => '',
|
||||
CURLOPT_MAXREDIRS => 10,
|
||||
CURLOPT_TIMEOUT => self::REQUEST_TIMEOUT,
|
||||
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
|
||||
CURLOPT_CUSTOMREQUEST => 'GET',
|
||||
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS | CURLPROTO_HTTP,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Authorization: Bearer ' . $accessToken,
|
||||
'Accept: application/json',
|
||||
],
|
||||
]);
|
||||
|
||||
/** @var string|false $response */
|
||||
$response = curl_exec($curl);
|
||||
$error = curl_error($curl);
|
||||
$status = curl_getinfo($curl, CURLINFO_HTTP_CODE);
|
||||
|
||||
curl_close($curl);
|
||||
|
||||
if ($response === false) {
|
||||
$response = '';
|
||||
}
|
||||
|
||||
if ($error || is_int($status) && ($status >= 400 && $status < 500)) {
|
||||
$this->log->error(self::composeLogMessage('UserInfo response error.', $status, $response));
|
||||
|
||||
throw new RuntimeException("OIDC: Userinfo request error.");
|
||||
}
|
||||
|
||||
$parsedResponse = null;
|
||||
|
||||
try {
|
||||
$parsedResponse = Json::decode($response, true);
|
||||
} catch (JsonException) {}
|
||||
|
||||
if (!is_array($parsedResponse)) {
|
||||
throw new RuntimeException("OIDC: Bad userinfo response.");
|
||||
}
|
||||
|
||||
return $parsedResponse;
|
||||
}
|
||||
|
||||
private static function composeLogMessage(string $text, ?int $status = null, ?string $response = null): string
|
||||
{
|
||||
if ($status === null) {
|
||||
return "OIDC: $text";
|
||||
}
|
||||
|
||||
return "OIDC: $text; Status: $status; Response: $response";
|
||||
}
|
||||
}
|
||||
38
application/Espo/Core/Authentication/Oidc/UserProvider.php
Normal file
38
application/Espo/Core/Authentication/Oidc/UserProvider.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\Oidc;
|
||||
|
||||
use Espo\Core\Authentication\Oidc\UserProvider\UserInfo;
|
||||
use Espo\Entities\User;
|
||||
|
||||
interface UserProvider
|
||||
{
|
||||
public function get(UserInfo $userInfo): ?User;
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\Oidc\UserProvider;
|
||||
|
||||
use Espo\Core\ApplicationState;
|
||||
use Espo\Core\Authentication\Oidc\ConfigDataProvider;
|
||||
use Espo\Core\Authentication\Oidc\UserProvider;
|
||||
use Espo\Core\Utils\Log;
|
||||
use Espo\Entities\User;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class DefaultUserProvider implements UserProvider
|
||||
{
|
||||
public function __construct(
|
||||
private ConfigDataProvider $configDataProvider,
|
||||
private Sync $sync,
|
||||
private UserRepository $userRepository,
|
||||
private ApplicationState $applicationState,
|
||||
private Log $log,
|
||||
) {}
|
||||
|
||||
public function get(UserInfo $userInfo): ?User
|
||||
{
|
||||
$user = $this->findUser($userInfo);
|
||||
|
||||
if ($user === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($user) {
|
||||
$this->syncUser($user, $userInfo);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
return $this->tryToCreateUser($userInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return User|false|null
|
||||
*/
|
||||
private function findUser(UserInfo $userInfo): User|bool|null
|
||||
{
|
||||
$usernameClaim = $this->configDataProvider->getUsernameClaim();
|
||||
|
||||
if (!$usernameClaim) {
|
||||
throw new RuntimeException("No username claim in config.");
|
||||
}
|
||||
|
||||
$username = $userInfo->get($usernameClaim);
|
||||
|
||||
if (!$username) {
|
||||
throw new RuntimeException("No username claim `$usernameClaim` in token and userinfo.");
|
||||
}
|
||||
|
||||
$username = $this->sync->normalizeUsername($username);
|
||||
|
||||
$user = $this->userRepository->findByUsername($username);
|
||||
|
||||
if (!$user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$userId = $user->getId();
|
||||
|
||||
if (!$user->isActive()) {
|
||||
$this->log->info("Oidc: User $userId found but it's not active.");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$isPortal = $this->applicationState->isPortal();
|
||||
|
||||
if (!$isPortal && !$user->isRegular() && !$user->isAdmin()) {
|
||||
$this->log->info("Oidc: User $userId found but it's neither regular user nor admin.");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($isPortal && !$user->isPortal()) {
|
||||
$this->log->info("Oidc: User $userId found but it's not portal user.");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($isPortal) {
|
||||
$portalId = $this->applicationState->getPortalId();
|
||||
|
||||
if (!$user->getPortals()->hasId($portalId)) {
|
||||
$this->log->info("Oidc: User $userId found but it's not related to current portal.");
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if ($user->isSuperAdmin()) {
|
||||
$this->log->info("Oidc: User $userId found but it's super-admin, not allowed.");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($user->isAdmin() && !$this->configDataProvider->allowAdminUser()) {
|
||||
$this->log->info("Oidc: User $userId found but it's admin, not allowed.");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function tryToCreateUser(UserInfo $userInfo): ?User
|
||||
{
|
||||
if (!$this->configDataProvider->createUser()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$usernameClaim = $this->configDataProvider->getUsernameClaim();
|
||||
|
||||
if (!$usernameClaim) {
|
||||
throw new RuntimeException("Could not create a user. No OIDC username claim in config.");
|
||||
}
|
||||
|
||||
$username = $userInfo->get($usernameClaim);
|
||||
|
||||
if (!$username) {
|
||||
throw new RuntimeException("Could not create a user. No username claim in token and userinfo.");
|
||||
}
|
||||
|
||||
return $this->sync->createUser($userInfo);
|
||||
}
|
||||
|
||||
private function syncUser(User $user, UserInfo $userInfo): void
|
||||
{
|
||||
if (
|
||||
!$this->configDataProvider->sync() &&
|
||||
!$this->configDataProvider->syncTeams()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->sync->syncUser($user, $userInfo);
|
||||
}
|
||||
}
|
||||
247
application/Espo/Core/Authentication/Oidc/UserProvider/Sync.php
Normal file
247
application/Espo/Core/Authentication/Oidc/UserProvider/Sync.php
Normal file
@@ -0,0 +1,247 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\Oidc\UserProvider;
|
||||
|
||||
use Espo\Core\Acl\Cache\Clearer as AclCacheClearer;
|
||||
use Espo\Core\ApplicationState;
|
||||
use Espo\Core\Authentication\Oidc\ConfigDataProvider;
|
||||
use Espo\Core\Field\LinkMultiple;
|
||||
use Espo\Core\Name\Field;
|
||||
use Espo\Core\Utils\Config;
|
||||
use Espo\Core\Utils\PasswordHash;
|
||||
use Espo\Core\Utils\Util;
|
||||
use Espo\Entities\User;
|
||||
use RuntimeException;
|
||||
|
||||
class Sync
|
||||
{
|
||||
public function __construct(
|
||||
private UsernameValidator $usernameValidator,
|
||||
private Config $config,
|
||||
private ConfigDataProvider $configDataProvider,
|
||||
private UserRepository $userRepository,
|
||||
private PasswordHash $passwordHash,
|
||||
private AclCacheClearer $aclCacheClearer,
|
||||
private ApplicationState $applicationState,
|
||||
) {}
|
||||
|
||||
public function createUser(UserInfo $userInfo): User
|
||||
{
|
||||
$username = $this->getUsernameFromToken($userInfo);
|
||||
|
||||
$this->usernameValidator->validate($username);
|
||||
|
||||
$user = $this->userRepository->getNew();
|
||||
|
||||
$user->setType(User::TYPE_REGULAR);
|
||||
$user->setUserName($username);
|
||||
|
||||
$user->setMultiple([
|
||||
'password' => $this->passwordHash->hash(Util::generatePassword(10, 4, 2, true)),
|
||||
]);
|
||||
|
||||
$user->set($this->getUserDataFromToken($userInfo));
|
||||
$user->set($this->getUserTeamsDataFromToken($userInfo));
|
||||
|
||||
if ($this->applicationState->isPortal()) {
|
||||
$portalId = $this->applicationState->getPortalId();
|
||||
|
||||
$user->setType(User::TYPE_PORTAL);
|
||||
$user->setPortals(LinkMultiple::create()->withAddedId($portalId));
|
||||
}
|
||||
|
||||
$this->userRepository->save($user);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
public function syncUser(User $user, UserInfo $payload): void
|
||||
{
|
||||
$username = $this->getUsernameFromToken($payload);
|
||||
|
||||
$this->usernameValidator->validate($username);
|
||||
|
||||
if ($user->getUserName() !== $username) {
|
||||
throw new RuntimeException("Could not sync user. Username mismatch.");
|
||||
}
|
||||
|
||||
if ($this->configDataProvider->sync()) {
|
||||
$user->set($this->getUserDataFromToken($payload));
|
||||
}
|
||||
|
||||
$clearAclCache = false;
|
||||
|
||||
if ($this->configDataProvider->syncTeams()) {
|
||||
$user->loadLinkMultipleField(Field::TEAMS);
|
||||
|
||||
$user->set($this->getUserTeamsDataFromToken($payload));
|
||||
|
||||
$clearAclCache = $user->isAttributeChanged('teamsIds');
|
||||
}
|
||||
|
||||
$this->userRepository->save($user);
|
||||
|
||||
if ($clearAclCache) {
|
||||
$this->aclCacheClearer->clearForUser($user);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function getUserDataFromToken(UserInfo $userInfo): array
|
||||
{
|
||||
return [
|
||||
'emailAddress' => $userInfo->get('email'),
|
||||
'phoneNumber' => $userInfo->get('phone_number'),
|
||||
'emailAddressData' => null,
|
||||
'phoneNumberData' => null,
|
||||
'firstName' => $userInfo->get('given_name'),
|
||||
'lastName' => $userInfo->get('family_name'),
|
||||
'middle_name' => $userInfo->get('middle_name'),
|
||||
'gender' =>
|
||||
in_array($userInfo->get('gender'), ['male', 'female']) ?
|
||||
ucfirst($userInfo->get('gender') ?? '') :
|
||||
null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function getUserTeamsDataFromToken(UserInfo $userInfo): array
|
||||
{
|
||||
return [
|
||||
'teamsIds' => $this->getTeamIdList($userInfo),
|
||||
];
|
||||
}
|
||||
|
||||
private function getUsernameFromToken(UserInfo $userInfo): string
|
||||
{
|
||||
$usernameClaim = $this->configDataProvider->getUsernameClaim();
|
||||
|
||||
if (!$usernameClaim) {
|
||||
throw new RuntimeException("No OIDC username claim in config.");
|
||||
}
|
||||
|
||||
$username = $userInfo->get($usernameClaim);
|
||||
|
||||
if (!$username) {
|
||||
throw new RuntimeException("No username claim returned in token.");
|
||||
}
|
||||
|
||||
if (!is_string($username)) {
|
||||
throw new RuntimeException("Bad username claim returned in token.");
|
||||
}
|
||||
|
||||
return $this->normalizeUsername($username);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
private function getTeamIdList(UserInfo $userInfo): array
|
||||
{
|
||||
$idList = $this->configDataProvider->getTeamIds() ?? [];
|
||||
$columns = $this->configDataProvider->getTeamColumns() ?? (object) [];
|
||||
|
||||
if ($idList === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$groupList = $this->getGroups($userInfo);
|
||||
|
||||
$resultIdList = [];
|
||||
|
||||
foreach ($idList as $id) {
|
||||
$group = ($columns->$id ?? (object) [])->group ?? null;
|
||||
|
||||
if (!$group || in_array($group, $groupList)) {
|
||||
$resultIdList[] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
return $resultIdList;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
private function getGroups(UserInfo $userInfo): array
|
||||
{
|
||||
$groupClaim = $this->configDataProvider->getGroupClaim();
|
||||
|
||||
if (!$groupClaim) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$value = $userInfo->get($groupClaim);
|
||||
|
||||
if (!$value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
return [$value];
|
||||
}
|
||||
|
||||
if (!is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$list = [];
|
||||
|
||||
foreach ($value as $item) {
|
||||
if (is_string($item)) {
|
||||
$list[] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
public function normalizeUsername(string $username): string
|
||||
{
|
||||
/** @var ?string $regExp */
|
||||
$regExp = $this->config->get('userNameRegularExpression');
|
||||
|
||||
if (!$regExp) {
|
||||
throw new RuntimeException("No `userNameRegularExpression` in config.");
|
||||
}
|
||||
|
||||
$username = strtolower($username);
|
||||
|
||||
/** @var string $result */
|
||||
$result = preg_replace("/$regExp/", '_', $username);
|
||||
|
||||
/** @var string */
|
||||
return str_replace(' ', '_', $result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\Oidc\UserProvider;
|
||||
|
||||
use Espo\Core\Authentication\Jwt\Token\Payload;
|
||||
|
||||
class UserInfo
|
||||
{
|
||||
/**
|
||||
* @internal
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function __construct(
|
||||
private Payload $payload,
|
||||
private array $data,
|
||||
) {}
|
||||
|
||||
public function get(string $name): mixed
|
||||
{
|
||||
return $this->payload->get($name) ?? $this->data[$name] ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\Oidc\UserProvider;
|
||||
|
||||
use Espo\Core\FieldProcessing\EmailAddress\Saver as EmailAddressSaver;
|
||||
use Espo\Core\FieldProcessing\PhoneNumber\Saver as PhoneNumberSaver;
|
||||
use Espo\Core\FieldProcessing\Relation\LinkMultipleSaver;
|
||||
use Espo\Core\FieldProcessing\Saver\Params as SaverParams;
|
||||
use Espo\Core\Name\Field;
|
||||
use Espo\Core\ORM\Repository\Option\SaveOption;
|
||||
use Espo\Entities\User;
|
||||
use Espo\ORM\EntityManager;
|
||||
|
||||
class UserRepository
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManager $entityManager,
|
||||
private LinkMultipleSaver $linkMultipleSaver,
|
||||
private EmailAddressSaver $emailAddressSaver,
|
||||
private PhoneNumberSaver $phoneNumberSaver
|
||||
) {}
|
||||
|
||||
public function getNew(): User
|
||||
{
|
||||
return $this->entityManager->getRDBRepositoryByClass(User::class)->getNew();
|
||||
}
|
||||
|
||||
public function save(User $user): void
|
||||
{
|
||||
$this->entityManager->saveEntity($user, [
|
||||
// Prevent `user` service being loaded by hooks.
|
||||
SaveOption::SKIP_HOOKS => true,
|
||||
SaveOption::KEEP_NEW => true,
|
||||
SaveOption::KEEP_DIRTY => true,
|
||||
]);
|
||||
|
||||
$saverParams = SaverParams::create()->withRawOptions(['skipLinkMultipleHooks' => true]);
|
||||
|
||||
$this->linkMultipleSaver->process($user, Field::TEAMS, $saverParams);
|
||||
$this->linkMultipleSaver->process($user, 'portals', $saverParams);
|
||||
$this->linkMultipleSaver->process($user, 'portalRoles', $saverParams);
|
||||
$this->emailAddressSaver->process($user, $saverParams);
|
||||
$this->phoneNumberSaver->process($user, $saverParams);
|
||||
|
||||
$user->setAsNotNew();
|
||||
$user->updateFetchedValues();
|
||||
|
||||
$this->entityManager->refreshEntity($user);
|
||||
}
|
||||
|
||||
public function findByUsername(string $username): ?User
|
||||
{
|
||||
return $this->entityManager
|
||||
->getRDBRepositoryByClass(User::class)
|
||||
->where(['userName' => $username])
|
||||
->findOne();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\Oidc\UserProvider;
|
||||
|
||||
use Espo\Entities\User;
|
||||
use Espo\ORM\EntityManager;
|
||||
use RuntimeException;
|
||||
|
||||
class UsernameValidator
|
||||
{
|
||||
public function __construct(private EntityManager $entityManager) {}
|
||||
|
||||
public function validate(string $username): void
|
||||
{
|
||||
$maxLength = $this->entityManager
|
||||
->getDefs()
|
||||
->getEntity(User::ENTITY_TYPE)
|
||||
->getAttribute('userName')
|
||||
->getLength();
|
||||
|
||||
if ($maxLength && $maxLength < strlen($username)) {
|
||||
throw new RuntimeException("Value in username claim exceeds max length of `$maxLength`. " .
|
||||
"Increase maxLength parameter for User.userName field (up to 255).");
|
||||
}
|
||||
}
|
||||
}
|
||||
210
application/Espo/Core/Authentication/Result.php
Normal file
210
application/Espo/Core/Authentication/Result.php
Normal file
@@ -0,0 +1,210 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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;
|
||||
|
||||
use Espo\Core\Authentication\Result\Data;
|
||||
use Espo\Entities\User;
|
||||
|
||||
use stdClass;
|
||||
|
||||
/**
|
||||
* An authentication result.
|
||||
*
|
||||
* Immutable.
|
||||
*/
|
||||
class Result
|
||||
{
|
||||
public const STATUS_SUCCESS = 'success';
|
||||
public const STATUS_SECOND_STEP_REQUIRED = 'secondStepRequired';
|
||||
public const STATUS_FAIL = 'fail';
|
||||
|
||||
private ?User $user;
|
||||
private string $status;
|
||||
private ?string $message = null;
|
||||
private ?string $token = null;
|
||||
private ?string $view = null;
|
||||
private ?string $failReason = null;
|
||||
private bool $bypassSecondStep = false;
|
||||
private ?Data $data;
|
||||
|
||||
private function __construct(string $status, ?User $user = null, ?Data $data = null)
|
||||
{
|
||||
$this->user = $user;
|
||||
$this->status = $status;
|
||||
|
||||
$this->data = $data;
|
||||
|
||||
if ($data) {
|
||||
$this->message = $data->getMessage();
|
||||
$this->token = $data->getToken();
|
||||
$this->view = $data->getView();
|
||||
$this->failReason = $data->getFailReason();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an instance for a successful login.
|
||||
*/
|
||||
public static function success(User $user): self
|
||||
{
|
||||
return new self(self::STATUS_SUCCESS, $user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an instance for a failed login.
|
||||
*/
|
||||
public static function fail(?string $reason = null): self
|
||||
{
|
||||
$data = $reason ?
|
||||
Data::createWithFailReason($reason) :
|
||||
Data::create();
|
||||
|
||||
return new self(self::STATUS_FAIL, null, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an instance for a login requiring a second step. E.g. for 2FA.
|
||||
*/
|
||||
public static function secondStepRequired(User $user, Data $data): self
|
||||
{
|
||||
return new self(self::STATUS_SECOND_STEP_REQUIRED, $user, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Login is successful.
|
||||
*/
|
||||
public function isSuccess(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* The second step is required.
|
||||
*/
|
||||
public function isSecondStepRequired(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_SECOND_STEP_REQUIRED;
|
||||
}
|
||||
|
||||
/**
|
||||
* To bypass the second step.
|
||||
*
|
||||
* @since 8.4.0
|
||||
*/
|
||||
public function bypassSecondStep(): bool
|
||||
{
|
||||
return $this->bypassSecondStep;
|
||||
}
|
||||
|
||||
/**
|
||||
* Login is failed.
|
||||
*/
|
||||
public function isFail(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_FAIL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user.
|
||||
*/
|
||||
public function getUser(): ?User
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use `getUser`.
|
||||
*/
|
||||
public function getLoggedUser(): ?User
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
/**
|
||||
* A status.
|
||||
*/
|
||||
public function getStatus(): string
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
/**
|
||||
* A client view to redirect to for a second step.
|
||||
*/
|
||||
public function getView(): ?string
|
||||
{
|
||||
return $this->view;
|
||||
}
|
||||
|
||||
/**
|
||||
* A message to show to a user for a second step.
|
||||
*/
|
||||
public function getMessage(): ?string
|
||||
{
|
||||
return $this->message;
|
||||
}
|
||||
|
||||
/**
|
||||
* A token can be returned to a client to be used instead of password in a request for a second step.
|
||||
*/
|
||||
public function getToken(): ?string
|
||||
{
|
||||
return $this->token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Additional data that can be needed for a second step.
|
||||
*/
|
||||
public function getData(): ?stdClass
|
||||
{
|
||||
return $this->data?->getData();
|
||||
}
|
||||
|
||||
/**
|
||||
* A fail reason.
|
||||
*/
|
||||
public function getFailReason(): ?string
|
||||
{
|
||||
return $this->failReason;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone with bypass second step.
|
||||
*
|
||||
* @since 8.4.0
|
||||
*/
|
||||
public function withBypassSecondStep(bool $bypassSecondStep = true): self
|
||||
{
|
||||
$obj = clone $this;
|
||||
$obj->bypassSecondStep = $bypassSecondStep;
|
||||
|
||||
return $obj;
|
||||
}
|
||||
}
|
||||
135
application/Espo/Core/Authentication/Result/Data.php
Normal file
135
application/Espo/Core/Authentication/Result/Data.php
Normal file
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\Result;
|
||||
|
||||
use stdClass;
|
||||
|
||||
/**
|
||||
* Immutable.
|
||||
*/
|
||||
class Data
|
||||
{
|
||||
/** @var array<string, mixed> */
|
||||
private array $data = [];
|
||||
|
||||
/** @noinspection PhpSameParameterValueInspection */
|
||||
private function __construct(
|
||||
private ?string $message = null,
|
||||
private ?string $failReason = null,
|
||||
private ?string $token = null,
|
||||
private ?string $view = null
|
||||
) {}
|
||||
|
||||
public static function create(): self
|
||||
{
|
||||
return new self();
|
||||
}
|
||||
|
||||
public static function createWithFailReason(string $failReason): self
|
||||
{
|
||||
return new self(null, $failReason);
|
||||
}
|
||||
|
||||
public static function createWithMessage(string $message): self
|
||||
{
|
||||
return new self($message);
|
||||
}
|
||||
|
||||
public function getView(): ?string
|
||||
{
|
||||
return $this->view;
|
||||
}
|
||||
|
||||
public function getMessage(): ?string
|
||||
{
|
||||
return $this->message;
|
||||
}
|
||||
|
||||
public function getToken(): ?string
|
||||
{
|
||||
return $this->token;
|
||||
}
|
||||
|
||||
public function getFailReason(): ?string
|
||||
{
|
||||
return $this->failReason;
|
||||
}
|
||||
|
||||
public function getData(): stdClass
|
||||
{
|
||||
return (object) $this->data;
|
||||
}
|
||||
|
||||
public function withMessage(?string $message): self
|
||||
{
|
||||
$obj = clone $this;
|
||||
$obj->message = $message;
|
||||
|
||||
return $obj;
|
||||
}
|
||||
|
||||
/** @noinspection PhpUnused */
|
||||
public function withFailReason(?string $failReason): self
|
||||
{
|
||||
$obj = clone $this;
|
||||
$obj->failReason = $failReason;
|
||||
|
||||
return $obj;
|
||||
}
|
||||
|
||||
/** @noinspection PhpUnused */
|
||||
public function withToken(?string $token): self
|
||||
{
|
||||
$obj = clone $this;
|
||||
$obj->token = $token;
|
||||
|
||||
return $obj;
|
||||
}
|
||||
|
||||
/** @noinspection PhpUnused */
|
||||
public function withView(?string $view): self
|
||||
{
|
||||
$obj = clone $this;
|
||||
$obj->view = $view;
|
||||
|
||||
return $obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
*/
|
||||
public function withDataItem(string $name, $value): self
|
||||
{
|
||||
$obj = clone $this;
|
||||
$obj->data[$name] = $value;
|
||||
|
||||
return $obj;
|
||||
}
|
||||
}
|
||||
48
application/Espo/Core/Authentication/Result/FailReason.php
Normal file
48
application/Espo/Core/Authentication/Result/FailReason.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\Result;
|
||||
|
||||
class FailReason
|
||||
{
|
||||
public const DENIED = 'Denied';
|
||||
public const CODE_NOT_VERIFIED = 'Code not verified';
|
||||
public const NO_USERNAME = 'No username';
|
||||
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';
|
||||
public const DISCREPANT_DATA = 'Discrepant authentication data';
|
||||
public const ANOTHER_USER_NOT_FOUND = 'Another user not found';
|
||||
public const ANOTHER_USER_NOT_ALLOWED = 'Another user not allowed';
|
||||
public const ERROR = 'Error';
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\TwoFactor\Email;
|
||||
|
||||
use Espo\Core\Authentication\HeaderKey;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Mail\Exceptions\SendingError;
|
||||
use Espo\Core\Utils\Log;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\Entities\User;
|
||||
use Espo\Entities\UserData;
|
||||
use Espo\Repositories\UserData as UserDataRepository;
|
||||
use Espo\Core\Authentication\TwoFactor\Login;
|
||||
use Espo\Core\Authentication\Result;
|
||||
use Espo\Core\Authentication\Result\Data as ResultData;
|
||||
use Espo\Core\Authentication\Result\FailReason;
|
||||
use Espo\Core\Api\Request;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class EmailLogin implements Login
|
||||
{
|
||||
public const NAME = 'Email';
|
||||
|
||||
public function __construct(
|
||||
private EntityManager $entityManager,
|
||||
private Util $util,
|
||||
private Log $log
|
||||
) {}
|
||||
|
||||
public function login(Result $result, Request $request): Result
|
||||
{
|
||||
$code = $request->getHeader(HeaderKey::AUTHORIZATION_CODE);
|
||||
|
||||
$user = $result->getUser();
|
||||
|
||||
if (!$user) {
|
||||
throw new RuntimeException("No user.");
|
||||
}
|
||||
|
||||
if (!$code) {
|
||||
try {
|
||||
$this->util->sendCode($user);
|
||||
} catch (Forbidden|SendingError $e) {
|
||||
$this->log->error("Could not send 2FA code for user {$user->getUserName()}. " . $e->getMessage());
|
||||
|
||||
return Result::fail(FailReason::ERROR);
|
||||
}
|
||||
|
||||
return Result::secondStepRequired($user, $this->getResultData());
|
||||
}
|
||||
|
||||
if ($this->verifyCode($user, $code)) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
return Result::fail(FailReason::CODE_NOT_VERIFIED);
|
||||
}
|
||||
|
||||
private function getResultData(): ResultData
|
||||
{
|
||||
return ResultData::createWithMessage('enterCodeSentInEmail');
|
||||
}
|
||||
|
||||
private function verifyCode(User $user, string $code): bool
|
||||
{
|
||||
$userData = $this->getUserDataRepository()->getByUserId($user->getId());
|
||||
|
||||
if (!$userData) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$userData->get('auth2FA')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($userData->get('auth2FAMethod') !== self::NAME) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->util->verifyCode($user, $code);
|
||||
}
|
||||
|
||||
private function getUserDataRepository(): UserDataRepository
|
||||
{
|
||||
/** @var UserDataRepository */
|
||||
return $this->entityManager->getRepository(UserData::ENTITY_TYPE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\TwoFactor\Email;
|
||||
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Entities\User;
|
||||
use Espo\Core\Authentication\TwoFactor\UserSetup;
|
||||
|
||||
use stdClass;
|
||||
|
||||
/**
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
class EmailUserSetup implements UserSetup
|
||||
{
|
||||
public function __construct(private Util $util)
|
||||
{}
|
||||
|
||||
public function getData(User $user): stdClass
|
||||
{
|
||||
return (object) [
|
||||
'emailAddressList' => $user->getEmailAddressGroup()->getAddressList(),
|
||||
];
|
||||
}
|
||||
|
||||
public function verifyData(User $user, stdClass $payloadData): bool
|
||||
{
|
||||
$code = $payloadData->code ?? null;
|
||||
|
||||
if ($code === null) {
|
||||
throw new BadRequest("No code.");
|
||||
}
|
||||
|
||||
$codeModified = str_replace(' ', '', trim($code));
|
||||
|
||||
if (!$codeModified) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->util->verifyCode($user, $codeModified);
|
||||
}
|
||||
}
|
||||
344
application/Espo/Core/Authentication/TwoFactor/Email/Util.php
Normal file
344
application/Espo/Core/Authentication/TwoFactor/Email/Util.php
Normal file
@@ -0,0 +1,344 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\TwoFactor\Email;
|
||||
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Mail\Exceptions\SendingError;
|
||||
use Espo\Core\Name\Field;
|
||||
use Espo\Core\Utils\Config;
|
||||
use Espo\Core\Mail\EmailSender;
|
||||
use Espo\Core\Mail\EmailFactory;
|
||||
use Espo\Core\Utils\TemplateFileManager;
|
||||
use Espo\Core\Htmlizer\HtmlizerFactory;
|
||||
use Espo\Core\Field\DateTime;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\ORM\Query\Part\Condition as Cond;
|
||||
use Espo\Entities\User;
|
||||
use Espo\Entities\Email;
|
||||
use Espo\Entities\TwoFactorCode;
|
||||
use Espo\Entities\UserData;
|
||||
use Espo\Repositories\UserData as UserDataRepository;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
use const STR_PAD_LEFT;
|
||||
|
||||
class Util
|
||||
{
|
||||
/**
|
||||
* A lifetime of a code.
|
||||
*/
|
||||
private const CODE_LIFETIME_PERIOD = '10 minutes';
|
||||
|
||||
/**
|
||||
* A max number of attempts to try a single code.
|
||||
*/
|
||||
private const CODE_ATTEMPTS_COUNT = 5;
|
||||
|
||||
/**
|
||||
* A length of a code.
|
||||
*/
|
||||
private const CODE_LENGTH = 7;
|
||||
|
||||
/**
|
||||
* A max number of codes tried by a user in a period defined by `CODE_LIMIT_PERIOD`.
|
||||
*/
|
||||
private const CODE_LIMIT = 5;
|
||||
|
||||
/**
|
||||
* A period for limiting trying to too many codes.
|
||||
*/
|
||||
private const CODE_LIMIT_PERIOD = '10 minutes';
|
||||
|
||||
public function __construct(
|
||||
private EntityManager $entityManager,
|
||||
private Config $config,
|
||||
private EmailSender $emailSender,
|
||||
private TemplateFileManager $templateFileManager,
|
||||
private HtmlizerFactory $htmlizerFactory,
|
||||
private EmailFactory $emailFactory
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
*/
|
||||
public function storeEmailAddress(User $user, string $emailAddress): void
|
||||
{
|
||||
$this->checkEmailAddressIsUsers($user, $emailAddress);
|
||||
|
||||
$userData = $this->getUserDataRepository()->getByUserId($user->getId());
|
||||
|
||||
if (!$userData) {
|
||||
throw new RuntimeException("UserData not found.");
|
||||
}
|
||||
|
||||
$userData->set('auth2FAEmailAddress', $emailAddress);
|
||||
|
||||
$this->entityManager->saveEntity($userData);
|
||||
}
|
||||
|
||||
public function verifyCode(User $user, string $code): bool
|
||||
{
|
||||
$codeEntity = $this->findCodeEntity($user);
|
||||
|
||||
if (!$codeEntity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($codeEntity->getAttemptsLeft() <= 1) {
|
||||
$this->decrementAttemptsLeft($codeEntity);
|
||||
$this->inactivateExistingCodeRecords($user);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($codeEntity->getCode() !== $code) {
|
||||
$this->decrementAttemptsLeft($codeEntity);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$this->isCodeValidByLifetime($codeEntity)) {
|
||||
$this->inactivateExistingCodeRecords($user);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->inactivateExistingCodeRecords($user);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws SendingError
|
||||
* @throws Forbidden
|
||||
*/
|
||||
public function sendCode(User $user, ?string $emailAddress = null): void
|
||||
{
|
||||
if ($emailAddress === null) {
|
||||
$emailAddress = $this->getEmailAddress($user);
|
||||
}
|
||||
|
||||
$this->checkEmailAddressIsUsers($user, $emailAddress);
|
||||
$this->checkCodeLimit($user);
|
||||
|
||||
$code = $this->generateCode();
|
||||
|
||||
$this->inactivateExistingCodeRecords($user);
|
||||
$this->createCodeRecord($user, $code);
|
||||
|
||||
$email = $this->createEmail($user, $code, $emailAddress);
|
||||
|
||||
$this->emailSender->send($email);
|
||||
}
|
||||
|
||||
private function isCodeValidByLifetime(TwoFactorCode $codeEntity): bool
|
||||
{
|
||||
$period = $this->config->get('auth2FAEmailCodeLifetimePeriod') ?? self::CODE_LIFETIME_PERIOD;
|
||||
|
||||
$validUntil = $codeEntity->getCreatedAt()->modify($period);
|
||||
|
||||
if (DateTime::createNow()->diff($validUntil)->invert) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function findCodeEntity(User $user): ?TwoFactorCode
|
||||
{
|
||||
/** @var ?TwoFactorCode */
|
||||
return $this->entityManager
|
||||
->getRDBRepository(TwoFactorCode::ENTITY_TYPE)
|
||||
->where([
|
||||
'method' => EmailLogin::NAME,
|
||||
'userId' => $user->getId(),
|
||||
'isActive' => true,
|
||||
])
|
||||
->findOne();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
*/
|
||||
private function getEmailAddress(User $user): string
|
||||
{
|
||||
$userData = $this->getUserDataRepository()->getByUserId($user->getId());
|
||||
|
||||
if (!$userData) {
|
||||
throw new RuntimeException("UserData not found.");
|
||||
}
|
||||
|
||||
$emailAddress = $userData->get('auth2FAEmailAddress');
|
||||
|
||||
if ($emailAddress) {
|
||||
return $emailAddress;
|
||||
}
|
||||
|
||||
if ($user->getEmailAddressGroup()->getCount() === 0) {
|
||||
throw new Forbidden("User does not have email address.");
|
||||
}
|
||||
|
||||
/** @var string */
|
||||
return $user->getEmailAddressGroup()->getPrimaryAddress();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
*/
|
||||
private function checkEmailAddressIsUsers(User $user, string $emailAddress): void
|
||||
{
|
||||
$userAddressList = array_map(
|
||||
function (string $item) {
|
||||
return strtolower($item);
|
||||
},
|
||||
$user->getEmailAddressGroup()->getAddressList()
|
||||
);
|
||||
|
||||
if (!in_array(strtolower($emailAddress), $userAddressList)) {
|
||||
throw new Forbidden("Email address is not one of user's.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
*/
|
||||
private function checkCodeLimit(User $user): void
|
||||
{
|
||||
$limit = $this->config->get('auth2FAEmailCodeLimit') ?? self::CODE_LIMIT;
|
||||
$period = $this->config->get('auth2FAEmailCodeLimitPeriod') ?? self::CODE_LIMIT_PERIOD;
|
||||
|
||||
$from = DateTime::createNow()
|
||||
->modify('-' . $period)
|
||||
->toString();
|
||||
|
||||
$count = $this->entityManager
|
||||
->getRDBRepository(TwoFactorCode::ENTITY_TYPE)
|
||||
->where(
|
||||
Cond::and(
|
||||
Cond::equal(Cond::column('method'), 'Email'),
|
||||
Cond::equal(Cond::column('userId'), $user->getId()),
|
||||
Cond::greaterOrEqual(Cond::column(Field::CREATED_AT), $from),
|
||||
Cond::lessOrEqual(Cond::column('attemptsLeft'), 0),
|
||||
)
|
||||
)
|
||||
->count();
|
||||
|
||||
if ($count >= $limit) {
|
||||
throw new Forbidden("Max code count exceeded.");
|
||||
}
|
||||
}
|
||||
|
||||
private function generateCode(): string
|
||||
{
|
||||
$codeLength = $this->config->get('auth2FAEmailCodeLength') ?? self::CODE_LENGTH;
|
||||
|
||||
$max = pow(10, $codeLength) - 1;
|
||||
|
||||
/** @noinspection PhpUnhandledExceptionInspection */
|
||||
return str_pad(
|
||||
(string) random_int(0, $max),
|
||||
$codeLength,
|
||||
'0',
|
||||
STR_PAD_LEFT
|
||||
);
|
||||
}
|
||||
|
||||
private function createEmail(User $user, string $code, string $emailAddress): Email
|
||||
{
|
||||
$subjectTpl = $this->templateFileManager->getTemplate('twoFactorCode', 'subject');
|
||||
$bodyTpl = $this->templateFileManager->getTemplate('twoFactorCode', 'body');
|
||||
|
||||
$htmlizer = $this->htmlizerFactory->create();
|
||||
|
||||
$data = [
|
||||
'code' => $code,
|
||||
];
|
||||
|
||||
$subject = $htmlizer->render($user, $subjectTpl, null, $data, true);
|
||||
$body = $htmlizer->render($user, $bodyTpl, null, $data, true);
|
||||
|
||||
$email = $this->emailFactory->create();
|
||||
|
||||
$email->setSubject($subject);
|
||||
$email->setBody($body);
|
||||
$email->addToAddress($emailAddress);
|
||||
|
||||
return $email;
|
||||
}
|
||||
|
||||
private function inactivateExistingCodeRecords(User $user): void
|
||||
{
|
||||
$query = $this->entityManager
|
||||
->getQueryBuilder()
|
||||
->update()
|
||||
->in(TwoFactorCode::ENTITY_TYPE)
|
||||
->where([
|
||||
'userId' => $user->getId(),
|
||||
'method' => EmailLogin::NAME,
|
||||
])
|
||||
->set([
|
||||
'isActive' => false,
|
||||
])
|
||||
->build();
|
||||
|
||||
$this->entityManager
|
||||
->getQueryExecutor()
|
||||
->execute($query);
|
||||
}
|
||||
|
||||
private function createCodeRecord(User $user, string $code): void
|
||||
{
|
||||
$this->entityManager->createEntity(TwoFactorCode::ENTITY_TYPE, [
|
||||
'code' => $code,
|
||||
'userId' => $user->getId(),
|
||||
'method' => EmailLogin::NAME,
|
||||
'attemptsLeft' => $this->getCodeAttemptsCount(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function getUserDataRepository(): UserDataRepository
|
||||
{
|
||||
/** @var UserDataRepository */
|
||||
return $this->entityManager->getRepository(UserData::ENTITY_TYPE);
|
||||
}
|
||||
|
||||
private function decrementAttemptsLeft(TwoFactorCode $codeEntity): void
|
||||
{
|
||||
$codeEntity->decrementAttemptsLeft();
|
||||
|
||||
$this->entityManager->saveEntity($codeEntity);
|
||||
}
|
||||
|
||||
private function getCodeAttemptsCount(): int
|
||||
{
|
||||
return $this->config->get('auth2FAEmailCodeAttemptsCount') ?? self::CODE_ATTEMPTS_COUNT;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\TwoFactor\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
class NotConfigured extends Exception
|
||||
{}
|
||||
41
application/Espo/Core/Authentication/TwoFactor/Login.php
Normal file
41
application/Espo/Core/Authentication/TwoFactor/Login.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\TwoFactor;
|
||||
|
||||
use Espo\Core\Authentication\Result;
|
||||
use Espo\Core\Api\Request;
|
||||
|
||||
/**
|
||||
* Processes second-step logging in.
|
||||
*/
|
||||
interface Login
|
||||
{
|
||||
public function login(Result $result, Request $request): Result;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\TwoFactor;
|
||||
|
||||
use Espo\Core\InjectableFactory;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
|
||||
use LogicException;
|
||||
|
||||
class LoginFactory
|
||||
{
|
||||
public function __construct(private InjectableFactory $injectableFactory, private Metadata $metadata)
|
||||
{}
|
||||
|
||||
public function create(string $method): Login
|
||||
{
|
||||
/** @var ?class-string<Login> $className */
|
||||
$className = $this->metadata->get(['app', 'authentication2FAMethods', $method, 'loginClassName']);
|
||||
|
||||
if (!$className) {
|
||||
throw new LogicException("No login-class class for '$method'.");
|
||||
}
|
||||
|
||||
return $this->injectableFactory->create($className);
|
||||
}
|
||||
}
|
||||
121
application/Espo/Core/Authentication/TwoFactor/Sms/SmsLogin.php
Normal file
121
application/Espo/Core/Authentication/TwoFactor/Sms/SmsLogin.php
Normal file
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\TwoFactor\Sms;
|
||||
|
||||
use Espo\Core\Authentication\HeaderKey;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Utils\Log;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\Entities\User;
|
||||
use Espo\Entities\UserData;
|
||||
use Espo\Repositories\UserData as UserDataRepository;
|
||||
use Espo\Core\Authentication\TwoFactor\Login;
|
||||
use Espo\Core\Authentication\Result;
|
||||
use Espo\Core\Authentication\Result\Data as ResultData;
|
||||
use Espo\Core\Authentication\Result\FailReason;
|
||||
use Espo\Core\Api\Request;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class SmsLogin implements Login
|
||||
{
|
||||
public const NAME = 'Sms';
|
||||
|
||||
public function __construct(
|
||||
private EntityManager $entityManager,
|
||||
private Util $util,
|
||||
private Log $log
|
||||
) {}
|
||||
|
||||
public function login(Result $result, Request $request): Result
|
||||
{
|
||||
$code = $request->getHeader(HeaderKey::AUTHORIZATION_CODE);
|
||||
|
||||
$user = $result->getUser();
|
||||
|
||||
if (!$user) {
|
||||
throw new RuntimeException("No user.");
|
||||
}
|
||||
|
||||
if (!$code) {
|
||||
$user = $result->getUser();
|
||||
|
||||
if (!$user) {
|
||||
throw new RuntimeException("No user.");
|
||||
}
|
||||
|
||||
try {
|
||||
$this->util->sendCode($user);
|
||||
} catch (Forbidden $e) {
|
||||
$this->log->error("Could not send 2FA code for user {$user->getUserName()}. " . $e->getMessage());
|
||||
|
||||
return Result::fail(FailReason::ERROR);
|
||||
}
|
||||
|
||||
return Result::secondStepRequired($user, $this->getResultData());
|
||||
}
|
||||
|
||||
if ($this->verifyCode($user, $code)) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
return Result::fail(FailReason::CODE_NOT_VERIFIED);
|
||||
}
|
||||
|
||||
private function getResultData(): ResultData
|
||||
{
|
||||
return ResultData::createWithMessage('enterCodeSentBySms');
|
||||
}
|
||||
|
||||
private function verifyCode(User $user, string $code): bool
|
||||
{
|
||||
$userData = $this->getUserDataRepository()->getByUserId($user->getId());
|
||||
|
||||
if (!$userData) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$userData->get('auth2FA')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($userData->get('auth2FAMethod') !== self::NAME) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->util->verifyCode($user, $code);
|
||||
}
|
||||
|
||||
private function getUserDataRepository(): UserDataRepository
|
||||
{
|
||||
/** @var UserDataRepository */
|
||||
return $this->entityManager->getRepository(UserData::ENTITY_TYPE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\TwoFactor\Sms;
|
||||
|
||||
use Espo\Core\Authentication\TwoFactor\Exceptions\NotConfigured;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Portal\Utils\Config;
|
||||
use Espo\Entities\User;
|
||||
use Espo\Core\Authentication\TwoFactor\UserSetup;
|
||||
|
||||
use stdClass;
|
||||
|
||||
/**
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
class SmsUserSetup implements UserSetup
|
||||
{
|
||||
public function __construct(
|
||||
private Util $util,
|
||||
private Config $config
|
||||
) {}
|
||||
|
||||
public function getData(User $user): stdClass
|
||||
{
|
||||
if (!$this->config->get('smsProvider')) {
|
||||
throw new NotConfigured("No SMS provider.");
|
||||
}
|
||||
|
||||
return (object) [
|
||||
'phoneNumberList' => $user->getPhoneNumberGroup()->getNumberList(),
|
||||
];
|
||||
}
|
||||
|
||||
public function verifyData(User $user, stdClass $payloadData): bool
|
||||
{
|
||||
$code = $payloadData->code ?? null;
|
||||
|
||||
if ($code === null) {
|
||||
throw new BadRequest("No code.");
|
||||
}
|
||||
|
||||
$codeModified = str_replace(' ', '', trim($code));
|
||||
|
||||
if (!$codeModified) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->util->verifyCode($user, $codeModified);
|
||||
}
|
||||
}
|
||||
332
application/Espo/Core/Authentication/TwoFactor/Sms/Util.php
Normal file
332
application/Espo/Core/Authentication/TwoFactor/Sms/Util.php
Normal file
@@ -0,0 +1,332 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\TwoFactor\Sms;
|
||||
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Name\Field;
|
||||
use Espo\Core\Utils\Config;
|
||||
use Espo\Core\Sms\SmsSender;
|
||||
use Espo\Core\Sms\SmsFactory;
|
||||
use Espo\Core\Utils\Language;
|
||||
use Espo\Core\Field\DateTime;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\ORM\Query\Part\Condition as Cond;
|
||||
use Espo\Entities\User;
|
||||
use Espo\Entities\Sms;
|
||||
use Espo\Entities\TwoFactorCode;
|
||||
use Espo\Entities\UserData;
|
||||
use Espo\Repositories\UserData as UserDataRepository;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
use const STR_PAD_LEFT;
|
||||
|
||||
class Util
|
||||
{
|
||||
private const METHOD = SmsLogin::NAME;
|
||||
|
||||
/**
|
||||
* A lifetime of a code.
|
||||
*/
|
||||
private const CODE_LIFETIME_PERIOD = '10 minutes';
|
||||
/**
|
||||
* A max number of attempts to try a single code.
|
||||
*/
|
||||
private const CODE_ATTEMPTS_COUNT = 5;
|
||||
/**
|
||||
* A length of a code.
|
||||
*/
|
||||
private const CODE_LENGTH = 6;
|
||||
/**
|
||||
* A max number of codes tried by a user in a period defined by `CODE_LIMIT_PERIOD`.
|
||||
*/
|
||||
private const CODE_LIMIT = 5;
|
||||
/**
|
||||
* A period for limiting trying to too many codes.
|
||||
*/
|
||||
private const CODE_LIMIT_PERIOD = '20 minutes';
|
||||
|
||||
public function __construct(
|
||||
private EntityManager $entityManager,
|
||||
private Config $config,
|
||||
private SmsSender $smsSender,
|
||||
private Language $language,
|
||||
private SmsFactory $smsFactory
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
*/
|
||||
public function storePhoneNumber(User $user, string $phoneNumber): void
|
||||
{
|
||||
$this->checkPhoneNumberIsUsers($user, $phoneNumber);
|
||||
|
||||
$userData = $this->getUserDataRepository()->getByUserId($user->getId());
|
||||
|
||||
if (!$userData) {
|
||||
throw new RuntimeException();
|
||||
}
|
||||
|
||||
$userData->set('auth2FASmsPhoneNumber', $phoneNumber);
|
||||
|
||||
$this->entityManager->saveEntity($userData);
|
||||
}
|
||||
|
||||
public function verifyCode(User $user, string $code): bool
|
||||
{
|
||||
$codeEntity = $this->findCodeEntity($user);
|
||||
|
||||
if (!$codeEntity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($codeEntity->getAttemptsLeft() <= 1) {
|
||||
$this->decrementAttemptsLeft($codeEntity);
|
||||
$this->inactivateExistingCodeRecords($user);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($codeEntity->getCode() !== $code) {
|
||||
$this->decrementAttemptsLeft($codeEntity);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$this->isCodeValidByLifetime($codeEntity)) {
|
||||
$this->inactivateExistingCodeRecords($user);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->inactivateExistingCodeRecords($user);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
*/
|
||||
public function sendCode(User $user, ?string $phoneNumber = null): void
|
||||
{
|
||||
if ($phoneNumber === null) {
|
||||
$phoneNumber = $this->getPhoneNumber($user);
|
||||
}
|
||||
|
||||
$this->checkPhoneNumberIsUsers($user, $phoneNumber);
|
||||
$this->checkCodeLimit($user);
|
||||
|
||||
$code = $this->generateCode();
|
||||
|
||||
$this->inactivateExistingCodeRecords($user);
|
||||
$this->createCodeRecord($user, $code);
|
||||
|
||||
$sms = $this->createSms($code, $phoneNumber);
|
||||
|
||||
$this->smsSender->send($sms);
|
||||
}
|
||||
|
||||
private function isCodeValidByLifetime(TwoFactorCode $codeEntity): bool
|
||||
{
|
||||
$period = $this->config->get('auth2FASmsCodeLifetimePeriod') ?? self::CODE_LIFETIME_PERIOD;
|
||||
|
||||
$validUntil = $codeEntity->getCreatedAt()->modify($period);
|
||||
|
||||
if (DateTime::createNow()->diff($validUntil)->invert) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function findCodeEntity(User $user): ?TwoFactorCode
|
||||
{
|
||||
/** @var ?TwoFactorCode */
|
||||
return $this->entityManager
|
||||
->getRDBRepository(TwoFactorCode::ENTITY_TYPE)
|
||||
->where([
|
||||
'method' => self::METHOD,
|
||||
'userId' => $user->getId(),
|
||||
'isActive' => true,
|
||||
])
|
||||
->findOne();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
*/
|
||||
private function getPhoneNumber(User $user): string
|
||||
{
|
||||
$userData = $this->getUserDataRepository()->getByUserId($user->getId());
|
||||
|
||||
if (!$userData) {
|
||||
throw new RuntimeException("UserData not found.");
|
||||
}
|
||||
|
||||
$phoneNumber = $userData->get('auth2FASmsPhoneNumber');
|
||||
|
||||
if ($phoneNumber) {
|
||||
return $phoneNumber;
|
||||
}
|
||||
|
||||
if ($user->getPhoneNumberGroup()->getCount() === 0) {
|
||||
throw new Forbidden("User does not have phone number.");
|
||||
}
|
||||
|
||||
/** @var string */
|
||||
return $user->getPhoneNumberGroup()->getPrimaryNumber();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
*/
|
||||
private function checkPhoneNumberIsUsers(User $user, string $phoneNumber): void
|
||||
{
|
||||
$userNumberList = array_map(
|
||||
function (string $item) {
|
||||
return strtolower($item);
|
||||
},
|
||||
$user->getPhoneNumberGroup()->getNumberList()
|
||||
);
|
||||
|
||||
if (!in_array(strtolower($phoneNumber), $userNumberList)) {
|
||||
throw new Forbidden("Phone number is not one of user's.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
*/
|
||||
private function checkCodeLimit(User $user): void
|
||||
{
|
||||
$limit = $this->config->get('auth2FASmsCodeLimit') ?? self::CODE_LIMIT;
|
||||
$period = $this->config->get('auth2FASmsCodeLimitPeriod') ?? self::CODE_LIMIT_PERIOD;
|
||||
|
||||
$from = DateTime::createNow()
|
||||
->modify('-' . $period)
|
||||
->toString();
|
||||
|
||||
$count = $this->entityManager
|
||||
->getRDBRepository(TwoFactorCode::ENTITY_TYPE)
|
||||
->where(
|
||||
Cond::and(
|
||||
Cond::equal(Cond::column('method'), self::METHOD),
|
||||
Cond::equal(Cond::column('userId'), $user->getId()),
|
||||
Cond::greaterOrEqual(Cond::column(Field::CREATED_AT), $from),
|
||||
Cond::lessOrEqual(Cond::column('attemptsLeft'), 0),
|
||||
)
|
||||
)
|
||||
->count();
|
||||
|
||||
if ($count >= $limit) {
|
||||
throw new Forbidden("Max code count exceeded.");
|
||||
}
|
||||
}
|
||||
|
||||
private function generateCode(): string
|
||||
{
|
||||
$codeLength = $this->config->get('auth2FASmsCodeLength') ?? self::CODE_LENGTH;
|
||||
|
||||
$max = pow(10, $codeLength) - 1;
|
||||
|
||||
/** @noinspection PhpUnhandledExceptionInspection */
|
||||
return str_pad(
|
||||
(string) random_int(0, $max),
|
||||
$codeLength,
|
||||
'0',
|
||||
STR_PAD_LEFT
|
||||
);
|
||||
}
|
||||
|
||||
private function createSms(string $code, string $phoneNumber): Sms
|
||||
{
|
||||
$fromNumber = $this->config->get('outboundSmsFromNumber');
|
||||
|
||||
$bodyTpl = $this->language->translateLabel('yourAuthenticationCode', 'messages', 'User');
|
||||
|
||||
$body = str_replace('{code}', $code, $bodyTpl);
|
||||
|
||||
$sms = $this->smsFactory->create();
|
||||
|
||||
$sms->setFromNumber($fromNumber);
|
||||
$sms->setBody($body);
|
||||
$sms->addToNumber($phoneNumber);
|
||||
|
||||
return $sms;
|
||||
}
|
||||
|
||||
private function inactivateExistingCodeRecords(User $user): void
|
||||
{
|
||||
$query = $this->entityManager
|
||||
->getQueryBuilder()
|
||||
->update()
|
||||
->in(TwoFactorCode::ENTITY_TYPE)
|
||||
->where([
|
||||
'userId' => $user->getId(),
|
||||
'method' => self::METHOD,
|
||||
])
|
||||
->set([
|
||||
'isActive' => false,
|
||||
])
|
||||
->build();
|
||||
|
||||
$this->entityManager
|
||||
->getQueryExecutor()
|
||||
->execute($query);
|
||||
}
|
||||
|
||||
private function createCodeRecord(User $user, string $code): void
|
||||
{
|
||||
$this->entityManager->createEntity(TwoFactorCode::ENTITY_TYPE, [
|
||||
'code' => $code,
|
||||
'userId' => $user->getId(),
|
||||
'method' => self::METHOD,
|
||||
'attemptsLeft' => $this->getCodeAttemptsCount(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function getUserDataRepository(): UserDataRepository
|
||||
{
|
||||
/** @var UserDataRepository */
|
||||
return $this->entityManager->getRepository(UserData::ENTITY_TYPE);
|
||||
}
|
||||
|
||||
private function decrementAttemptsLeft(TwoFactorCode $codeEntity): void
|
||||
{
|
||||
$codeEntity->decrementAttemptsLeft();
|
||||
|
||||
$this->entityManager->saveEntity($codeEntity);
|
||||
}
|
||||
|
||||
private function getCodeAttemptsCount(): int
|
||||
{
|
||||
return $this->config->get('auth2FASmsCodeAttemptsCount') ?? self::CODE_ATTEMPTS_COUNT;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\TwoFactor\Totp;
|
||||
|
||||
use Espo\Core\Authentication\HeaderKey;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\Entities\User;
|
||||
use Espo\Entities\UserData;
|
||||
use Espo\Repositories\UserData as UserDataRepository;
|
||||
use Espo\Core\Authentication\TwoFactor\Login;
|
||||
use Espo\Core\Authentication\Result;
|
||||
use Espo\Core\Authentication\Result\Data as ResultData;
|
||||
use Espo\Core\Authentication\Result\FailReason;
|
||||
use Espo\Core\Api\Request;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
class TotpLogin implements Login
|
||||
{
|
||||
public const NAME = 'Totp';
|
||||
|
||||
public function __construct(
|
||||
private EntityManager $entityManager,
|
||||
private Util $totp
|
||||
) {}
|
||||
|
||||
public function login(Result $result, Request $request): Result
|
||||
{
|
||||
$code = $request->getHeader(HeaderKey::AUTHORIZATION_CODE);
|
||||
|
||||
$user = $result->getUser();
|
||||
|
||||
if (!$user) {
|
||||
throw new RuntimeException("No user.");
|
||||
}
|
||||
|
||||
if (!$code) {
|
||||
return Result::secondStepRequired($user, $this->getResultData());
|
||||
}
|
||||
|
||||
if ($this->verifyCode($user, $code)) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
return Result::fail(FailReason::CODE_NOT_VERIFIED);
|
||||
}
|
||||
|
||||
private function getResultData(): ResultData
|
||||
{
|
||||
return ResultData::createWithMessage('enterTotpCode');
|
||||
}
|
||||
|
||||
private function verifyCode(User $user, string $code): bool
|
||||
{
|
||||
$userData = $this->getUserDataRepository()->getByUserId($user->getId());
|
||||
|
||||
if (!$userData) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$userData->get('auth2FA')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($userData->get('auth2FAMethod') !== self::NAME) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$secret = $userData->get('auth2FATotpSecret');
|
||||
|
||||
if (!$secret) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->totp->verifyCode($secret, $code);
|
||||
}
|
||||
|
||||
private function getUserDataRepository(): UserDataRepository
|
||||
{
|
||||
/** @var UserDataRepository $repository */
|
||||
$repository = $this->entityManager->getRepository(UserData::ENTITY_TYPE);
|
||||
|
||||
return $repository;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\TwoFactor\Totp;
|
||||
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Entities\UserData;
|
||||
use Espo\Entities\User;
|
||||
use Espo\Repositories\UserData as UserDataRepository;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\Core\Authentication\TwoFactor\UserSetup;
|
||||
use Espo\Core\Utils\Config;
|
||||
|
||||
use RuntimeException;
|
||||
use stdClass;
|
||||
|
||||
/**
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
class TotpUserSetup implements UserSetup
|
||||
{
|
||||
public function __construct(
|
||||
private Util $totp,
|
||||
private Config $config,
|
||||
private EntityManager $entityManager
|
||||
) {}
|
||||
|
||||
public function getData(User $user): stdClass
|
||||
{
|
||||
$userName = $user->get('userName');
|
||||
|
||||
$secret = $this->totp->createSecret();
|
||||
|
||||
$label = rawurlencode($this->config->get('applicationName')) . ':' . rawurlencode($userName);
|
||||
|
||||
$this->storeSecret($user, $secret);
|
||||
|
||||
return (object) [
|
||||
'auth2FATotpSecret' => $secret,
|
||||
'label' => $label,
|
||||
];
|
||||
}
|
||||
|
||||
public function verifyData(User $user, stdClass $payloadData): bool
|
||||
{
|
||||
$code = $payloadData->code ?? null;
|
||||
|
||||
if ($code === null) {
|
||||
throw new BadRequest("No code.");
|
||||
}
|
||||
|
||||
$codeModified = str_replace(' ', '', trim($code));
|
||||
|
||||
if (!$codeModified) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$userData = $this->getUserDataRepository()->getByUserId($user->getId());
|
||||
|
||||
if (!$userData) {
|
||||
throw new RuntimeException("User not found.");
|
||||
}
|
||||
|
||||
$secret = $userData->get('auth2FATotpSecret');
|
||||
|
||||
return $this->totp->verifyCode($secret, $codeModified);
|
||||
}
|
||||
|
||||
private function storeSecret(User $user, string $secret): void
|
||||
{
|
||||
$userData = $this->getUserDataRepository()->getByUserId($user->getId());
|
||||
|
||||
if (!$userData) {
|
||||
throw new RuntimeException();
|
||||
}
|
||||
|
||||
$userData->set('auth2FATotpSecret', $secret);
|
||||
|
||||
$this->entityManager->saveEntity($userData);
|
||||
}
|
||||
|
||||
private function getUserDataRepository(): UserDataRepository
|
||||
{
|
||||
/** @var UserDataRepository $repository */
|
||||
$repository = $this->entityManager->getRepository(UserData::ENTITY_TYPE);
|
||||
|
||||
return $repository;
|
||||
}
|
||||
}
|
||||
55
application/Espo/Core/Authentication/TwoFactor/Totp/Util.php
Normal file
55
application/Espo/Core/Authentication/TwoFactor/Totp/Util.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\TwoFactor\Totp;
|
||||
|
||||
use RobThree\Auth\TwoFactorAuth;
|
||||
use RobThree\Auth\TwoFactorAuthException;
|
||||
use RuntimeException;
|
||||
|
||||
class Util
|
||||
{
|
||||
public function verifyCode(string $secret, string $code): bool
|
||||
{
|
||||
$impl = new TwoFactorAuth();
|
||||
|
||||
return $impl->verifyCode($secret, $code);
|
||||
}
|
||||
|
||||
public function createSecret(): string
|
||||
{
|
||||
$impl = new TwoFactorAuth();
|
||||
|
||||
try {
|
||||
return $impl->createSecret();
|
||||
} catch (TwoFactorAuthException $e) {
|
||||
throw new RuntimeException($e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
56
application/Espo/Core/Authentication/TwoFactor/UserSetup.php
Normal file
56
application/Espo/Core/Authentication/TwoFactor/UserSetup.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\TwoFactor;
|
||||
|
||||
use Espo\Core\Authentication\TwoFactor\Exceptions\NotConfigured;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Entities\User;
|
||||
|
||||
use stdClass;
|
||||
|
||||
/**
|
||||
* 2FA setting-up for a user.
|
||||
*/
|
||||
interface UserSetup
|
||||
{
|
||||
/**
|
||||
* Get data needed for configuration for a user. Data will be passed to the front-end.
|
||||
*
|
||||
* @throws NotConfigured
|
||||
*/
|
||||
public function getData(User $user): stdClass;
|
||||
|
||||
/**
|
||||
* Verify input data before making 2FA enabled for a user.
|
||||
*
|
||||
* @throws BadRequest
|
||||
*/
|
||||
public function verifyData(User $user, stdClass $payloadData): bool;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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\TwoFactor;
|
||||
|
||||
use Espo\Core\InjectableFactory;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class UserSetupFactory
|
||||
{
|
||||
public function __construct(
|
||||
private InjectableFactory $injectableFactory,
|
||||
private Metadata $metadata
|
||||
) {}
|
||||
|
||||
public function create(string $method): UserSetup
|
||||
{
|
||||
/** @var ?class-string<UserSetup> $className */
|
||||
$className = $this->metadata->get(['app', 'authentication2FAMethods', $method, 'userSetupClassName']);
|
||||
|
||||
if (!$className) {
|
||||
throw new RuntimeException("No user-setup class for '$method'.");
|
||||
}
|
||||
|
||||
return $this->injectableFactory->create($className);
|
||||
}
|
||||
}
|
||||
59
application/Espo/Core/Authentication/Util/IpAddressUtil.php
Normal file
59
application/Espo/Core/Authentication/Util/IpAddressUtil.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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;
|
||||
|
||||
use CIDRmatch\CIDRmatch;
|
||||
|
||||
class IpAddressUtil
|
||||
{
|
||||
/**
|
||||
* @param string $ipAddress An IP address.
|
||||
* @param string[] $whitelist A whitelist. IPs or IP ranges in CIDR notation.
|
||||
*/
|
||||
public function isInWhitelist(string $ipAddress, array $whitelist): bool
|
||||
{
|
||||
$cidrMatch = new CIDRmatch();
|
||||
|
||||
foreach ($whitelist as $whiteIpAddress) {
|
||||
if ($ipAddress === $whiteIpAddress) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
str_contains($whiteIpAddress, '/') &&
|
||||
$cidrMatch->match($ipAddress, $whiteIpAddress)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
115
application/Espo/Core/Authentication/Util/MethodProvider.php
Normal file
115
application/Espo/Core/Authentication/Util/MethodProvider.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 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;
|
||||
|
||||
use Espo\Core\ApplicationState;
|
||||
use Espo\Core\Authentication\ConfigDataProvider;
|
||||
use Espo\Core\Authentication\Logins\Espo;
|
||||
use Espo\Core\ORM\EntityManagerProxy;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
use Espo\Entities\AuthenticationProvider;
|
||||
use Espo\Entities\Portal;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* An authentication method provider.
|
||||
*/
|
||||
class MethodProvider
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerProxy $entityManager,
|
||||
private ApplicationState $applicationState,
|
||||
private ConfigDataProvider $configDataProvider,
|
||||
private Metadata $metadata
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get an authentication method.
|
||||
*/
|
||||
public function get(): string
|
||||
{
|
||||
if ($this->applicationState->isPortal()) {
|
||||
$method = $this->getForPortal($this->applicationState->getPortal());
|
||||
|
||||
if ($method) {
|
||||
return $method;
|
||||
}
|
||||
|
||||
return $this->getDefaultForPortal();
|
||||
}
|
||||
|
||||
return $this->configDataProvider->getDefaultAuthenticationMethod();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an authentication method for portals. The method that is applied via the authentication provider link.
|
||||
* If no provider, then returns null.
|
||||
*/
|
||||
public function getForPortal(Portal $portal): ?string
|
||||
{
|
||||
$providerId = $portal->getAuthenticationProvider()?->getId();
|
||||
|
||||
if (!$providerId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var ?AuthenticationProvider $provider */
|
||||
$provider = $this->entityManager->getEntityById(AuthenticationProvider::ENTITY_TYPE, $providerId);
|
||||
|
||||
if (!$provider) {
|
||||
throw new RuntimeException("No authentication provider for portal.");
|
||||
}
|
||||
|
||||
$method = $provider->getMethod();
|
||||
|
||||
if (!$method) {
|
||||
throw new RuntimeException("No method in authentication provider.");
|
||||
}
|
||||
|
||||
return $method;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a default authentication method for portals. Should be used if a portal does not have
|
||||
* an authentication provider.
|
||||
*/
|
||||
private function getDefaultForPortal(): string
|
||||
{
|
||||
$method = $this->configDataProvider->getDefaultAuthenticationMethod();
|
||||
|
||||
$allow = $this->metadata->get(['authenticationMethods', $method, 'portalDefault']);
|
||||
|
||||
if (!$allow) {
|
||||
return Espo::NAME;
|
||||
}
|
||||
|
||||
return $method;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user