Initial commit
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Core\Authentication\TwoFactor\Email;
|
||||
|
||||
use Espo\Core\Authentication\HeaderKey;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Mail\Exceptions\SendingError;
|
||||
use Espo\Core\Utils\Log;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\Entities\User;
|
||||
use Espo\Entities\UserData;
|
||||
use Espo\Repositories\UserData as UserDataRepository;
|
||||
use Espo\Core\Authentication\TwoFactor\Login;
|
||||
use Espo\Core\Authentication\Result;
|
||||
use Espo\Core\Authentication\Result\Data as ResultData;
|
||||
use Espo\Core\Authentication\Result\FailReason;
|
||||
use Espo\Core\Api\Request;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class EmailLogin implements Login
|
||||
{
|
||||
public const NAME = 'Email';
|
||||
|
||||
public function __construct(
|
||||
private EntityManager $entityManager,
|
||||
private Util $util,
|
||||
private Log $log
|
||||
) {}
|
||||
|
||||
public function login(Result $result, Request $request): Result
|
||||
{
|
||||
$code = $request->getHeader(HeaderKey::AUTHORIZATION_CODE);
|
||||
|
||||
$user = $result->getUser();
|
||||
|
||||
if (!$user) {
|
||||
throw new RuntimeException("No user.");
|
||||
}
|
||||
|
||||
if (!$code) {
|
||||
try {
|
||||
$this->util->sendCode($user);
|
||||
} catch (Forbidden|SendingError $e) {
|
||||
$this->log->error("Could not send 2FA code for user {$user->getUserName()}. " . $e->getMessage());
|
||||
|
||||
return Result::fail(FailReason::ERROR);
|
||||
}
|
||||
|
||||
return Result::secondStepRequired($user, $this->getResultData());
|
||||
}
|
||||
|
||||
if ($this->verifyCode($user, $code)) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
return Result::fail(FailReason::CODE_NOT_VERIFIED);
|
||||
}
|
||||
|
||||
private function getResultData(): ResultData
|
||||
{
|
||||
return ResultData::createWithMessage('enterCodeSentInEmail');
|
||||
}
|
||||
|
||||
private function verifyCode(User $user, string $code): bool
|
||||
{
|
||||
$userData = $this->getUserDataRepository()->getByUserId($user->getId());
|
||||
|
||||
if (!$userData) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$userData->get('auth2FA')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($userData->get('auth2FAMethod') !== self::NAME) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->util->verifyCode($user, $code);
|
||||
}
|
||||
|
||||
private function getUserDataRepository(): UserDataRepository
|
||||
{
|
||||
/** @var UserDataRepository */
|
||||
return $this->entityManager->getRepository(UserData::ENTITY_TYPE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Core\Authentication\TwoFactor\Email;
|
||||
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Entities\User;
|
||||
use Espo\Core\Authentication\TwoFactor\UserSetup;
|
||||
|
||||
use stdClass;
|
||||
|
||||
/**
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
class EmailUserSetup implements UserSetup
|
||||
{
|
||||
public function __construct(private Util $util)
|
||||
{}
|
||||
|
||||
public function getData(User $user): stdClass
|
||||
{
|
||||
return (object) [
|
||||
'emailAddressList' => $user->getEmailAddressGroup()->getAddressList(),
|
||||
];
|
||||
}
|
||||
|
||||
public function verifyData(User $user, stdClass $payloadData): bool
|
||||
{
|
||||
$code = $payloadData->code ?? null;
|
||||
|
||||
if ($code === null) {
|
||||
throw new BadRequest("No code.");
|
||||
}
|
||||
|
||||
$codeModified = str_replace(' ', '', trim($code));
|
||||
|
||||
if (!$codeModified) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->util->verifyCode($user, $codeModified);
|
||||
}
|
||||
}
|
||||
344
application/Espo/Core/Authentication/TwoFactor/Email/Util.php
Normal file
344
application/Espo/Core/Authentication/TwoFactor/Email/Util.php
Normal file
@@ -0,0 +1,344 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Core\Authentication\TwoFactor\Email;
|
||||
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Mail\Exceptions\SendingError;
|
||||
use Espo\Core\Name\Field;
|
||||
use Espo\Core\Utils\Config;
|
||||
use Espo\Core\Mail\EmailSender;
|
||||
use Espo\Core\Mail\EmailFactory;
|
||||
use Espo\Core\Utils\TemplateFileManager;
|
||||
use Espo\Core\Htmlizer\HtmlizerFactory;
|
||||
use Espo\Core\Field\DateTime;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\ORM\Query\Part\Condition as Cond;
|
||||
use Espo\Entities\User;
|
||||
use Espo\Entities\Email;
|
||||
use Espo\Entities\TwoFactorCode;
|
||||
use Espo\Entities\UserData;
|
||||
use Espo\Repositories\UserData as UserDataRepository;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
use const STR_PAD_LEFT;
|
||||
|
||||
class Util
|
||||
{
|
||||
/**
|
||||
* A lifetime of a code.
|
||||
*/
|
||||
private const CODE_LIFETIME_PERIOD = '10 minutes';
|
||||
|
||||
/**
|
||||
* A max number of attempts to try a single code.
|
||||
*/
|
||||
private const CODE_ATTEMPTS_COUNT = 5;
|
||||
|
||||
/**
|
||||
* A length of a code.
|
||||
*/
|
||||
private const CODE_LENGTH = 7;
|
||||
|
||||
/**
|
||||
* A max number of codes tried by a user in a period defined by `CODE_LIMIT_PERIOD`.
|
||||
*/
|
||||
private const CODE_LIMIT = 5;
|
||||
|
||||
/**
|
||||
* A period for limiting trying to too many codes.
|
||||
*/
|
||||
private const CODE_LIMIT_PERIOD = '10 minutes';
|
||||
|
||||
public function __construct(
|
||||
private EntityManager $entityManager,
|
||||
private Config $config,
|
||||
private EmailSender $emailSender,
|
||||
private TemplateFileManager $templateFileManager,
|
||||
private HtmlizerFactory $htmlizerFactory,
|
||||
private EmailFactory $emailFactory
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
*/
|
||||
public function storeEmailAddress(User $user, string $emailAddress): void
|
||||
{
|
||||
$this->checkEmailAddressIsUsers($user, $emailAddress);
|
||||
|
||||
$userData = $this->getUserDataRepository()->getByUserId($user->getId());
|
||||
|
||||
if (!$userData) {
|
||||
throw new RuntimeException("UserData not found.");
|
||||
}
|
||||
|
||||
$userData->set('auth2FAEmailAddress', $emailAddress);
|
||||
|
||||
$this->entityManager->saveEntity($userData);
|
||||
}
|
||||
|
||||
public function verifyCode(User $user, string $code): bool
|
||||
{
|
||||
$codeEntity = $this->findCodeEntity($user);
|
||||
|
||||
if (!$codeEntity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($codeEntity->getAttemptsLeft() <= 1) {
|
||||
$this->decrementAttemptsLeft($codeEntity);
|
||||
$this->inactivateExistingCodeRecords($user);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($codeEntity->getCode() !== $code) {
|
||||
$this->decrementAttemptsLeft($codeEntity);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$this->isCodeValidByLifetime($codeEntity)) {
|
||||
$this->inactivateExistingCodeRecords($user);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->inactivateExistingCodeRecords($user);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws SendingError
|
||||
* @throws Forbidden
|
||||
*/
|
||||
public function sendCode(User $user, ?string $emailAddress = null): void
|
||||
{
|
||||
if ($emailAddress === null) {
|
||||
$emailAddress = $this->getEmailAddress($user);
|
||||
}
|
||||
|
||||
$this->checkEmailAddressIsUsers($user, $emailAddress);
|
||||
$this->checkCodeLimit($user);
|
||||
|
||||
$code = $this->generateCode();
|
||||
|
||||
$this->inactivateExistingCodeRecords($user);
|
||||
$this->createCodeRecord($user, $code);
|
||||
|
||||
$email = $this->createEmail($user, $code, $emailAddress);
|
||||
|
||||
$this->emailSender->send($email);
|
||||
}
|
||||
|
||||
private function isCodeValidByLifetime(TwoFactorCode $codeEntity): bool
|
||||
{
|
||||
$period = $this->config->get('auth2FAEmailCodeLifetimePeriod') ?? self::CODE_LIFETIME_PERIOD;
|
||||
|
||||
$validUntil = $codeEntity->getCreatedAt()->modify($period);
|
||||
|
||||
if (DateTime::createNow()->diff($validUntil)->invert) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function findCodeEntity(User $user): ?TwoFactorCode
|
||||
{
|
||||
/** @var ?TwoFactorCode */
|
||||
return $this->entityManager
|
||||
->getRDBRepository(TwoFactorCode::ENTITY_TYPE)
|
||||
->where([
|
||||
'method' => EmailLogin::NAME,
|
||||
'userId' => $user->getId(),
|
||||
'isActive' => true,
|
||||
])
|
||||
->findOne();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
*/
|
||||
private function getEmailAddress(User $user): string
|
||||
{
|
||||
$userData = $this->getUserDataRepository()->getByUserId($user->getId());
|
||||
|
||||
if (!$userData) {
|
||||
throw new RuntimeException("UserData not found.");
|
||||
}
|
||||
|
||||
$emailAddress = $userData->get('auth2FAEmailAddress');
|
||||
|
||||
if ($emailAddress) {
|
||||
return $emailAddress;
|
||||
}
|
||||
|
||||
if ($user->getEmailAddressGroup()->getCount() === 0) {
|
||||
throw new Forbidden("User does not have email address.");
|
||||
}
|
||||
|
||||
/** @var string */
|
||||
return $user->getEmailAddressGroup()->getPrimaryAddress();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
*/
|
||||
private function checkEmailAddressIsUsers(User $user, string $emailAddress): void
|
||||
{
|
||||
$userAddressList = array_map(
|
||||
function (string $item) {
|
||||
return strtolower($item);
|
||||
},
|
||||
$user->getEmailAddressGroup()->getAddressList()
|
||||
);
|
||||
|
||||
if (!in_array(strtolower($emailAddress), $userAddressList)) {
|
||||
throw new Forbidden("Email address is not one of user's.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
*/
|
||||
private function checkCodeLimit(User $user): void
|
||||
{
|
||||
$limit = $this->config->get('auth2FAEmailCodeLimit') ?? self::CODE_LIMIT;
|
||||
$period = $this->config->get('auth2FAEmailCodeLimitPeriod') ?? self::CODE_LIMIT_PERIOD;
|
||||
|
||||
$from = DateTime::createNow()
|
||||
->modify('-' . $period)
|
||||
->toString();
|
||||
|
||||
$count = $this->entityManager
|
||||
->getRDBRepository(TwoFactorCode::ENTITY_TYPE)
|
||||
->where(
|
||||
Cond::and(
|
||||
Cond::equal(Cond::column('method'), 'Email'),
|
||||
Cond::equal(Cond::column('userId'), $user->getId()),
|
||||
Cond::greaterOrEqual(Cond::column(Field::CREATED_AT), $from),
|
||||
Cond::lessOrEqual(Cond::column('attemptsLeft'), 0),
|
||||
)
|
||||
)
|
||||
->count();
|
||||
|
||||
if ($count >= $limit) {
|
||||
throw new Forbidden("Max code count exceeded.");
|
||||
}
|
||||
}
|
||||
|
||||
private function generateCode(): string
|
||||
{
|
||||
$codeLength = $this->config->get('auth2FAEmailCodeLength') ?? self::CODE_LENGTH;
|
||||
|
||||
$max = pow(10, $codeLength) - 1;
|
||||
|
||||
/** @noinspection PhpUnhandledExceptionInspection */
|
||||
return str_pad(
|
||||
(string) random_int(0, $max),
|
||||
$codeLength,
|
||||
'0',
|
||||
STR_PAD_LEFT
|
||||
);
|
||||
}
|
||||
|
||||
private function createEmail(User $user, string $code, string $emailAddress): Email
|
||||
{
|
||||
$subjectTpl = $this->templateFileManager->getTemplate('twoFactorCode', 'subject');
|
||||
$bodyTpl = $this->templateFileManager->getTemplate('twoFactorCode', 'body');
|
||||
|
||||
$htmlizer = $this->htmlizerFactory->create();
|
||||
|
||||
$data = [
|
||||
'code' => $code,
|
||||
];
|
||||
|
||||
$subject = $htmlizer->render($user, $subjectTpl, null, $data, true);
|
||||
$body = $htmlizer->render($user, $bodyTpl, null, $data, true);
|
||||
|
||||
$email = $this->emailFactory->create();
|
||||
|
||||
$email->setSubject($subject);
|
||||
$email->setBody($body);
|
||||
$email->addToAddress($emailAddress);
|
||||
|
||||
return $email;
|
||||
}
|
||||
|
||||
private function inactivateExistingCodeRecords(User $user): void
|
||||
{
|
||||
$query = $this->entityManager
|
||||
->getQueryBuilder()
|
||||
->update()
|
||||
->in(TwoFactorCode::ENTITY_TYPE)
|
||||
->where([
|
||||
'userId' => $user->getId(),
|
||||
'method' => EmailLogin::NAME,
|
||||
])
|
||||
->set([
|
||||
'isActive' => false,
|
||||
])
|
||||
->build();
|
||||
|
||||
$this->entityManager
|
||||
->getQueryExecutor()
|
||||
->execute($query);
|
||||
}
|
||||
|
||||
private function createCodeRecord(User $user, string $code): void
|
||||
{
|
||||
$this->entityManager->createEntity(TwoFactorCode::ENTITY_TYPE, [
|
||||
'code' => $code,
|
||||
'userId' => $user->getId(),
|
||||
'method' => EmailLogin::NAME,
|
||||
'attemptsLeft' => $this->getCodeAttemptsCount(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function getUserDataRepository(): UserDataRepository
|
||||
{
|
||||
/** @var UserDataRepository */
|
||||
return $this->entityManager->getRepository(UserData::ENTITY_TYPE);
|
||||
}
|
||||
|
||||
private function decrementAttemptsLeft(TwoFactorCode $codeEntity): void
|
||||
{
|
||||
$codeEntity->decrementAttemptsLeft();
|
||||
|
||||
$this->entityManager->saveEntity($codeEntity);
|
||||
}
|
||||
|
||||
private function getCodeAttemptsCount(): int
|
||||
{
|
||||
return $this->config->get('auth2FAEmailCodeAttemptsCount') ?? self::CODE_ATTEMPTS_COUNT;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Core\Authentication\TwoFactor\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
class NotConfigured extends Exception
|
||||
{}
|
||||
41
application/Espo/Core/Authentication/TwoFactor/Login.php
Normal file
41
application/Espo/Core/Authentication/TwoFactor/Login.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Core\Authentication\TwoFactor;
|
||||
|
||||
use Espo\Core\Authentication\Result;
|
||||
use Espo\Core\Api\Request;
|
||||
|
||||
/**
|
||||
* Processes second-step logging in.
|
||||
*/
|
||||
interface Login
|
||||
{
|
||||
public function login(Result $result, Request $request): Result;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Core\Authentication\TwoFactor;
|
||||
|
||||
use Espo\Core\InjectableFactory;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
|
||||
use LogicException;
|
||||
|
||||
class LoginFactory
|
||||
{
|
||||
public function __construct(private InjectableFactory $injectableFactory, private Metadata $metadata)
|
||||
{}
|
||||
|
||||
public function create(string $method): Login
|
||||
{
|
||||
/** @var ?class-string<Login> $className */
|
||||
$className = $this->metadata->get(['app', 'authentication2FAMethods', $method, 'loginClassName']);
|
||||
|
||||
if (!$className) {
|
||||
throw new LogicException("No login-class class for '$method'.");
|
||||
}
|
||||
|
||||
return $this->injectableFactory->create($className);
|
||||
}
|
||||
}
|
||||
121
application/Espo/Core/Authentication/TwoFactor/Sms/SmsLogin.php
Normal file
121
application/Espo/Core/Authentication/TwoFactor/Sms/SmsLogin.php
Normal file
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Core\Authentication\TwoFactor\Sms;
|
||||
|
||||
use Espo\Core\Authentication\HeaderKey;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Utils\Log;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\Entities\User;
|
||||
use Espo\Entities\UserData;
|
||||
use Espo\Repositories\UserData as UserDataRepository;
|
||||
use Espo\Core\Authentication\TwoFactor\Login;
|
||||
use Espo\Core\Authentication\Result;
|
||||
use Espo\Core\Authentication\Result\Data as ResultData;
|
||||
use Espo\Core\Authentication\Result\FailReason;
|
||||
use Espo\Core\Api\Request;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class SmsLogin implements Login
|
||||
{
|
||||
public const NAME = 'Sms';
|
||||
|
||||
public function __construct(
|
||||
private EntityManager $entityManager,
|
||||
private Util $util,
|
||||
private Log $log
|
||||
) {}
|
||||
|
||||
public function login(Result $result, Request $request): Result
|
||||
{
|
||||
$code = $request->getHeader(HeaderKey::AUTHORIZATION_CODE);
|
||||
|
||||
$user = $result->getUser();
|
||||
|
||||
if (!$user) {
|
||||
throw new RuntimeException("No user.");
|
||||
}
|
||||
|
||||
if (!$code) {
|
||||
$user = $result->getUser();
|
||||
|
||||
if (!$user) {
|
||||
throw new RuntimeException("No user.");
|
||||
}
|
||||
|
||||
try {
|
||||
$this->util->sendCode($user);
|
||||
} catch (Forbidden $e) {
|
||||
$this->log->error("Could not send 2FA code for user {$user->getUserName()}. " . $e->getMessage());
|
||||
|
||||
return Result::fail(FailReason::ERROR);
|
||||
}
|
||||
|
||||
return Result::secondStepRequired($user, $this->getResultData());
|
||||
}
|
||||
|
||||
if ($this->verifyCode($user, $code)) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
return Result::fail(FailReason::CODE_NOT_VERIFIED);
|
||||
}
|
||||
|
||||
private function getResultData(): ResultData
|
||||
{
|
||||
return ResultData::createWithMessage('enterCodeSentBySms');
|
||||
}
|
||||
|
||||
private function verifyCode(User $user, string $code): bool
|
||||
{
|
||||
$userData = $this->getUserDataRepository()->getByUserId($user->getId());
|
||||
|
||||
if (!$userData) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$userData->get('auth2FA')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($userData->get('auth2FAMethod') !== self::NAME) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->util->verifyCode($user, $code);
|
||||
}
|
||||
|
||||
private function getUserDataRepository(): UserDataRepository
|
||||
{
|
||||
/** @var UserDataRepository */
|
||||
return $this->entityManager->getRepository(UserData::ENTITY_TYPE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Core\Authentication\TwoFactor\Sms;
|
||||
|
||||
use Espo\Core\Authentication\TwoFactor\Exceptions\NotConfigured;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Portal\Utils\Config;
|
||||
use Espo\Entities\User;
|
||||
use Espo\Core\Authentication\TwoFactor\UserSetup;
|
||||
|
||||
use stdClass;
|
||||
|
||||
/**
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
class SmsUserSetup implements UserSetup
|
||||
{
|
||||
public function __construct(
|
||||
private Util $util,
|
||||
private Config $config
|
||||
) {}
|
||||
|
||||
public function getData(User $user): stdClass
|
||||
{
|
||||
if (!$this->config->get('smsProvider')) {
|
||||
throw new NotConfigured("No SMS provider.");
|
||||
}
|
||||
|
||||
return (object) [
|
||||
'phoneNumberList' => $user->getPhoneNumberGroup()->getNumberList(),
|
||||
];
|
||||
}
|
||||
|
||||
public function verifyData(User $user, stdClass $payloadData): bool
|
||||
{
|
||||
$code = $payloadData->code ?? null;
|
||||
|
||||
if ($code === null) {
|
||||
throw new BadRequest("No code.");
|
||||
}
|
||||
|
||||
$codeModified = str_replace(' ', '', trim($code));
|
||||
|
||||
if (!$codeModified) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->util->verifyCode($user, $codeModified);
|
||||
}
|
||||
}
|
||||
332
application/Espo/Core/Authentication/TwoFactor/Sms/Util.php
Normal file
332
application/Espo/Core/Authentication/TwoFactor/Sms/Util.php
Normal file
@@ -0,0 +1,332 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Core\Authentication\TwoFactor\Sms;
|
||||
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Name\Field;
|
||||
use Espo\Core\Utils\Config;
|
||||
use Espo\Core\Sms\SmsSender;
|
||||
use Espo\Core\Sms\SmsFactory;
|
||||
use Espo\Core\Utils\Language;
|
||||
use Espo\Core\Field\DateTime;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\ORM\Query\Part\Condition as Cond;
|
||||
use Espo\Entities\User;
|
||||
use Espo\Entities\Sms;
|
||||
use Espo\Entities\TwoFactorCode;
|
||||
use Espo\Entities\UserData;
|
||||
use Espo\Repositories\UserData as UserDataRepository;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
use const STR_PAD_LEFT;
|
||||
|
||||
class Util
|
||||
{
|
||||
private const METHOD = SmsLogin::NAME;
|
||||
|
||||
/**
|
||||
* A lifetime of a code.
|
||||
*/
|
||||
private const CODE_LIFETIME_PERIOD = '10 minutes';
|
||||
/**
|
||||
* A max number of attempts to try a single code.
|
||||
*/
|
||||
private const CODE_ATTEMPTS_COUNT = 5;
|
||||
/**
|
||||
* A length of a code.
|
||||
*/
|
||||
private const CODE_LENGTH = 6;
|
||||
/**
|
||||
* A max number of codes tried by a user in a period defined by `CODE_LIMIT_PERIOD`.
|
||||
*/
|
||||
private const CODE_LIMIT = 5;
|
||||
/**
|
||||
* A period for limiting trying to too many codes.
|
||||
*/
|
||||
private const CODE_LIMIT_PERIOD = '20 minutes';
|
||||
|
||||
public function __construct(
|
||||
private EntityManager $entityManager,
|
||||
private Config $config,
|
||||
private SmsSender $smsSender,
|
||||
private Language $language,
|
||||
private SmsFactory $smsFactory
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
*/
|
||||
public function storePhoneNumber(User $user, string $phoneNumber): void
|
||||
{
|
||||
$this->checkPhoneNumberIsUsers($user, $phoneNumber);
|
||||
|
||||
$userData = $this->getUserDataRepository()->getByUserId($user->getId());
|
||||
|
||||
if (!$userData) {
|
||||
throw new RuntimeException();
|
||||
}
|
||||
|
||||
$userData->set('auth2FASmsPhoneNumber', $phoneNumber);
|
||||
|
||||
$this->entityManager->saveEntity($userData);
|
||||
}
|
||||
|
||||
public function verifyCode(User $user, string $code): bool
|
||||
{
|
||||
$codeEntity = $this->findCodeEntity($user);
|
||||
|
||||
if (!$codeEntity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($codeEntity->getAttemptsLeft() <= 1) {
|
||||
$this->decrementAttemptsLeft($codeEntity);
|
||||
$this->inactivateExistingCodeRecords($user);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($codeEntity->getCode() !== $code) {
|
||||
$this->decrementAttemptsLeft($codeEntity);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$this->isCodeValidByLifetime($codeEntity)) {
|
||||
$this->inactivateExistingCodeRecords($user);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->inactivateExistingCodeRecords($user);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
*/
|
||||
public function sendCode(User $user, ?string $phoneNumber = null): void
|
||||
{
|
||||
if ($phoneNumber === null) {
|
||||
$phoneNumber = $this->getPhoneNumber($user);
|
||||
}
|
||||
|
||||
$this->checkPhoneNumberIsUsers($user, $phoneNumber);
|
||||
$this->checkCodeLimit($user);
|
||||
|
||||
$code = $this->generateCode();
|
||||
|
||||
$this->inactivateExistingCodeRecords($user);
|
||||
$this->createCodeRecord($user, $code);
|
||||
|
||||
$sms = $this->createSms($code, $phoneNumber);
|
||||
|
||||
$this->smsSender->send($sms);
|
||||
}
|
||||
|
||||
private function isCodeValidByLifetime(TwoFactorCode $codeEntity): bool
|
||||
{
|
||||
$period = $this->config->get('auth2FASmsCodeLifetimePeriod') ?? self::CODE_LIFETIME_PERIOD;
|
||||
|
||||
$validUntil = $codeEntity->getCreatedAt()->modify($period);
|
||||
|
||||
if (DateTime::createNow()->diff($validUntil)->invert) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function findCodeEntity(User $user): ?TwoFactorCode
|
||||
{
|
||||
/** @var ?TwoFactorCode */
|
||||
return $this->entityManager
|
||||
->getRDBRepository(TwoFactorCode::ENTITY_TYPE)
|
||||
->where([
|
||||
'method' => self::METHOD,
|
||||
'userId' => $user->getId(),
|
||||
'isActive' => true,
|
||||
])
|
||||
->findOne();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
*/
|
||||
private function getPhoneNumber(User $user): string
|
||||
{
|
||||
$userData = $this->getUserDataRepository()->getByUserId($user->getId());
|
||||
|
||||
if (!$userData) {
|
||||
throw new RuntimeException("UserData not found.");
|
||||
}
|
||||
|
||||
$phoneNumber = $userData->get('auth2FASmsPhoneNumber');
|
||||
|
||||
if ($phoneNumber) {
|
||||
return $phoneNumber;
|
||||
}
|
||||
|
||||
if ($user->getPhoneNumberGroup()->getCount() === 0) {
|
||||
throw new Forbidden("User does not have phone number.");
|
||||
}
|
||||
|
||||
/** @var string */
|
||||
return $user->getPhoneNumberGroup()->getPrimaryNumber();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
*/
|
||||
private function checkPhoneNumberIsUsers(User $user, string $phoneNumber): void
|
||||
{
|
||||
$userNumberList = array_map(
|
||||
function (string $item) {
|
||||
return strtolower($item);
|
||||
},
|
||||
$user->getPhoneNumberGroup()->getNumberList()
|
||||
);
|
||||
|
||||
if (!in_array(strtolower($phoneNumber), $userNumberList)) {
|
||||
throw new Forbidden("Phone number is not one of user's.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
*/
|
||||
private function checkCodeLimit(User $user): void
|
||||
{
|
||||
$limit = $this->config->get('auth2FASmsCodeLimit') ?? self::CODE_LIMIT;
|
||||
$period = $this->config->get('auth2FASmsCodeLimitPeriod') ?? self::CODE_LIMIT_PERIOD;
|
||||
|
||||
$from = DateTime::createNow()
|
||||
->modify('-' . $period)
|
||||
->toString();
|
||||
|
||||
$count = $this->entityManager
|
||||
->getRDBRepository(TwoFactorCode::ENTITY_TYPE)
|
||||
->where(
|
||||
Cond::and(
|
||||
Cond::equal(Cond::column('method'), self::METHOD),
|
||||
Cond::equal(Cond::column('userId'), $user->getId()),
|
||||
Cond::greaterOrEqual(Cond::column(Field::CREATED_AT), $from),
|
||||
Cond::lessOrEqual(Cond::column('attemptsLeft'), 0),
|
||||
)
|
||||
)
|
||||
->count();
|
||||
|
||||
if ($count >= $limit) {
|
||||
throw new Forbidden("Max code count exceeded.");
|
||||
}
|
||||
}
|
||||
|
||||
private function generateCode(): string
|
||||
{
|
||||
$codeLength = $this->config->get('auth2FASmsCodeLength') ?? self::CODE_LENGTH;
|
||||
|
||||
$max = pow(10, $codeLength) - 1;
|
||||
|
||||
/** @noinspection PhpUnhandledExceptionInspection */
|
||||
return str_pad(
|
||||
(string) random_int(0, $max),
|
||||
$codeLength,
|
||||
'0',
|
||||
STR_PAD_LEFT
|
||||
);
|
||||
}
|
||||
|
||||
private function createSms(string $code, string $phoneNumber): Sms
|
||||
{
|
||||
$fromNumber = $this->config->get('outboundSmsFromNumber');
|
||||
|
||||
$bodyTpl = $this->language->translateLabel('yourAuthenticationCode', 'messages', 'User');
|
||||
|
||||
$body = str_replace('{code}', $code, $bodyTpl);
|
||||
|
||||
$sms = $this->smsFactory->create();
|
||||
|
||||
$sms->setFromNumber($fromNumber);
|
||||
$sms->setBody($body);
|
||||
$sms->addToNumber($phoneNumber);
|
||||
|
||||
return $sms;
|
||||
}
|
||||
|
||||
private function inactivateExistingCodeRecords(User $user): void
|
||||
{
|
||||
$query = $this->entityManager
|
||||
->getQueryBuilder()
|
||||
->update()
|
||||
->in(TwoFactorCode::ENTITY_TYPE)
|
||||
->where([
|
||||
'userId' => $user->getId(),
|
||||
'method' => self::METHOD,
|
||||
])
|
||||
->set([
|
||||
'isActive' => false,
|
||||
])
|
||||
->build();
|
||||
|
||||
$this->entityManager
|
||||
->getQueryExecutor()
|
||||
->execute($query);
|
||||
}
|
||||
|
||||
private function createCodeRecord(User $user, string $code): void
|
||||
{
|
||||
$this->entityManager->createEntity(TwoFactorCode::ENTITY_TYPE, [
|
||||
'code' => $code,
|
||||
'userId' => $user->getId(),
|
||||
'method' => self::METHOD,
|
||||
'attemptsLeft' => $this->getCodeAttemptsCount(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function getUserDataRepository(): UserDataRepository
|
||||
{
|
||||
/** @var UserDataRepository */
|
||||
return $this->entityManager->getRepository(UserData::ENTITY_TYPE);
|
||||
}
|
||||
|
||||
private function decrementAttemptsLeft(TwoFactorCode $codeEntity): void
|
||||
{
|
||||
$codeEntity->decrementAttemptsLeft();
|
||||
|
||||
$this->entityManager->saveEntity($codeEntity);
|
||||
}
|
||||
|
||||
private function getCodeAttemptsCount(): int
|
||||
{
|
||||
return $this->config->get('auth2FASmsCodeAttemptsCount') ?? self::CODE_ATTEMPTS_COUNT;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Core\Authentication\TwoFactor\Totp;
|
||||
|
||||
use Espo\Core\Authentication\HeaderKey;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\Entities\User;
|
||||
use Espo\Entities\UserData;
|
||||
use Espo\Repositories\UserData as UserDataRepository;
|
||||
use Espo\Core\Authentication\TwoFactor\Login;
|
||||
use Espo\Core\Authentication\Result;
|
||||
use Espo\Core\Authentication\Result\Data as ResultData;
|
||||
use Espo\Core\Authentication\Result\FailReason;
|
||||
use Espo\Core\Api\Request;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
class TotpLogin implements Login
|
||||
{
|
||||
public const NAME = 'Totp';
|
||||
|
||||
public function __construct(
|
||||
private EntityManager $entityManager,
|
||||
private Util $totp
|
||||
) {}
|
||||
|
||||
public function login(Result $result, Request $request): Result
|
||||
{
|
||||
$code = $request->getHeader(HeaderKey::AUTHORIZATION_CODE);
|
||||
|
||||
$user = $result->getUser();
|
||||
|
||||
if (!$user) {
|
||||
throw new RuntimeException("No user.");
|
||||
}
|
||||
|
||||
if (!$code) {
|
||||
return Result::secondStepRequired($user, $this->getResultData());
|
||||
}
|
||||
|
||||
if ($this->verifyCode($user, $code)) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
return Result::fail(FailReason::CODE_NOT_VERIFIED);
|
||||
}
|
||||
|
||||
private function getResultData(): ResultData
|
||||
{
|
||||
return ResultData::createWithMessage('enterTotpCode');
|
||||
}
|
||||
|
||||
private function verifyCode(User $user, string $code): bool
|
||||
{
|
||||
$userData = $this->getUserDataRepository()->getByUserId($user->getId());
|
||||
|
||||
if (!$userData) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$userData->get('auth2FA')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($userData->get('auth2FAMethod') !== self::NAME) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$secret = $userData->get('auth2FATotpSecret');
|
||||
|
||||
if (!$secret) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->totp->verifyCode($secret, $code);
|
||||
}
|
||||
|
||||
private function getUserDataRepository(): UserDataRepository
|
||||
{
|
||||
/** @var UserDataRepository $repository */
|
||||
$repository = $this->entityManager->getRepository(UserData::ENTITY_TYPE);
|
||||
|
||||
return $repository;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Core\Authentication\TwoFactor\Totp;
|
||||
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Entities\UserData;
|
||||
use Espo\Entities\User;
|
||||
use Espo\Repositories\UserData as UserDataRepository;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\Core\Authentication\TwoFactor\UserSetup;
|
||||
use Espo\Core\Utils\Config;
|
||||
|
||||
use RuntimeException;
|
||||
use stdClass;
|
||||
|
||||
/**
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
class TotpUserSetup implements UserSetup
|
||||
{
|
||||
public function __construct(
|
||||
private Util $totp,
|
||||
private Config $config,
|
||||
private EntityManager $entityManager
|
||||
) {}
|
||||
|
||||
public function getData(User $user): stdClass
|
||||
{
|
||||
$userName = $user->get('userName');
|
||||
|
||||
$secret = $this->totp->createSecret();
|
||||
|
||||
$label = rawurlencode($this->config->get('applicationName')) . ':' . rawurlencode($userName);
|
||||
|
||||
$this->storeSecret($user, $secret);
|
||||
|
||||
return (object) [
|
||||
'auth2FATotpSecret' => $secret,
|
||||
'label' => $label,
|
||||
];
|
||||
}
|
||||
|
||||
public function verifyData(User $user, stdClass $payloadData): bool
|
||||
{
|
||||
$code = $payloadData->code ?? null;
|
||||
|
||||
if ($code === null) {
|
||||
throw new BadRequest("No code.");
|
||||
}
|
||||
|
||||
$codeModified = str_replace(' ', '', trim($code));
|
||||
|
||||
if (!$codeModified) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$userData = $this->getUserDataRepository()->getByUserId($user->getId());
|
||||
|
||||
if (!$userData) {
|
||||
throw new RuntimeException("User not found.");
|
||||
}
|
||||
|
||||
$secret = $userData->get('auth2FATotpSecret');
|
||||
|
||||
return $this->totp->verifyCode($secret, $codeModified);
|
||||
}
|
||||
|
||||
private function storeSecret(User $user, string $secret): void
|
||||
{
|
||||
$userData = $this->getUserDataRepository()->getByUserId($user->getId());
|
||||
|
||||
if (!$userData) {
|
||||
throw new RuntimeException();
|
||||
}
|
||||
|
||||
$userData->set('auth2FATotpSecret', $secret);
|
||||
|
||||
$this->entityManager->saveEntity($userData);
|
||||
}
|
||||
|
||||
private function getUserDataRepository(): UserDataRepository
|
||||
{
|
||||
/** @var UserDataRepository $repository */
|
||||
$repository = $this->entityManager->getRepository(UserData::ENTITY_TYPE);
|
||||
|
||||
return $repository;
|
||||
}
|
||||
}
|
||||
55
application/Espo/Core/Authentication/TwoFactor/Totp/Util.php
Normal file
55
application/Espo/Core/Authentication/TwoFactor/Totp/Util.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Core\Authentication\TwoFactor\Totp;
|
||||
|
||||
use RobThree\Auth\TwoFactorAuth;
|
||||
use RobThree\Auth\TwoFactorAuthException;
|
||||
use RuntimeException;
|
||||
|
||||
class Util
|
||||
{
|
||||
public function verifyCode(string $secret, string $code): bool
|
||||
{
|
||||
$impl = new TwoFactorAuth();
|
||||
|
||||
return $impl->verifyCode($secret, $code);
|
||||
}
|
||||
|
||||
public function createSecret(): string
|
||||
{
|
||||
$impl = new TwoFactorAuth();
|
||||
|
||||
try {
|
||||
return $impl->createSecret();
|
||||
} catch (TwoFactorAuthException $e) {
|
||||
throw new RuntimeException($e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
56
application/Espo/Core/Authentication/TwoFactor/UserSetup.php
Normal file
56
application/Espo/Core/Authentication/TwoFactor/UserSetup.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Core\Authentication\TwoFactor;
|
||||
|
||||
use Espo\Core\Authentication\TwoFactor\Exceptions\NotConfigured;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Entities\User;
|
||||
|
||||
use stdClass;
|
||||
|
||||
/**
|
||||
* 2FA setting-up for a user.
|
||||
*/
|
||||
interface UserSetup
|
||||
{
|
||||
/**
|
||||
* Get data needed for configuration for a user. Data will be passed to the front-end.
|
||||
*
|
||||
* @throws NotConfigured
|
||||
*/
|
||||
public function getData(User $user): stdClass;
|
||||
|
||||
/**
|
||||
* Verify input data before making 2FA enabled for a user.
|
||||
*
|
||||
* @throws BadRequest
|
||||
*/
|
||||
public function verifyData(User $user, stdClass $payloadData): bool;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Core\Authentication\TwoFactor;
|
||||
|
||||
use Espo\Core\InjectableFactory;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class UserSetupFactory
|
||||
{
|
||||
public function __construct(
|
||||
private InjectableFactory $injectableFactory,
|
||||
private Metadata $metadata
|
||||
) {}
|
||||
|
||||
public function create(string $method): UserSetup
|
||||
{
|
||||
/** @var ?class-string<UserSetup> $className */
|
||||
$className = $this->metadata->get(['app', 'authentication2FAMethods', $method, 'userSetupClassName']);
|
||||
|
||||
if (!$className) {
|
||||
throw new RuntimeException("No user-setup class for '$method'.");
|
||||
}
|
||||
|
||||
return $this->injectableFactory->create($className);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user