diff --git a/application/Espo/Controllers/TwoFactorSms.php b/application/Espo/Controllers/TwoFactorSms.php new file mode 100644 index 0000000000..4de03899c8 --- /dev/null +++ b/application/Espo/Controllers/TwoFactorSms.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; + $phoneNumber = $data->phoneNumber ?? null; + + if (!$id) { + throw new BadRequest("No 'id'."); + } + + if (!$phoneNumber) { + throw new BadRequest("No 'phoneNumber'."); + } + + if (!$this->user->isAdmin() && $id !== $this->user->getId()) { + throw new Forbidden(); + } + + $this->service->sendCode($id, $phoneNumber); + + return true; + } +} diff --git a/application/Espo/Core/Authentication/TwoFactor/Sms/SmsLogin.php b/application/Espo/Core/Authentication/TwoFactor/Sms/SmsLogin.php new file mode 100644 index 0000000000..8a72317cc0 --- /dev/null +++ b/application/Espo/Core/Authentication/TwoFactor/Sms/SmsLogin.php @@ -0,0 +1,104 @@ +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('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') !== 'Sms') { + 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/Sms/SmsUserSetup.php b/application/Espo/Core/Authentication/TwoFactor/Sms/SmsUserSetup.php new file mode 100644 index 0000000000..c534019249 --- /dev/null +++ b/application/Espo/Core/Authentication/TwoFactor/Sms/SmsUserSetup.php @@ -0,0 +1,71 @@ +util = $util; + } + + public function getData(User $user): stdClass + { + return (object) [ + 'phoneNumberList' => $user->getPhoneNumberGroup()->getNumberList(), + ]; + } + + 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/Sms/Util.php b/application/Espo/Core/Authentication/TwoFactor/Sms/Util.php new file mode 100644 index 0000000000..21a3de3d8f --- /dev/null +++ b/application/Espo/Core/Authentication/TwoFactor/Sms/Util.php @@ -0,0 +1,329 @@ +entityManager = $entityManager; + $this->config = $config; + $this->smsSender = $smsSender; + $this->language = $language; + $this->smsFactory = $smsFactory; + } + + public function storePhoneNumber(User $user, string $phoneNumber): void + { + $this->checkPhoneNumberIsUsers($user, $phoneNumber); + + $userData = $this->getUserDataRepository()->getByUserId($user->getId()); + + $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; + } + + 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($user, $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 + { + return $this->entityManager + ->getRDBRepository(TwoFactorCode::ENTITY_TYPE) + ->where([ + 'method' => 'Sms', + 'userId' => $user->getId(), + 'isActive' => true, + ]) + ->findOne(); + } + + private function getPhoneNumber(User $user): string + { + $userData = $this->getUserDataRepository()->getByUserId($user->getId()); + + if (!$userData) { + throw new Error("UserData not found."); + } + + $phoneNumber = $userData->get('auth2FASmsPhoneNumber'); + + if ($phoneNumber) { + return $phoneNumber; + } + + if ($user->getPhoneNumberGroup()->getCount() === 0) { + throw new Error("User does not have phone number."); + } + + return $user->getPhoneNumberGroup()->getPrimary()->getNumber(); + } + + 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."); + } + } + + 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) + ->getString(); + + $count = $this->entityManager + ->getRDBRepository(TwoFactorCode::ENTITY_TYPE) + ->where( + Cond::and( + Cond::equal(Cond::column('method'), 'Sms'), + 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('auth2FASmsCodeLength') ?? self::CODE_LENGTH; + + $max = pow(10, $codeLength) - 1; + + return str_pad( + (string) random_int(0, $max), + $codeLength, + '0', + STR_PAD_LEFT + ); + } + + private function createSms(User $user, string $code, string $phoneNumber): Sms + { + $fromNumber = $this->config->get('outboundSmsFromNumber'); + + $bodyTpl = $this->language->translate('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' => 'Sms', + ]) + ->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' => 'Sms', + '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('auth2FASmsCodeAttemptsCount') ?? self::CODE_ATTEMPTS_COUNT; + } +} diff --git a/application/Espo/Core/Binding/DefaultBinding.php b/application/Espo/Core/Binding/DefaultBinding.php index 76b3f46890..c3d4f299f4 100644 --- a/application/Espo/Core/Binding/DefaultBinding.php +++ b/application/Espo/Core/Binding/DefaultBinding.php @@ -217,5 +217,10 @@ class DefaultBinding implements BindingProcessor 'Espo\\Core\\WebSocket\\Sender', 'Espo\\Core\\WebSocket\\SenderFactory' ); + + $binder->bindFactory( + 'Espo\\Core\\Sms\\Sender', + 'Espo\\Core\\Sms\\SenderFactory' + ); } } diff --git a/application/Espo/Core/Formula/Functions/ExtGroup/SmsGroup/SendType.php b/application/Espo/Core/Formula/Functions/ExtGroup/SmsGroup/SendType.php new file mode 100644 index 0000000000..5c1c508dba --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ExtGroup/SmsGroup/SendType.php @@ -0,0 +1,96 @@ +throwTooFewArguments(1); + } + + $evaluatedArgs = $this->evaluate($args); + + $id = $evaluatedArgs[0]; + + if (!$id || !is_string($id)) { + $this->throwBadArgumentType(1, 'string'); + } + + /** @var Sms $sms */ + $sms = $this->entityManager->getEntity(Sms::ENTITY_TYPE, $id); + + if (!$sms) { + $this->log("Sms '{$id}' does not exist."); + + return false; + } + + if ($sms->getStatus() === Sms::STATUS_SENT) { + $this->log("Can't send SMS that has 'Sent' status."); + + return false; + } + + try { + $this->createSender()->send($sms); + } + catch (Exception $e) { + $message = $e->getMessage(); + + $this->log("Error while sending SMS. Message: {$message}." , 'error'); + + return false; + } + + return true; + } + + private function createSender(): SmsSender + { + return $this->injectableFactory->create(SmsSender::class); + } +} diff --git a/application/Espo/Core/Sms/Sender.php b/application/Espo/Core/Sms/Sender.php new file mode 100644 index 0000000000..ecc1d80079 --- /dev/null +++ b/application/Espo/Core/Sms/Sender.php @@ -0,0 +1,38 @@ +config = $config; + $this->metadata = $metadata; + $this->injectableFactory = $injectableFactory; + } + + public function create(): Sender + { + $provider = $this->config->get('smsProvider'); + + if (!$provider) { + throw new RuntimeException("No `smsProvider` in config."); + } + + $className = $this->metadata->get(['app', 'smsProviders', $provider, 'senderClassName']); + + if (!$className) { + throw new RuntimeException("No `senderClassName` for '{$provider}' provider."); + } + + return $this->injectableFactory->create($className); + } +} diff --git a/application/Espo/Core/Sms/Sms.php b/application/Espo/Core/Sms/Sms.php new file mode 100644 index 0000000000..471c7e1c32 --- /dev/null +++ b/application/Espo/Core/Sms/Sms.php @@ -0,0 +1,47 @@ +entityManager = $entityManager; + } + + /** + * Create an SMS instance. + */ + public function create(): SmsEntity + { + return $this->entityManager->getNewEntity(SmsEntity::ENTITY_TYPE); + } +} diff --git a/application/Espo/Core/Sms/SmsSender.php b/application/Espo/Core/Sms/SmsSender.php new file mode 100644 index 0000000000..6387f62f6a --- /dev/null +++ b/application/Espo/Core/Sms/SmsSender.php @@ -0,0 +1,60 @@ +sender = $sender; + $this->config = $config; + } + + public function send(SmsEntity $sms): void + { + $systemFromNumber = $this->config->get('outboundSmsFromNumber'); + + if ($sms->getFromNumber() === null && $systemFromNumber) { + $sms->setFromNumber($systemFromNumber); + } + + $this->sender->send($sms); + + $sms->setAsSent(); + } +} diff --git a/application/Espo/Core/Utils/Database/Orm/Relations/SmsPhoneNumber.php b/application/Espo/Core/Utils/Database/Orm/Relations/SmsPhoneNumber.php new file mode 100644 index 0000000000..73550be8da --- /dev/null +++ b/application/Espo/Core/Utils/Database/Orm/Relations/SmsPhoneNumber.php @@ -0,0 +1,57 @@ +getForeignEntityName(); + + $relation = [ + $entityName => [ + 'relations' => [ + $linkName => [ + 'midKeys' => [ + lcfirst($entityName) . 'Id', + lcfirst($foreignEntityName) . 'Id', + ], + ], + ], + ], + ]; + + return Util::merge($parentRelation, $relation); + } +} diff --git a/application/Espo/Entities/Sms.php b/application/Espo/Entities/Sms.php new file mode 100644 index 0000000000..6038adce7d --- /dev/null +++ b/application/Espo/Entities/Sms.php @@ -0,0 +1,134 @@ +getValueObject('dateTime'); + } + + public function getCreatedAt(): ?DateTime + { + return $this->getValueObject('createdAt'); + } + + public function getBody(): string + { + return $this->get('body') ?? ''; + } + + public function getStatus(): ?string + { + return $this->get('status'); + } + + public function setBody(?string $body): void + { + $this->set('body', $body); + } + + public function setFromNumber(?string $number): void + { + $this->set('from', $number); + } + + public function addToNumber(string $number): void + { + $list = $this->getToNumberList(); + + $list[] = $number; + + $this->set('to', implode(';', $list)); + } + + public function getFromNumber(): ?string + { + if (!$this->hasInContainer('from') && !$this->isNew()) { + $this->getSmsRepository()->loadFromField($this); + } + + return $this->get('from'); + } + + public function getFromName(): ?string + { + return $this->get('fromName'); + } + + /** + * @return string[] + */ + public function getToNumberList(): array + { + if (!$this->hasInContainer('to') && !$this->isNew()) { + $this->getSmsRepository()->loadToField($this); + } + + $value = $this->get('to'); + + if (!$value) { + return []; + } + + return explode(';', $value); + } + + public function setAsSent(): void + { + $this->set('status', self::STATUS_SENT); + + if (!$this->get('dateSent')) { + $this->set('dateSent', DateTime::createNow()->getString()); + } + } + + private function getSmsRepository(): SmsRepository + { + return $this->entityManager->getRepository(self::ENTITY_TYPE); + } +} diff --git a/application/Espo/Hooks/Sms/Numbers.php b/application/Espo/Hooks/Sms/Numbers.php new file mode 100644 index 0000000000..c36ecef705 --- /dev/null +++ b/application/Espo/Hooks/Sms/Numbers.php @@ -0,0 +1,111 @@ +entityManager = $entityManager; + } + + public function beforeSave(Entity $entity): void + { + $this->processNumbers($entity); + } + + private function processNumbers(Sms $entity): void + { + if ($entity->has('from')) { + $this->processFrom($entity); + } + + if ($entity->has('to')) { + $this->processTo($entity); + } + } + + private function processFrom(Sms $entity): void + { + $from = $entity->get('from'); + + $entity->set('fromPhoneNumberId', null); + $entity->set('fromEmailAddressName', null); + + if (!$from) { + return; + } + + $numberIds = $this->getPhoneNumberRepository()->getIds([$from]); + + if (!count($numberIds)) { + return; + } + + $entity->set('fromEmailAddressId', $numberIds[0]); + $entity->set('fromEmailAddressName', $from); + } + + private function processTo(Sms $entity): void + { + $entity->setLinkMultipleIdList('toPhoneNumbers', []); + + $to = $entity->get('to'); + + if ($to === null || !$to) { + return; + } + + $numberList = array_map( + function (string $item): string { + return trim($item); + }, + explode(';', $to) + ); + + $numberIds = $this->getPhoneNumberRepository()->getIds($numberList); + + $entity->setLinkMultipleIdList('toPhoneNumbers', $numberIds); + } + + private function getPhoneNumberRepository(): PhoneNumberRepository + { + return $this->entityManager->getRepository(PhoneNumber::ENTITY_TYPE); + } +} diff --git a/application/Espo/Repositories/Sms.php b/application/Espo/Repositories/Sms.php new file mode 100644 index 0000000000..c13f15ede9 --- /dev/null +++ b/application/Espo/Repositories/Sms.php @@ -0,0 +1,84 @@ +get('fromPhoneNumberName')) { + $entity->set('from', $entity->get('fromPhoneNumberName')); + + return; + } + + $numberId = $entity->get('fromPhoneNumberId'); + + if ($numberId) { + $phoneNumber = $this->entityManager + ->getRepository(PhoneNumber::ENTITY_TYPE) + ->getById($numberId); + + if ($phoneNumber) { + $entity->set('from', $phoneNumber->get('name')); + + return; + } + } + + $entity->set('from', null); + } + + public function loadToField(SmsEntity $entity): void + { + $entity->loadLinkMultipleField('toPhoneNumbers'); + + $names = $entity->get('toPhoneNumbersNames'); + + if (empty($names)) { + $entity->set('to', null); + + return; + } + + $list = []; + + foreach ($names as $address) { + $list[] = $address; + } + + $entity->set('to', implode(';', $list)); + } +} diff --git a/application/Espo/Resources/defaults/systemConfig.php b/application/Espo/Resources/defaults/systemConfig.php index 3e1d2b76bf..bc92f1555e 100644 --- a/application/Espo/Resources/defaults/systemConfig.php +++ b/application/Espo/Resources/defaults/systemConfig.php @@ -205,6 +205,7 @@ return [ 'passwordRecoveryRequestDelay', 'thumbImageCacheDisabled', 'emailReminderPortionSize', + 'outboundSmsFromNumber', 'latestVersion', ], 'superAdminItems' => [ diff --git a/application/Espo/Resources/i18n/en_US/Admin.json b/application/Espo/Resources/i18n/en_US/Admin.json index a2efbcec90..dbd24b2701 100644 --- a/application/Espo/Resources/i18n/en_US/Admin.json +++ b/application/Espo/Resources/i18n/en_US/Admin.json @@ -5,6 +5,7 @@ "System": "System", "Users": "Users", "Email": "Email", + "Messaging": "Messaging", "Data": "Data", "Customization": "Customization", "Available Fields": "Available Fields", @@ -278,6 +279,7 @@ "dashboardTemplates": "Deploy dashboards to users.", "layoutSets": "Collections of layouts that can be assigned to teams & portals.", "jobsSettings": "Job processing settings. Jobs execute tasks in the background.", + "sms": "SMS settings.", "pdfTemplates": "Templates for printing to PDF." }, "keywords": { diff --git a/application/Espo/Resources/i18n/en_US/Settings.json b/application/Espo/Resources/i18n/en_US/Settings.json index fa652ed41a..542f5587d4 100644 --- a/application/Espo/Resources/i18n/en_US/Settings.json +++ b/application/Espo/Resources/i18n/en_US/Settings.json @@ -13,6 +13,8 @@ "currencyList": "Currency List", "language": "Language", "companyLogo": "Company Logo", + "smsProvider": "SMS Provider", + "outboundSmsFromNumber": "SMS From Number", "smtpServer": "Server", "smtpPort": "Port", "smtpAuth": "Auth", @@ -155,7 +157,8 @@ }, "auth2FAMethodList": { "Totp": "TOTP", - "Email": "Email" + "Email": "Email", + "Sms": "SMS" } }, "tooltips": { diff --git a/application/Espo/Resources/i18n/en_US/User.json b/application/Espo/Resources/i18n/en_US/User.json index e43cdf74b0..ca4aad54f3 100644 --- a/application/Espo/Resources/i18n/en_US/User.json +++ b/application/Espo/Resources/i18n/en_US/User.json @@ -109,8 +109,11 @@ "userNameExists": "User Name already exists", "wrongCode": "Wrong code", "codeIsRequired": "Code is required", + "yourAuthenticationCode": "Your authentication code: {code}.", + "choose2FaSmsPhoneNumber": "Select a phone number that will be used for 2FA.", "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.", + "enterCodeSentBySms": "Enter the code sent by SMS to your phone number.", "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/layouts/Settings/sms.json b/application/Espo/Resources/layouts/Settings/sms.json new file mode 100644 index 0000000000..240f2dadbf --- /dev/null +++ b/application/Espo/Resources/layouts/Settings/sms.json @@ -0,0 +1,8 @@ +[ + { + "rows": [ + [{"name": "smsProvider"}, false], + [{"name": "outboundSmsFromNumber"}, false] + ] + } +] diff --git a/application/Espo/Resources/metadata/app/adminPanel.json b/application/Espo/Resources/metadata/app/adminPanel.json index be96e5cdf1..cda71f308b 100644 --- a/application/Espo/Resources/metadata/app/adminPanel.json +++ b/application/Espo/Resources/metadata/app/adminPanel.json @@ -169,7 +169,7 @@ "order": 10 }, "email":{ - "label": "Email", + "label": "Messaging", "itemList": [ { "url": "#Admin/outboundEmails", @@ -208,6 +208,13 @@ "label": "Email Templates", "iconClass": "fas fa-envelope-square", "description": "emailTemplates" + }, + { + "url": "#Admin/sms", + "label": "SMS", + "iconClass": "fas fa-paper-plane", + "description": "sms", + "recordView": "views/admin/sms" } ], "order": 15 diff --git a/application/Espo/Resources/metadata/app/authentication2FAMethods.json b/application/Espo/Resources/metadata/app/authentication2FAMethods.json index 328293de1a..1e804f89d2 100644 --- a/application/Espo/Resources/metadata/app/authentication2FAMethods.json +++ b/application/Espo/Resources/metadata/app/authentication2FAMethods.json @@ -14,5 +14,13 @@ "userApplyView": "views/user-security/modals/two-factor-email", "loginClassName": "Espo\\Core\\Authentication\\TwoFactor\\Email\\EmailLogin", "userSetupClassName": "Espo\\Core\\Authentication\\TwoFactor\\Email\\EmailUserSetup" + }, + "Sms": { + "settings": { + "isAvailable": true + }, + "userApplyView": "views/user-security/modals/two-factor-sms", + "loginClassName": "Espo\\Core\\Authentication\\TwoFactor\\Sms\\SmsLogin", + "userSetupClassName": "Espo\\Core\\Authentication\\TwoFactor\\Sms\\SmsUserSetup" } } diff --git a/application/Espo/Resources/metadata/app/config.json b/application/Espo/Resources/metadata/app/config.json index 087d053a19..73a361c5f5 100644 --- a/application/Espo/Resources/metadata/app/config.json +++ b/application/Espo/Resources/metadata/app/config.json @@ -15,6 +15,9 @@ "params": { "awsS3Storage": { "level": "system" + }, + "smsProvider": { + "level": "admin" } } } diff --git a/application/Espo/Resources/metadata/app/formula.json b/application/Espo/Resources/metadata/app/formula.json index 69ea90f4df..028031ca59 100644 --- a/application/Espo/Resources/metadata/app/formula.json +++ b/application/Espo/Resources/metadata/app/formula.json @@ -378,6 +378,11 @@ "insertText": "ext\\email\\send(EMAIL_ID)", "returnType": "bool" }, + { + "name": "ext\\sms\\send", + "insertText": "ext\\sms\\send(SMS_ID)", + "returnType": "bool" + }, { "name": "ext\\email\\applyTemplate", "insertText": "ext\\email\\applyTemplate(EMAIL_ID, EMAIL_TEMPLATE_ID)" diff --git a/application/Espo/Resources/metadata/app/metadata.json b/application/Espo/Resources/metadata/app/metadata.json index c848a7d0cc..eb2dba42eb 100644 --- a/application/Espo/Resources/metadata/app/metadata.json +++ b/application/Espo/Resources/metadata/app/metadata.json @@ -25,6 +25,7 @@ ["app", "webSocket", "messagers"], ["app", "config"], ["app", "rebuild"], + ["app", "smsProviders", "__ANY__", "senderClassName"], ["selectDefs"], ["recordDefs"], ["pdfDefs"], diff --git a/application/Espo/Resources/metadata/app/smsProviders.json b/application/Espo/Resources/metadata/app/smsProviders.json new file mode 100644 index 0000000000..0db3279e44 --- /dev/null +++ b/application/Espo/Resources/metadata/app/smsProviders.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/application/Espo/Resources/metadata/entityDefs/Settings.json b/application/Espo/Resources/metadata/entityDefs/Settings.json index 910216587a..e77469c488 100644 --- a/application/Espo/Resources/metadata/entityDefs/Settings.json +++ b/application/Espo/Resources/metadata/entityDefs/Settings.json @@ -635,6 +635,14 @@ }, "awsS3Storage": { "type": "jsonObject" + }, + "outboundSmsFromNumber": { + "type": "varchar", + "trim": true + }, + "smsProvider": { + "type": "enum", + "view": "views/settings/fields/sms-provider" } } } diff --git a/application/Espo/Resources/metadata/entityDefs/Sms.json b/application/Espo/Resources/metadata/entityDefs/Sms.json new file mode 100644 index 0000000000..9c25ebaac7 --- /dev/null +++ b/application/Espo/Resources/metadata/entityDefs/Sms.json @@ -0,0 +1,147 @@ +{ + "fields": { + "from": { + "type": "varchar", + "notStorable": true, + "required": true, + "textFilterDisabled": true + }, + "fromName": { + "type": "varchar" + }, + "to": { + "type": "varchar", + "notStorable": true, + "required": true, + "textFilterDisabled": true + }, + "fromPhoneNumber": { + "type": "link", + "textFilterDisabled": true + }, + "toPhoneNumbers": { + "type": "linkMultiple" + }, + "body": { + "type": "text" + }, + "status": { + "type": "enum", + "options": ["Draft", "Sending", "Sent", "Archived", "Failed"], + "default": "Archived", + "clientReadOnly": true, + "style": { + "Draft": "warning", + "Failed": "danger", + "Sending": "warning" + } + }, + "parent": { + "type": "linkParent" + }, + "dateSent": { + "type": "datetime" + }, + "createdAt": { + "type": "datetime", + "readOnly": true + }, + "modifiedAt": { + "type": "datetime", + "readOnly": true + }, + "createdBy": { + "type": "link", + "readOnly": true, + "view": "views/fields/user" + }, + "modifiedBy": { + "type": "link", + "readOnly": true, + "view": "views/fields/user" + }, + "replied": { + "type": "link", + "noJoin": true, + "readOnly": true, + "view": "views/email/fields/replied" + }, + "replies": { + "type": "linkMultiple", + "readOnly": true, + "orderBy": "dateSent", + "view": "views/email/fields/replies" + }, + "teams": { + "type": "linkMultiple", + "view": "views/fields/teams" + } + }, + "links": { + "createdBy": { + "type": "belongsTo", + "entity": "User" + }, + "modifiedBy": { + "type": "belongsTo", + "entity": "User" + }, + "teams": { + "type": "hasMany", + "entity": "Team", + "relationName": "entityTeam" + }, + "parent": { + "type": "belongsToParent", + "entityList": [ + "Account", + "Contact", + "Lead", + "Opportunity" + ], + "foreign": "emails" + }, + "replied": { + "type": "belongsTo", + "entity": "Sms", + "foreign": "replies", + "foreignName": "id" + }, + "replies": { + "type": "hasMany", + "entity": "Sms", + "foreign": "replied" + }, + "fromPhoneNumber": { + "type": "belongsTo", + "entity": "PhoneNumber" + }, + "toPhoneNumbers": { + "type": "hasMany", + "entity": "PhoneNumber", + "relationName": "smsPhoneNumber", + "conditions": { + "addressType": "to" + }, + "additionalColumns": { + "addressType": { + "type": "varchar", + "len": "4" + } + } + } + }, + "collection": { + "orderBy": "dateSent", + "order": "desc", + "textFilterFields": ["name", "body"] + }, + "indexes": { + "dateSent": { + "columns": ["dateSent", "deleted"] + }, + "dateSentStatus": { + "columns": ["dateSent", "status", "deleted"] + } + } +} diff --git a/application/Espo/Services/TwoFactorSms.php b/application/Espo/Services/TwoFactorSms.php new file mode 100644 index 0000000000..08af257c45 --- /dev/null +++ b/application/Espo/Services/TwoFactorSms.php @@ -0,0 +1,90 @@ +util = $util; + $this->user = $user; + $this->entityManager = $entityManager; + $this->config = $config; + } + + public function sendCode(string $userId, string $phoneNumber): void + { + if (!$this->user->isAdmin() && $userId !== $this->user->getId()) { + throw new Forbidden(); + } + + $this->checkAllowed(); + + $user = $this->entityManager->getEntity(User::ENTITY_TYPE, $userId); + + if (!$user) { + throw new NotFound(); + } + + $this->util->sendCode($user, $phoneNumber); + $this->util->storePhoneNumber($user, $phoneNumber); + } + + private function checkAllowed(): void + { + if (!$this->config->get('auth2FA')) { + throw new Forbidden("2FA is not enabled."); + } + + $methodList = $this->config->get('auth2FAMethodList') ?? []; + + if (!in_array('Sms', $methodList)) { + throw new Forbidden("Sms 2FA is not allowed."); + } + } +} diff --git a/client/res/templates/user-security/modals/two-factor-sms.tpl b/client/res/templates/user-security/modals/two-factor-sms.tpl new file mode 100644 index 0000000000..138ca89731 --- /dev/null +++ b/client/res/templates/user-security/modals/two-factor-sms.tpl @@ -0,0 +1,15 @@ +
+ {{translate 'choose2FaSmsPhoneNumber' category='messages' scope='User'}} +
+ + +