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