diff --git a/application/Espo/Classes/Cleanup/TwoFactorCodes.php b/application/Espo/Classes/Cleanup/TwoFactorCodes.php new file mode 100644 index 0000000000..1d627ce0c9 --- /dev/null +++ b/application/Espo/Classes/Cleanup/TwoFactorCodes.php @@ -0,0 +1,77 @@ +config = $config; + $this->entityManager = $entityManager; + } + + public function process(): void + { + $period = '-' . $this->config->get('cleanupTwoFactorCodesPeriod', self::PERIOD); + + $from = (new DateTime()) + ->modify($period) + ->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT); + + $query = $this->entityManager + ->getQueryBuilder() + ->delete() + ->from(TwoFactorCode::ENTITY_TYPE) + ->where([ + 'createdAt<' => $from, + ]) + ->build(); + + $this->entityManager + ->getQueryExecutor() + ->execute($query); + } +} diff --git a/application/Espo/Controllers/TwoFactorEmail.php b/application/Espo/Controllers/TwoFactorEmail.php new file mode 100644 index 0000000000..fd675a8431 --- /dev/null +++ b/application/Espo/Controllers/TwoFactorEmail.php @@ -0,0 +1,80 @@ +service = $service; + $this->user = $user; + + if ( + !$this->user->isAdmin() && + !$this->user->isRegular() + ) { + throw new Forbidden(); + } + } + public function postActionSendCode(Request $request): bool + { + $data = $request->getParsedBody(); + + $id = $data->id ?? null; + $emailAddress = $data->emailAddress ?? null; + + if (!$id) { + throw new BadRequest("No 'id'."); + } + + if (!$emailAddress) { + throw new BadRequest("No 'emailAddress'."); + } + + if (!$this->user->isAdmin() && $id !== $this->user->getId()) { + throw new Forbidden(); + } + + $this->service->sendCode($id, $emailAddress); + + return true; + } +} diff --git a/application/Espo/Core/Authentication/TwoFactor/Email/EmailLogin.php b/application/Espo/Core/Authentication/TwoFactor/Email/EmailLogin.php new file mode 100644 index 0000000000..d366ab2c07 --- /dev/null +++ b/application/Espo/Core/Authentication/TwoFactor/Email/EmailLogin.php @@ -0,0 +1,105 @@ +entityManager = $entityManager; + $this->util = $util; + } + + public function login(Result $result, Request $request): Result + { + $code = $request->getHeader('Espo-Authorization-Code'); + + if (!$code) { + $this->util->sendCode($result->getLoggedUser()); + + return Result::secondStepRequired($result->getUser(), $this->getLoginData()); + } + + $loggedUser = $result->getLoggedUser(); + + if ($this->verifyCode($loggedUser, $code)) { + return $result; + } + + return Result::fail(FailReason::CODE_NOT_VERIFIED); + } + + private function getLoginData(): 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') !== 'Email') { + return false; + } + + return $this->util->verifyCode($user, $code); + } + + private function getUserDataRepository(): UserDataRepository + { + return $this->entityManager->getRepository(UserData::ENTITY_TYPE); + } +} diff --git a/application/Espo/Core/Authentication/TwoFactor/Email/EmailUserSetup.php b/application/Espo/Core/Authentication/TwoFactor/Email/EmailUserSetup.php new file mode 100644 index 0000000000..99f62548a6 --- /dev/null +++ b/application/Espo/Core/Authentication/TwoFactor/Email/EmailUserSetup.php @@ -0,0 +1,72 @@ +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 Error("No code."); + } + + $codeModified = str_replace(' ', '', trim($code)); + + if (!$codeModified) { + return false; + } + + return $this->util->verifyCode($user, $codeModified); + } +} diff --git a/application/Espo/Core/Authentication/TwoFactor/Email/Util.php b/application/Espo/Core/Authentication/TwoFactor/Email/Util.php new file mode 100644 index 0000000000..351567d05c --- /dev/null +++ b/application/Espo/Core/Authentication/TwoFactor/Email/Util.php @@ -0,0 +1,340 @@ +entityManager = $entityManager; + $this->config = $config; + $this->emailSender = $emailSender; + $this->templateFileManager = $templateFileManager; + $this->htmlizerFactory = $htmlizerFactory; + $this->emailFactory = $emailFactory; + } + + public function storeEmailAddress(User $user, string $emailAddress): void + { + $this->checkEmailAddressIsUsers($user, $emailAddress); + + $userData = $this->getUserDataRepository()->getByUserId($user->getId()); + + $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; + } + + 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 + { + return $this->entityManager + ->getRDBRepository(TwoFactorCode::ENTITY_TYPE) + ->where([ + 'method' => 'Email', + 'userId' => $user->getId(), + 'isActive' => true, + ]) + ->findOne(); + } + + private function getEmailAddress(User $user): string + { + $userData = $this->getUserDataRepository()->getByUserId($user->getId()); + + if (!$userData) { + throw new Error("UserData not found."); + } + + $emailAddress = $userData->get('auth2FAEmailAddress'); + + if ($emailAddress) { + return $emailAddress; + } + + if ($user->getEmailAddressGroup()->getCount() === 0) { + throw new Error("User does not have email address."); + } + + return $user->getEmailAddressGroup()->getPrimary()->getAddress(); + } + + 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."); + } + } + + 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) + ->getString(); + + $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('createdAt'), $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; + + 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' => 'Email', + ]) + ->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' => 'Email', + 'attemptsLeft' => $this->getCodeAttemptsCount(), + ]); + } + + private function getUserDataRepository(): 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; + } +} diff --git a/application/Espo/Entities/TwoFactorCode.php b/application/Espo/Entities/TwoFactorCode.php new file mode 100644 index 0000000000..8459b39dd8 --- /dev/null +++ b/application/Espo/Entities/TwoFactorCode.php @@ -0,0 +1,67 @@ +get('isActive'); + } + + public function getCreatedAt(): DateTime + { + return $this->getValueObject('createdAt'); + } + + public function getCode(): string + { + return $this->get('code'); + } + + public function getAttemptsLeft(): int + { + return $this->get('attemptsLeft'); + } + + public function setInactive(): void + { + $this->set('isActive', false); + } + + public function decrementAttemptsLeft(): void + { + $this->set('attemptsLeft', $this->getAttemptsLeft() - 1); + } +} diff --git a/application/Espo/Resources/i18n/en_US/Admin.json b/application/Espo/Resources/i18n/en_US/Admin.json index 54f8fdb1c7..a2efbcec90 100644 --- a/application/Espo/Resources/i18n/en_US/Admin.json +++ b/application/Espo/Resources/i18n/en_US/Admin.json @@ -317,6 +317,7 @@ "readable": "Readable" }, "templates": { + "twoFactorCode": "2FA Code", "accessInfo": "Access Info", "accessInfoPortal": "Access Info for Portals", "assignment": "Assignment", diff --git a/application/Espo/Resources/i18n/en_US/Settings.json b/application/Espo/Resources/i18n/en_US/Settings.json index 93258183ca..fa652ed41a 100644 --- a/application/Espo/Resources/i18n/en_US/Settings.json +++ b/application/Espo/Resources/i18n/en_US/Settings.json @@ -154,7 +154,8 @@ "EmailReceived": "Received emails" }, "auth2FAMethodList": { - "Totp": "TOTP" + "Totp": "TOTP", + "Email": "Email" } }, "tooltips": { diff --git a/application/Espo/Resources/i18n/en_US/User.json b/application/Espo/Resources/i18n/en_US/User.json index 11a88296a2..e43cdf74b0 100644 --- a/application/Espo/Resources/i18n/en_US/User.json +++ b/application/Espo/Resources/i18n/en_US/User.json @@ -77,7 +77,8 @@ "Security": "Security", "Reset 2FA": "Reset 2FA", "Code": "Code", - "Secret": "Secret" + "Secret": "Secret", + "Send Code": "Send Code" }, "tooltips": { "defaultTeam": "All records created by this user will be related to this team by default.", @@ -108,6 +109,8 @@ "userNameExists": "User Name already exists", "wrongCode": "Wrong code", "codeIsRequired": "Code is required", + "choose2FaEmailAddress": "Select an email address that will be used for 2FA. It's highly recommended to use a non-primary email address.", + "enterCodeSentInEmail": "Enter the code sent to your email address.", "enterTotpCode": "Enter a code from your authenticator app.", "verifyTotpCode": "Scan the QR-code with your mobile authenticator app. If you have a trouble with scanning, you can enter the secret manually. After that you will see a 6-digit code in your application. Enter this code in the field below.", "generateAndSendNewPassword": "A new password will be generated and sent to the user's email address.", diff --git a/application/Espo/Resources/metadata/app/authentication2FAMethods.json b/application/Espo/Resources/metadata/app/authentication2FAMethods.json index d25df8b2fa..328293de1a 100644 --- a/application/Espo/Resources/metadata/app/authentication2FAMethods.json +++ b/application/Espo/Resources/metadata/app/authentication2FAMethods.json @@ -6,5 +6,13 @@ "userApplyView": "views/user-security/modals/totp", "loginClassName": "Espo\\Core\\Authentication\\TwoFactor\\Totp\\TotpLogin", "userSetupClassName": "Espo\\Core\\Authentication\\TwoFactor\\Totp\\TotpUserSetup" + }, + "Email": { + "settings": { + "isAvailable": true + }, + "userApplyView": "views/user-security/modals/two-factor-email", + "loginClassName": "Espo\\Core\\Authentication\\TwoFactor\\Email\\EmailLogin", + "userSetupClassName": "Espo\\Core\\Authentication\\TwoFactor\\Email\\EmailUserSetup" } } diff --git a/application/Espo/Resources/metadata/app/cleanup.json b/application/Espo/Resources/metadata/app/cleanup.json index 09db587b02..e031a00925 100644 --- a/application/Espo/Resources/metadata/app/cleanup.json +++ b/application/Espo/Resources/metadata/app/cleanup.json @@ -6,5 +6,8 @@ "webhookQueue": { "className": "Espo\\Classes\\Cleanup\\WebhookQueue", "order": 11 + }, + "twoFactorCodes": { + "className": "Espo\\Classes\\Cleanup\\TwoFactorCodes" } } diff --git a/application/Espo/Resources/metadata/app/templates.json b/application/Espo/Resources/metadata/app/templates.json index a9ae5eea3c..930adb5ff5 100644 --- a/application/Espo/Resources/metadata/app/templates.json +++ b/application/Espo/Resources/metadata/app/templates.json @@ -25,5 +25,8 @@ }, "passwordChangeLink": { "scope": "User" + }, + "twoFactorCode": { + "scope": "User" } } diff --git a/application/Espo/Resources/metadata/entityDefs/TwoFactorCode.json b/application/Espo/Resources/metadata/entityDefs/TwoFactorCode.json new file mode 100644 index 0000000000..c5158331d0 --- /dev/null +++ b/application/Espo/Resources/metadata/entityDefs/TwoFactorCode.json @@ -0,0 +1,56 @@ +{ + "fields": { + "code": { + "type": "varchar", + "maxLength": 100 + }, + "method": { + "type": "varchar", + "maxLength": 100 + }, + "attemptsLeft": { + "type": "int" + }, + "isActive": { + "type": "bool", + "default": true + }, + "createdAt": { + "type": "datetime", + "readOnly": true + } + }, + "links": { + "user": { + "type": "belongsTo", + "entity": "User" + } + }, + "indexes": { + "createdAt": { + "columns": [ + "createdAt" + ] + }, + "userIdMethod": { + "columns": [ + "userId", + "method" + ] + }, + "userIdMethodIsActive": { + "columns": [ + "userId", + "method", + "isActive" + ] + }, + "userIdMethodCreatedAt": { + "columns": [ + "userId", + "method", + "createdAt" + ] + } + } +} diff --git a/application/Espo/Resources/metadata/entityDefs/UserData.json b/application/Espo/Resources/metadata/entityDefs/UserData.json index 1942b994eb..7a4cc0d368 100644 --- a/application/Espo/Resources/metadata/entityDefs/UserData.json +++ b/application/Espo/Resources/metadata/entityDefs/UserData.json @@ -15,6 +15,9 @@ "auth2FATotpSecret": { "type": "varchar", "maxLength": 32 + }, + "auth2FAEmailAddress": { + "type": "varchar" } }, "links": { diff --git a/application/Espo/Resources/templates/twoFactorCode/en_US/body.tpl b/application/Espo/Resources/templates/twoFactorCode/en_US/body.tpl new file mode 100644 index 0000000000..f37be9a078 --- /dev/null +++ b/application/Espo/Resources/templates/twoFactorCode/en_US/body.tpl @@ -0,0 +1,3 @@ +
Enter this code to log in to EspoCRM.
+ +Code: {{code}}
diff --git a/application/Espo/Resources/templates/twoFactorCode/en_US/subject.tpl b/application/Espo/Resources/templates/twoFactorCode/en_US/subject.tpl new file mode 100644 index 0000000000..ab3fa58556 --- /dev/null +++ b/application/Espo/Resources/templates/twoFactorCode/en_US/subject.tpl @@ -0,0 +1 @@ +EspoCRM authentication code \ No newline at end of file diff --git a/application/Espo/Services/TwoFactorEmail.php b/application/Espo/Services/TwoFactorEmail.php new file mode 100644 index 0000000000..b8f10dfe16 --- /dev/null +++ b/application/Espo/Services/TwoFactorEmail.php @@ -0,0 +1,71 @@ +util = $util; + $this->user = $user; + $this->entityManager = $entityManager; + } + + public function sendCode(string $userId, string $emailAddress): void + { + if (!$this->user->isAdmin() && $userId !== $this->user->getId()) { + throw new Forbidden(); + } + + $user = $this->entityManager->getEntity(User::ENTITY_TYPE, $userId); + + if (!$user) { + throw new NotFound(); + } + + $this->util->sendCode($user, $emailAddress); + $this->util->storeEmailAddress($user, $emailAddress); + } +} diff --git a/client/res/templates/user-security/modals/two-factor-email.tpl b/client/res/templates/user-security/modals/two-factor-email.tpl new file mode 100644 index 0000000000..15a6f8c17e --- /dev/null +++ b/client/res/templates/user-security/modals/two-factor-email.tpl @@ -0,0 +1,15 @@ ++ {{translate 'choose2FaEmailAddress' category='messages' scope='User'}} +
+ + +