Initial commit

This commit is contained in:
root
2026-01-19 17:44:46 +01:00
commit 823af8b11d
8721 changed files with 1130846 additions and 0 deletions

View 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;
}

View 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;
}
}

View 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));
}
}

View 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;
}

View 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);
}
}

View 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;
}
}

View 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;
use Espo\Core\InjectableFactory;
class AuthenticationFactory
{
public function __construct(private InjectableFactory $injectableFactory)
{}
public function create(): Authentication
{
return $this->injectableFactory->create(Authentication::class);
}
}

View 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') ?? [];
}
}

View 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';
}

View 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();
}
}

View 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;
}

View File

@@ -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;
}
}

View 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\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;
}
}

View File

@@ -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.");
}
}

View 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;
}
}

View 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;
}

View 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;
}

View File

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

View File

@@ -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 {}

View 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\Jwt\Exceptions;
use Exception;
class Invalid extends Exception {}

View File

@@ -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 {}

View File

@@ -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 {}

View 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\Jwt\Exceptions;
use Exception;
class UnsupportedKey extends Exception {}

View 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;
}

View 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;
}

View 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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View 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;
}
}

View 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;
}
}

View 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)
);
}
}

View 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;
}
}

View 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.");
}
}
}

View 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 {}

View 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);
}
}

View 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;
}
}

View 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));
}
}

View 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;
}

View 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();
}
}

View 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);
}
}

View File

@@ -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;
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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;
}

View 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();
}
}

View 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;
}
}

View 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']);
}
}

View 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);
}
}
}

View 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;
}
}

View 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\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();
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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.");
}
}
}

View 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\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";
}
}

View 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;
}

View File

@@ -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);
}
}

View 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);
}
}

View File

@@ -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;
}
}

View File

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

View File

@@ -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).");
}
}
}

View 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;
}
}

View 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;
}
}

View 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';
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View 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;
}
}

View File

@@ -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
{}

View 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;
}

View File

@@ -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);
}
}

View 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);
}
}

View File

@@ -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);
}
}

View 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;
}
}

View 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\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;
}
}

View 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\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;
}
}

View 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());
}
}
}

View 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;
}

View 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;
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);
}
}

View 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;
}
}

View 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;
}
}