From 9add3fa538481b8317ce3e68e12fc5d3f4b290f8 Mon Sep 17 00:00:00 2001 From: Yuri Kuznetsov Date: Thu, 16 Sep 2021 17:35:32 +0300 Subject: [PATCH] sms --- application/Espo/Controllers/TwoFactorSms.php | 80 +++++ .../Authentication/TwoFactor/Sms/SmsLogin.php | 104 ++++++ .../TwoFactor/Sms/SmsUserSetup.php | 71 ++++ .../Authentication/TwoFactor/Sms/Util.php | 329 ++++++++++++++++++ .../Espo/Core/Binding/DefaultBinding.php | 5 + .../Functions/ExtGroup/SmsGroup/SendType.php | 96 +++++ application/Espo/Core/Sms/Sender.php | 38 ++ application/Espo/Core/Sms/SenderFactory.php | 74 ++++ application/Espo/Core/Sms/Sms.php | 47 +++ application/Espo/Core/Sms/SmsFactory.php | 54 +++ application/Espo/Core/Sms/SmsSender.php | 60 ++++ .../Database/Orm/Relations/SmsPhoneNumber.php | 57 +++ application/Espo/Entities/Sms.php | 134 +++++++ application/Espo/Hooks/Sms/Numbers.php | 111 ++++++ application/Espo/Repositories/Sms.php | 84 +++++ .../Espo/Resources/defaults/systemConfig.php | 1 + .../Espo/Resources/i18n/en_US/Admin.json | 2 + .../Espo/Resources/i18n/en_US/Settings.json | 5 +- .../Espo/Resources/i18n/en_US/User.json | 3 + .../Espo/Resources/layouts/Settings/sms.json | 8 + .../Resources/metadata/app/adminPanel.json | 9 +- .../app/authentication2FAMethods.json | 8 + .../Espo/Resources/metadata/app/config.json | 3 + .../Espo/Resources/metadata/app/formula.json | 5 + .../Espo/Resources/metadata/app/metadata.json | 1 + .../Resources/metadata/app/smsProviders.json | 3 + .../metadata/entityDefs/Settings.json | 8 + .../Resources/metadata/entityDefs/Sms.json | 147 ++++++++ application/Espo/Services/TwoFactorSms.php | 90 +++++ .../user-security/modals/two-factor-sms.tpl | 15 + client/src/views/admin/sms.js | 36 ++ .../src/views/settings/fields/sms-provider.js | 39 +++ .../user-security/modals/two-factor-sms.js | 192 ++++++++++ 33 files changed, 1917 insertions(+), 2 deletions(-) create mode 100644 application/Espo/Controllers/TwoFactorSms.php create mode 100644 application/Espo/Core/Authentication/TwoFactor/Sms/SmsLogin.php create mode 100644 application/Espo/Core/Authentication/TwoFactor/Sms/SmsUserSetup.php create mode 100644 application/Espo/Core/Authentication/TwoFactor/Sms/Util.php create mode 100644 application/Espo/Core/Formula/Functions/ExtGroup/SmsGroup/SendType.php create mode 100644 application/Espo/Core/Sms/Sender.php create mode 100644 application/Espo/Core/Sms/SenderFactory.php create mode 100644 application/Espo/Core/Sms/Sms.php create mode 100644 application/Espo/Core/Sms/SmsFactory.php create mode 100644 application/Espo/Core/Sms/SmsSender.php create mode 100644 application/Espo/Core/Utils/Database/Orm/Relations/SmsPhoneNumber.php create mode 100644 application/Espo/Entities/Sms.php create mode 100644 application/Espo/Hooks/Sms/Numbers.php create mode 100644 application/Espo/Repositories/Sms.php create mode 100644 application/Espo/Resources/layouts/Settings/sms.json create mode 100644 application/Espo/Resources/metadata/app/smsProviders.json create mode 100644 application/Espo/Resources/metadata/entityDefs/Sms.json create mode 100644 application/Espo/Services/TwoFactorSms.php create mode 100644 client/res/templates/user-security/modals/two-factor-sms.tpl create mode 100644 client/src/views/admin/sms.js create mode 100644 client/src/views/settings/fields/sms-provider.js create mode 100644 client/src/views/user-security/modals/two-factor-sms.js 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'}} +

+

+ +

+ +
+
+ +
{{{record}}}
diff --git a/client/src/views/admin/sms.js b/client/src/views/admin/sms.js new file mode 100644 index 0000000000..e365aa3dcd --- /dev/null +++ b/client/src/views/admin/sms.js @@ -0,0 +1,36 @@ +/************************************************************************ + * This file is part of EspoCRM. + * + * EspoCRM - Open Source CRM application. + * Copyright (C) 2014-2021 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko + * Website: https://www.espocrm.com + * + * EspoCRM is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * EspoCRM 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with EspoCRM. If not, see http://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 General Public License version 3. + * + * In accordance with Section 7(b) of the GNU General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +define('views/admin/sms', 'views/settings/record/edit', function (Dep) { + + return Dep.extend({ + + layoutName: 'sms', + + }); +}); diff --git a/client/src/views/settings/fields/sms-provider.js b/client/src/views/settings/fields/sms-provider.js new file mode 100644 index 0000000000..3d5e0bb884 --- /dev/null +++ b/client/src/views/settings/fields/sms-provider.js @@ -0,0 +1,39 @@ +/************************************************************************ + * This file is part of EspoCRM. + * + * EspoCRM - Open Source CRM application. + * Copyright (C) 2014-2021 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko + * Website: https://www.espocrm.com + * + * EspoCRM is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * EspoCRM 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with EspoCRM. If not, see http://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 General Public License version 3. + * + * In accordance with Section 7(b) of the GNU General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +define('views/settings/fields/sms-provider', 'views/fields/enum', function (Dep) { + + return Dep.extend({ + + setupOptions: function () { + this.params.options = Object.keys( + this.getMetadata().get(['app', 'smsProviders']) || {} + ); + }, + }); +}); diff --git a/client/src/views/user-security/modals/two-factor-sms.js b/client/src/views/user-security/modals/two-factor-sms.js new file mode 100644 index 0000000000..e8b0f15ad7 --- /dev/null +++ b/client/src/views/user-security/modals/two-factor-sms.js @@ -0,0 +1,192 @@ +/************************************************************************ + * This file is part of EspoCRM. + * + * EspoCRM - Open Source CRM application. + * Copyright (C) 2014-2021 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko + * Website: https://www.espocrm.com + * + * EspoCRM is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * EspoCRM 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with EspoCRM. If not, see http://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 General Public License version 3. + * + * In accordance with Section 7(b) of the GNU General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +define('views/user-security/modals/two-factor-sms', + ['views/modal', 'model'], + function (Dep, Model) { + + return Dep.extend({ + + template: 'user-security/modals/two-factor-sms', + + className: 'dialog dialog-record', + + events: { + 'click [data-action="sendCode"]': function () { + this.actionSendCode(); + }, + }, + + setup: function () { + this.buttonList = [ + { + name: 'apply', + label: 'Apply', + style: 'danger', + hidden: true, + }, + { + name: 'cancel', + label: 'Cancel', + } + ]; + + this.headerHtml = false; + + let codeLength = this.getConfig().get('auth2FASmsCodeLength') || 7; + + let model = new Model(); + + model.name = 'UserSecurity'; + + model.set('phoneNumber', null); + + model.setDefs({ + fields: { + 'code': { + type: 'varchar', + required: true, + maxLength: codeLength, + }, + 'phoneNumber': { + type: 'enum', + required: true, + }, + } + }); + + this.internalModel = model; + + this.wait( + Espo.Ajax + .postRequest('UserSecurity/action/getTwoFactorUserSetupData', { + id: this.model.id, + password: this.model.get('password'), + auth2FAMethod: this.model.get('auth2FAMethod'), + reset: this.options.reset, + }) + .then(data => { + this.phoneNumberList = data.phoneNumberList; + + this.createView('record', 'views/record/edit-for-modal', { + scope: 'None', + el: this.getSelector() + ' .record', + model: model, + detailLayout: [ + { + rows: [ + [ + { + name: 'phoneNumber', + labelText: this.translate('phoneNumber', 'fields', 'User'), + }, + false + ], + [ + { + name: 'code', + labelText: this.translate('Code', 'labels', 'User'), + }, + false + ], + ] + } + ], + }, view => { + view.setFieldOptionList('phoneNumber', this.phoneNumberList); + + if (this.phoneNumberList.length) { + model.set('phoneNumber', this.phoneNumberList[0]); + } + + view.hideField('code'); + }); + }) + ); + }, + + afterRender: function () { + this.$sendCode = this.$el.find('[data-action="sendCode"]'); + + this.$pInfo = this.$el.find('p.p-info'); + this.$pButton = this.$el.find('p.p-button'); + this.$pInfoAfter = this.$el.find('p.p-info-after'); + }, + + actionSendCode: function () { + this.$sendCode.attr('disabled', 'disabled').addClass('disabled'); + + Espo.Ajax + .postRequest('TwoFactorSms/action/sendCode', { + id: this.model.id, + phoneNumber: this.internalModel.get('phoneNumber'), + }) + .then(() => { + this.showButton('apply'); + + this.$pInfo.addClass('hidden'); + this.$pButton.addClass('hidden'); + this.$pInfoAfter.removeClass('hidden'); + + this.getView('record').setFieldReadOnly('phoneNumber'); + this.getView('record').showField('code'); + }) + .catch(() => { + this.$sendCode.removeAttr('disabled').removeClass('disabled'); + }); + }, + + actionApply: function () { + let data = this.getView('record').processFetch(); + + if (!data) { + return; + } + + this.model.set('code', data.code); + + this.hideButton('apply'); + this.hideButton('cancel'); + + Espo.Ui.notify(this.translate('pleaseWait', 'messages')); + + this.model + .save() + .then(() => { + Espo.Ui.notify(false); + + this.trigger('done'); + }) + .catch(() => { + this.showButton('apply'); + this.showButton('cancel'); + }); + }, + + }); +});