From b43d9f878462bf323a5d9b2bbf2edbbad3a908e1 Mon Sep 17 00:00:00 2001 From: Yuri Kuznetsov Date: Tue, 18 Oct 2022 20:29:23 +0300 Subject: [PATCH] user password ref --- .../Cleanup/PasswordChangeRequests.php | 1 - application/Espo/Controllers/User.php | 110 +++- .../Core/Password/Jobs/SendAccessInfo.php | 17 +- application/Espo/Core/Password/Recovery.php | 85 +-- .../Espo/Entities/PasswordChangeRequest.php | 21 +- application/Espo/Services/User.php | 558 ++---------------- .../Tools/UserSecurity/Password/Checker.php | 108 ++++ .../Tools/UserSecurity/Password/Generator.php | 81 +++ .../Password/Jobs/RemoveRecoveryRequest.php | 63 ++ .../Tools/UserSecurity/Password/Sender.php | 270 +++++++++ .../Tools/UserSecurity/Password/Service.php | 332 +++++++++++ .../Espo/Tools/UserSecurity/Service.php | 8 +- 12 files changed, 1060 insertions(+), 594 deletions(-) create mode 100644 application/Espo/Tools/UserSecurity/Password/Checker.php create mode 100644 application/Espo/Tools/UserSecurity/Password/Generator.php create mode 100644 application/Espo/Tools/UserSecurity/Password/Jobs/RemoveRecoveryRequest.php create mode 100644 application/Espo/Tools/UserSecurity/Password/Sender.php create mode 100644 application/Espo/Tools/UserSecurity/Password/Service.php diff --git a/application/Espo/Classes/Cleanup/PasswordChangeRequests.php b/application/Espo/Classes/Cleanup/PasswordChangeRequests.php index 0f06995540..a751f1eae3 100644 --- a/application/Espo/Classes/Cleanup/PasswordChangeRequests.php +++ b/application/Espo/Classes/Cleanup/PasswordChangeRequests.php @@ -40,7 +40,6 @@ use Espo\Entities\PasswordChangeRequest; class PasswordChangeRequests implements Cleanup { private Config $config; - private EntityManager $entityManager; private string $cleanupPeriod = '30 days'; diff --git a/application/Espo/Controllers/User.php b/application/Espo/Controllers/User.php index c8c5e4b448..d5b33b2139 100644 --- a/application/Espo/Controllers/User.php +++ b/application/Espo/Controllers/User.php @@ -36,17 +36,23 @@ use Espo\Core\Exceptions\BadRequest; use Espo\Services\User as Service; -use Espo\Core\{ - Controllers\Record, - Api\Request, - Select\SearchParams, - Select\Where\Item as WhereItem, -}; +use Espo\Core\Api\Request; +use Espo\Core\Controllers\Record; +use Espo\Core\Mail\Exceptions\SendingError; +use Espo\Core\Password\Recovery; +use Espo\Core\Select\SearchParams; +use Espo\Core\Select\Where\Item as WhereItem; +use Espo\Tools\UserSecurity\Password\Service as PasswordService; use stdClass; class User extends Record { + /** + * @throws Forbidden + * @throws NotFound + * @throws Error + */ public function getActionAcl(Request $request): stdClass { $userId = $request->getQueryParam('id'); @@ -55,40 +61,53 @@ class User extends Record throw new Error(); } - if (!$this->user->isAdmin() && $this->user->getId() !== $userId) { + if ( + !$this->user->isAdmin() && + $this->user->getId() !== $userId + ) { throw new Forbidden(); } - $user = $this->getEntityManager()->getEntity('User', $userId); + $user = $this->entityManager->getEntityById(\Espo\Entities\User::ENTITY_TYPE, $userId); if (empty($user)) { throw new NotFound(); } - return $this->getAclManager()->getMapData($user); + return $this->aclManager->getMapData($user); } + /** + * @throws BadRequest + * @throws Forbidden + * @throws Error + * @throws NotFound + */ public function postActionChangeOwnPassword(Request $request): bool { $data = $request->getParsedBody(); + $password = $data->password ?? null; + $currentPassword = $data->currentPassword ?? null; + if ( - !property_exists($data, 'password') || - !property_exists($data, 'currentPassword') + !is_string($password) || + !is_string($currentPassword) ) { throw new BadRequest(); } - $this->getUserService()->changePassword( - $this->user->getId(), - $data->password, - true, - $data->currentPassword - ); + $this->getPasswordService()->changePasswordWithCheck($this->user->getId(), $password, $currentPassword); return true; } + /** + * @throws BadRequest + * @throws Forbidden + * @throws Error + * @throws NotFound + */ public function postActionChangePasswordByRequest(Request $request): stdClass { $data = $request->getParsedBody(); @@ -97,31 +116,41 @@ class User extends Record throw new BadRequest(); } - return $this->getUserService()->changePasswordByRequest($data->requestId, $data->password); + $url = $this->getPasswordService()->changePasswordByRecovery($data->requestId, $data->password); + + return (object) [ + 'url' => $url, + ]; } + /** + * @throws BadRequest + * @throws Forbidden + */ public function postActionPasswordChangeRequest(Request $request): bool { $data = $request->getParsedBody(); - if (empty($data->userName) || empty($data->emailAddress)) { + $userName = $data->userName ?? null; + $emailAddress = $data->emailAddress ?? null; + $url = $data->url ?? null; + + if (!$userName || !$emailAddress) { throw new BadRequest(); } - $userName = $data->userName; - $emailAddress = $data->emailAddress; - - $url = null; - - if (!empty($data->url)) { - $url = $data->url; - } - - $this->getUserService()->passwordChangeRequest($userName, $emailAddress, $url); + $this->injectableFactory + ->create(Recovery::class) + ->request($emailAddress, $userName, $url); return true; } + /** + * @throws BadRequest + * @throws Forbidden + * @throws NotFound + */ public function postActionGenerateNewApiKey(Request $request): stdClass { $data = $request->getParsedBody(); @@ -139,6 +168,14 @@ class User extends Record ->getValueMap(); } + + /** + * @throws BadRequest + * @throws Forbidden + * @throws Error + * @throws SendingError + * @throws NotFound + */ public function postActionGenerateNewPassword(Request $request): bool { $data = $request->getParsedBody(); @@ -151,11 +188,17 @@ class User extends Record throw new Forbidden(); } - $this->getUserService()->generateNewPasswordForUser($data->id); + $this->getPasswordService()->generateAndSendNewPasswordForUser($data->id); return true; } + /** + * @throws BadRequest + * @throws Forbidden + * @throws NotFound + * @throws Error + */ public function postActionSendPasswordChangeLink(Request $request): bool { if (!$this->user->isAdmin()) { @@ -168,7 +211,7 @@ class User extends Record throw new BadRequest(); } - $this->getUserService()->sendPasswordChangeLink($id); + $this->getPasswordService()->createAndSendPasswordRecovery($id); return true; } @@ -210,6 +253,11 @@ class User extends Record ); } + private function getPasswordService(): PasswordService + { + return $this->injectableFactory->create(PasswordService::class); + } + private function getUserService(): Service { /** @var Service */ diff --git a/application/Espo/Core/Password/Jobs/SendAccessInfo.php b/application/Espo/Core/Password/Jobs/SendAccessInfo.php index 799aa61b34..f5e5a927e5 100644 --- a/application/Espo/Core/Password/Jobs/SendAccessInfo.php +++ b/application/Espo/Core/Password/Jobs/SendAccessInfo.php @@ -29,6 +29,7 @@ namespace Espo\Core\Password\Jobs; +use Espo\Core\Mail\Exceptions\SendingError; use Espo\Entities\User; use Espo\Core\Job\Job; @@ -37,20 +38,23 @@ use Espo\Core\Exceptions\Error; use Espo\ORM\EntityManager; -use Espo\Services\User as Service; +use Espo\Tools\UserSecurity\Password\Service as PasswordService; class SendAccessInfo implements Job { private EntityManager $entityManager; + private PasswordService $passwordService; - private Service $service; - - public function __construct(EntityManager $entityManager, Service $service) + public function __construct(EntityManager $entityManager, PasswordService $passwordService) { $this->entityManager = $entityManager; - $this->service = $service; + $this->passwordService = $passwordService; } + /** + * @throws SendingError + * @throws Error + */ public function run(Data $data): void { $userId = $data->getTargetId(); @@ -59,12 +63,13 @@ class SendAccessInfo implements Job throw new Error(); } + /** @var ?User $user */ $user = $this->entityManager->getEntityById(User::ENTITY_TYPE, $userId); if (!$user) { throw new Error("User '{$userId}' not found."); } - $this->service->sendAccessInfoNew($user); + $this->passwordService->sendAccessInfoForNewUser($user); } } diff --git a/application/Espo/Core/Password/Recovery.php b/application/Espo/Core/Password/Recovery.php index 42762b4574..4aec7162b5 100644 --- a/application/Espo/Core/Password/Recovery.php +++ b/application/Espo/Core/Password/Recovery.php @@ -29,6 +29,7 @@ namespace Espo\Core\Password; +use Espo\Core\Job\JobSchedulerFactory; use Espo\Core\Utils\Util; use Espo\Core\Utils\Json; @@ -44,41 +45,31 @@ use Espo\Core\Exceptions\Error; use Espo\Core\Field\DateTime; -use Espo\Core\{ - Authentication\Logins\Espo as EspoLogin, - ORM\EntityManager, - Utils\Config, - Mail\EmailSender, - Htmlizer\HtmlizerFactory as HtmlizerFactory, - Utils\TemplateFileManager, - Utils\Log, - Job\QueueName, -}; +use Espo\Core\Authentication\Logins\Espo as EspoLogin; +use Espo\Core\Htmlizer\HtmlizerFactory as HtmlizerFactory; +use Espo\Core\Job\QueueName; +use Espo\Core\Mail\EmailSender; +use Espo\Core\ORM\EntityManager; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\Log; +use Espo\Core\Utils\TemplateFileManager; +use Espo\Tools\UserSecurity\Password\Jobs\RemoveRecoveryRequest; class Recovery { - /** - * Milliseconds. - */ + /** Milliseconds. */ private const REQUEST_DELAY = 3000; - private const REQUEST_LIFETIME = '3 hours'; - private const NEW_USER_REQUEST_LIFETIME = '2 days'; - private const EXISTING_USER_REQUEST_LIFETIME = '2 days'; - protected EntityManager $entityManager; - - protected Config $config; - - protected EmailSender $emailSender; - - protected HtmlizerFactory $htmlizerFactory; - - protected TemplateFileManager $templateFileManager; - + private EntityManager $entityManager; + private Config $config; + private EmailSender $emailSender; + private HtmlizerFactory $htmlizerFactory; + private TemplateFileManager $templateFileManager; private Log $log; + private JobSchedulerFactory $jobSchedulerFactory; public function __construct( EntityManager $entityManager, @@ -86,7 +77,8 @@ class Recovery EmailSender $emailSender, HtmlizerFactory $htmlizerFactory, TemplateFileManager $templateFileManager, - Log $log + Log $log, + JobSchedulerFactory $jobSchedulerFactory ) { $this->entityManager = $entityManager; $this->config = $config; @@ -94,8 +86,14 @@ class Recovery $this->htmlizerFactory = $htmlizerFactory; $this->templateFileManager = $templateFileManager; $this->log = $log; + $this->jobSchedulerFactory = $jobSchedulerFactory; } + /** + * @throws Forbidden + * @throws Error + * @throws NotFound + */ public function getRequest(string $id): PasswordChangeRequest { $config = $this->config; @@ -137,6 +135,11 @@ class Recovery } } + /** + * @throws Forbidden + * @throws NotFound + * @throws Error + */ public function request(string $emailAddress, string $userName, ?string $url): bool { $config = $this->config; @@ -148,7 +151,7 @@ class Recovery } $user = $this->entityManager - ->getRDBRepository('User') + ->getRDBRepository(User::ENTITY_TYPE) ->where([ 'userName' => $userName, 'emailAddress' => $emailAddress, @@ -269,6 +272,9 @@ class Recovery return $entity; } + /** + * @throws Error + */ public function createAndSendRequestForExistingUser(User $user, ?string $url = null): PasswordChangeRequest { $this->checkUser($user); @@ -283,7 +289,8 @@ class Recovery $this->entityManager->saveEntity($entity); - $lifetime = $this->config->get('passwordChangeRequestExistingUserLifetime') ?? + $lifetime = + $this->config->get('passwordChangeRequestExistingUserLifetime') ?? self::EXISTING_USER_REQUEST_LIFETIME; $this->createCleanupRequestJob($entity->getId(), $lifetime); @@ -312,15 +319,17 @@ class Recovery private function createCleanupRequestJob(string $id, string $lifetime): void { - $this->entityManager->createEntity('Job', [ - 'serviceName' => 'User', - 'methodName' => 'removeChangePasswordRequestJob', - 'data' => ['id' => $id], - 'executeTime' => DateTime::createNow() - ->modify('+' . $lifetime) - ->getString(), - 'queue' => QueueName::Q1, - ]); + $this->jobSchedulerFactory + ->create() + ->setClassName(RemoveRecoveryRequest::class) + ->setData(['id' => $id]) + ->setTime( + DateTime::createNow() + ->modify('+' . $lifetime) + ->getDateTime() + ) + ->setQueue(QueueName::Q1) + ->schedule(); } private function getDelay(): int diff --git a/application/Espo/Entities/PasswordChangeRequest.php b/application/Espo/Entities/PasswordChangeRequest.php index d859164dd7..b144a04878 100644 --- a/application/Espo/Entities/PasswordChangeRequest.php +++ b/application/Espo/Entities/PasswordChangeRequest.php @@ -29,12 +29,31 @@ namespace Espo\Entities; -class PasswordChangeRequest extends \Espo\Core\ORM\Entity +use Espo\Core\ORM\Entity; +use UnexpectedValueException; + +class PasswordChangeRequest extends Entity { public const ENTITY_TYPE = 'PasswordChangeRequest'; + public function getUrl(): ?string + { + return $this->get('url'); + } + public function getRequestId(): string { return $this->get('requestId'); } + + public function getUserId(): string + { + $userId = $this->get('userId'); + + if ($userId === null) { + throw new UnexpectedValueException(); + } + + return $userId; + } } diff --git a/application/Espo/Services/User.php b/application/Espo/Services/User.php index 9a989290e5..b0bc58a14f 100644 --- a/application/Espo/Services/User.php +++ b/application/Espo/Services/User.php @@ -29,33 +29,27 @@ namespace Espo\Services; +use Espo\Core\ApplicationUser; +use Espo\Core\Mail\Exceptions\SendingError; use Espo\Entities\Team as TeamEntity; use Espo\Entities\User as UserEntity; -use Espo\Entities\Email as EmailEntity; -use Espo\Entities\PasswordChangeRequest; -use Espo\Entities\Portal as PortalEntity; - use Espo\Modules\Crm\Entities\Contact; -use Espo\Repositories\Portal as PortalRepository; - -use Espo\Core\Mail\Sender; - -use Espo\Core\{Acl\Cache\Clearer as AclCacheClearer, - Exceptions\Forbidden, - Exceptions\Error, - Exceptions\NotFound, - Exceptions\BadRequest, - Utils\Util, - Utils\PasswordHash, - Utils\ApiKey as ApiKeyUtil, - Di, - Password\Recovery, - Record\CreateParams, - Record\UpdateParams, - Record\DeleteParams}; - +use Espo\Core\Acl\Cache\Clearer as AclCacheClearer; +use Espo\Core\Di; +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Error; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Exceptions\NotFound; +use Espo\Core\Record\CreateParams; +use Espo\Core\Record\DeleteParams; +use Espo\Core\Record\UpdateParams; +use Espo\Core\Utils\ApiKey as ApiKeyUtil; +use Espo\Core\Utils\PasswordHash; +use Espo\Core\Utils\Util; use Espo\ORM\Entity; - +use Espo\Tools\UserSecurity\Password\Checker as PasswordChecker; +use Espo\Tools\UserSecurity\Password\Sender as PasswordSender; +use Espo\Tools\UserSecurity\Password\Service as PasswordService; use stdClass; use Exception; @@ -64,23 +58,12 @@ use Exception; */ class User extends Record implements - Di\TemplateFileManagerAware, - Di\EmailSenderAware, - Di\HtmlizerFactoryAware, - Di\FileManagerAware, Di\DataManagerAware { - use Di\TemplateFileManagerSetter; - use Di\EmailSenderSetter; - use Di\HtmlizerFactorySetter; - use Di\FileManagerSetter; use Di\DataManagerSetter; - private const AUTHENTICATION_METHOD_ESPO = 'Espo'; private const AUTHENTICATION_METHOD_HMAC = 'Hmac'; - private const ID_SYSTEM = 'system'; - /** * @var string[] */ @@ -105,7 +88,7 @@ class User extends Record implements */ public function getEntity(string $id): ?Entity { - if ($id === self::ID_SYSTEM) { + if ($id === ApplicationUser::SYSTEM_USER_ID) { throw new Forbidden(); } @@ -123,181 +106,7 @@ class User extends Record implements return $entity; } - /** - * @throws Forbidden - * @throws Error - * @throws NotFound - */ - public function changePassword( - string $userId, - string $password, - bool $checkCurrentPassword = false, - ?string $currentPassword = null - ): void { - - /** @var ?UserEntity $user */ - $user = $this->entityManager->getEntityById(UserEntity::ENTITY_TYPE, $userId); - - if (!$user) { - throw new NotFound(); - } - - if ($user->isSuperAdmin() && !$this->user->isSuperAdmin()) { - throw new Forbidden(); - } - - $authenticationMethod = $this->config->get('authenticationMethod', self::AUTHENTICATION_METHOD_ESPO); - - if (!$user->isAdmin() && $authenticationMethod !== self::AUTHENTICATION_METHOD_ESPO) { - throw new Forbidden("Authentication method is not `Espo`."); - } - - if (empty($password)) { - throw new Error("Password can't be empty."); - } - - if ($checkCurrentPassword) { - $u = $this->entityManager - ->getRDBRepository(UserEntity::ENTITY_TYPE) - ->where([ - 'id' => $user->getId(), - 'password' => $this->createPasswordHashUtil()->hash($currentPassword ?? ''), - ]) - ->findOne(); - - if (!$u) { - throw new Forbidden("Wrong password."); - } - } - - if (!$this->checkPasswordStrength($password)) { - throw new Forbidden("Password is weak."); - } - - $validLength = $this->fieldValidationManager - ->check($user, 'password', 'maxLength', (object) ['password' => $password]); - - if (!$validLength) { - throw new Forbidden("Password exceeds max length."); - } - - $user->set('password', $this->hashPassword($password)); - - $this->entityManager->saveEntity($user); - } - - public function checkPasswordStrength(string $password): bool - { - $minLength = $this->config->get('passwordStrengthLength'); - - if ($minLength) { - if (mb_strlen($password) < $minLength) { - return false; - } - } - - $requiredLetterCount = $this->config->get('passwordStrengthLetterCount'); - - if ($requiredLetterCount) { - $letterCount = 0; - - foreach (str_split($password) as $c) { - if (ctype_alpha($c)) { - $letterCount++; - } - } - - if ($letterCount < $requiredLetterCount) { - return false; - } - } - - $requiredNumberCount = $this->config->get('passwordStrengthNumberCount'); - - if ($requiredNumberCount) { - $numberCount = 0; - - foreach (str_split($password) as $c) { - if (is_numeric($c)) { - $numberCount++; - } - } - - if ($numberCount < $requiredNumberCount) { - return false; - } - } - - $bothCases = $this->config->get('passwordStrengthBothCases'); - - if ($bothCases) { - $ucCount = 0; - $lcCount = 0; - - foreach (str_split($password) as $c) { - if (ctype_alpha($c) && $c === mb_strtoupper($c)) { - $ucCount++; - } - - if (ctype_alpha($c) && $c === mb_strtolower($c)) { - $lcCount++; - } - } - if (!$ucCount || !$lcCount) { - return false; - } - } - - return true; - } - - private function createRecoveryService(): Recovery - { - return $this->injectableFactory->create(Recovery::class); - } - - public function passwordChangeRequest(string $userName, string $emailAddress, ?string $url = null): void - { - $this->createRecoveryService()->request($emailAddress, $userName, $url); - } - - public function changePasswordByRequest(string $requestId, string $password): stdClass - { - $recovery = $this->createRecoveryService(); - - $request = $recovery->getRequest($requestId); - - $userId = $request->get('userId'); - - if (!$userId) { - throw new Error(); - } - - $this->changePassword($userId, $password); - - $recovery->removeRequest($requestId); - - return (object) [ - 'url' => $request->get('url'), - ]; - } - - public function removeChangePasswordRequestJob(stdClass $data): void - { - if (empty($data->id)) { - return; - } - - $id = $data->id; - - $p = $this->entityManager->getEntity(PasswordChangeRequest::ENTITY_TYPE, $id); - - if ($p) { - $this->entityManager->removeEntity($p); - } - } - - protected function hashPassword(string $password): string + private function hashPassword(string $password): string { $passwordHash = $this->injectableFactory->create(PasswordHash::class); @@ -332,7 +141,7 @@ class User extends Record implements } if ($newPassword !== null) { - if (!$this->checkPasswordStrength($newPassword)) { + if (!$this->createPasswordChecker()->checkStrength($newPassword)) { throw new Forbidden("Password is weak."); } @@ -355,7 +164,7 @@ class User extends Record implements return $user; } - $this->sendAccessInfoNew($user); + $this->getPasswordService()->sendAccessInfoForNewUser($user); } catch (Exception $e) { $this->log->error("Could not send user access info. " . $e->getMessage()); @@ -366,7 +175,7 @@ class User extends Record implements public function update(string $id, stdClass $data, UpdateParams $params): Entity { - if ($id === self::ID_SYSTEM) { + if ($id === ApplicationUser::SYSTEM_USER_ID) { throw new Forbidden(); } @@ -375,7 +184,7 @@ class User extends Record implements if (property_exists($data, 'password')) { $newPassword = $data->password; - if (!$this->checkPasswordStrength($newPassword)) { + if (!$this->createPasswordChecker()->checkStrength($newPassword)) { throw new Forbidden("Password is weak."); } @@ -403,6 +212,22 @@ class User extends Record implements return $user; } + private function getPasswordService(): PasswordService + { + return $this->injectableFactory->create(PasswordService::class); + } + + /** + * @throws SendingError + * @throws Error + */ + private function sendPassword(UserEntity $user, string $password): void + { + $this->injectableFactory + ->create(PasswordSender::class) + ->sendPassword($user, $password); + } + public function prepareEntityForOutput(Entity $entity) { assert($entity instanceof UserEntity); @@ -467,132 +292,6 @@ class User extends Record implements return $entity; } - private function generatePassword(): string - { - $length = $this->config->get('passwordStrengthLength'); - $letterCount = $this->config->get('passwordStrengthLetterCount'); - $numberCount = $this->config->get('passwordStrengthNumberCount'); - - $generateLength = $this->config->get('passwordGenerateLength', 10); - $generateLetterCount = $this->config->get('passwordGenerateLetterCount', 4); - $generateNumberCount = $this->config->get('passwordGenerateNumberCount', 2); - - $length = is_null($length) ? $generateLength : $length; - $letterCount = is_null($letterCount) ? $generateLetterCount : $letterCount; - $numberCount = is_null($letterCount) ? $generateNumberCount : $numberCount; - - if ($length < $generateLength) { - $length = $generateLength; - } - - if ($letterCount < $generateLetterCount) { - $letterCount = $generateLetterCount; - } - - if ($numberCount < $generateNumberCount) { - $numberCount = $generateNumberCount; - } - - return Util::generatePassword($length, $letterCount, $numberCount, true); - } - - public function sendPasswordChangeLink(string $id, bool $allowNonAdmin = false): void - { - if (!$allowNonAdmin && !$this->user->isAdmin()) { - throw new Forbidden(); - } - - /** @var UserEntity|null $user */ - $user = $this->entityManager->getEntityById(UserEntity::ENTITY_TYPE, $id); - - if (!$user) { - throw new NotFound(); - } - - if (!$user->isActive()) { - throw new Forbidden("User is not active."); - } - - if ( - !$user->isRegular() && - !$user->isAdmin() && - !$user->isPortal() - ) { - throw new Forbidden(); - } - - $this->createRecoveryService()->createAndSendRequestForExistingUser($user); - } - - /** - * @throws Error - * @throws \Espo\Core\Mail\Exceptions\SendingError - * @throws Forbidden - * @throws NotFound - */ - public function generateNewPasswordForUser(string $id, bool $allowNonAdmin = false): void - { - if (!$allowNonAdmin) { - if (!$this->user->isAdmin()) { - throw new Forbidden(); - } - } - - /** @var ?UserEntity $user */ - $user = $this->getEntity($id); - - if (!$user) { - throw new NotFound(); - } - - if ($user->isApi()) { - throw new Forbidden(); - } - - if ($user->isSuperAdmin()) { - throw new Forbidden(); - } - - if ($user->isSystem()) { - throw new Forbidden(); - } - - if (!$user->getEmailAddress()) { - throw new Forbidden( - "Generate new password: Can't process because user doesn't have email address." - ); - } - - if (!$this->isSmtpConfigured()) { - throw new Forbidden( - "Generate new password: Can't process because SMTP is not configured." - ); - } - - $password = $this->generatePassword(); - - $this->sendPassword($user, $password); - - $this->saveUserPassword($user, $password); - } - - private function isSmtpConfigured(): bool - { - return $this->emailSender->hasSystemSmtp() || $this->config->get('internalSmtpServer'); - } - - private function saveUserPassword(UserEntity $user, string $password, bool $silent = false): void - { - $user->set('password', $this->createPasswordHashUtil()->hash($password)); - - $this->entityManager->saveEntity($user, ['silent' => $silent]); - } - - private function createPasswordHashUtil(): PasswordHash - { - return $this->injectableFactory->create(PasswordHash::class); - } - protected function getInternalUserCount(): int { return $this->entityManager @@ -755,135 +454,9 @@ class User extends Record implements } } - /** - * @return array{?string,?string,?array} - */ - private function getAccessInfoTemplateData( - UserEntity $user, - ?string $password = null, - ?PasswordChangeRequest $passwordChangeRequest = null - ): array { - - $data = []; - - if ($password !== null) { - $data['password'] = $password; - } - - $urlSuffix = ''; - - if ($passwordChangeRequest !== null) { - $urlSuffix = '?entryPoint=changePassword&id=' . $passwordChangeRequest->getRequestId(); - } - - $siteUrl = $this->config->getSiteUrl() . '/' . $urlSuffix; - - if ($user->isPortal()) { - $subjectTpl = $this->templateFileManager - ->getTemplate('accessInfoPortal', 'subject', UserEntity::ENTITY_TYPE); - $bodyTpl = $this->templateFileManager - ->getTemplate('accessInfoPortal', 'body', UserEntity::ENTITY_TYPE); - - $urlList = []; - - $portalList = $this->entityManager - ->getRDBRepository(PortalEntity::ENTITY_TYPE) - ->distinct() - ->join('users') - ->where([ - 'isActive' => true, - 'users.id' => $user->getId(), - ]) - ->find(); - - foreach ($portalList as $portal) { - /** @var PortalEntity $portal */ - $this->getPortalRepository()->loadUrlField($portal); - - $urlList[] = $portal->getUrl() . $urlSuffix; - } - - if (count($urlList) === 0) { - return [null, null, null]; - } - - $data['siteUrlList'] = $urlList; - - return [$subjectTpl, $bodyTpl, $data]; - } - - $subjectTpl = $this->templateFileManager->getTemplate('accessInfo', 'subject', UserEntity::ENTITY_TYPE); - $bodyTpl = $this->templateFileManager->getTemplate('accessInfo', 'body', UserEntity::ENTITY_TYPE); - - $data['siteUrl'] = $siteUrl; - - return [$subjectTpl, $bodyTpl, $data]; - } - - /** - * @throws \Espo\Core\Mail\Exceptions\SendingError - * @throws Error - */ - protected function sendPassword(UserEntity $user, string $password): void - { - $emailAddress = $user->getEmailAddress(); - - if (empty($emailAddress)) { - return; - } - - /** @var EmailEntity $email */ - $email = $this->entityManager->getNewEntity(EmailEntity::ENTITY_TYPE); - - if (!$this->isSmtpConfigured()) { - return; - } - - [$subjectTpl, $bodyTpl, $data] = $this->getAccessInfoTemplateData($user, $password); - - if ($data === null) { - return; - } - - $htmlizer = $this->htmlizerFactory->createNoAcl(); - - $subject = $htmlizer->render($user, $subjectTpl ?? '', null, $data, true); - $body = $htmlizer->render($user, $bodyTpl ?? '', null, $data, true); - - $email->set([ - 'subject' => $subject, - 'body' => $body, - 'to' => $emailAddress, - ]); - - $this->getEmailSenderForAccessInfo()->send($email); - } - - private function getEmailSenderForAccessInfo(): Sender - { - $sender = $this->emailSender->create(); - - if (!$this->emailSender->hasSystemSmtp()) { - $sender->withSmtpParams([ - 'server' => $this->config->get('internalSmtpServer'), - 'port' => $this->config->get('internalSmtpPort'), - 'auth' => $this->config->get('internalSmtpAuth'), - 'username' => $this->config->get('internalSmtpUsername'), - 'password' => $this->config->get('internalSmtpPassword'), - 'security' => $this->config->get('internalSmtpSecurity'), - 'fromAddress' => $this->config->get( - 'internalOutboundEmailFromAddress', - $this->config->get('outboundEmailFromAddress') - ), - ]); - } - - return $sender; - } - public function delete(string $id, DeleteParams $params): void { - if ($id === self::ID_SYSTEM) { + if ($id === ApplicationUser::SYSTEM_USER_ID) { throw new Forbidden(); } @@ -962,56 +535,9 @@ class User extends Record implements $this->dataManager->updateCacheTimestamp(); } - /** - * @throws Error - * @throws \Espo\Core\Mail\Exceptions\SendingError - */ - public function sendAccessInfoNew(UserEntity $user): void + private function createPasswordChecker(): PasswordChecker { - $primaryAddress = $user->getEmailAddressGroup()->getPrimary(); - - if ($primaryAddress === null) { - throw new Error("Can't send access info for user '{$user->getId()}' w/o email address."); - } - - $emailAddress = $primaryAddress->getAddress(); - - if (!$this->isSmtpConfigured()) { - throw new Error("Can't send access info. SMTP is not configured."); - } - - $stubPassword = $this->generatePassword(); - - $this->saveUserPassword($user, $stubPassword, true); - - $request = $this->createRecoveryService()->createRequestForNewUser($user); - - [$subjectTpl, $bodyTpl, $data] = $this->getAccessInfoTemplateData($user, null, $request); - - if ($data === null) { - throw new Error("Could not send access info."); - } - - /** @var EmailEntity $email */ - $email = $this->entityManager->getEntity(EmailEntity::ENTITY_TYPE); - - $htmlizer = $this->htmlizerFactory->createNoAcl(); - - $subject = $htmlizer->render($user, $subjectTpl ?? '', null, $data, true); - $body = $htmlizer->render($user, $bodyTpl ?? '', null, $data, true); - - $email - ->addToAddress($emailAddress) - ->setSubject($subject) - ->setBody($body); - - $this->getEmailSenderForAccessInfo()->send($email); - } - - private function getPortalRepository(): PortalRepository - { - /** @var PortalRepository */ - return $this->entityManager->getRDBRepository(PortalEntity::ENTITY_TYPE); + return $this->injectableFactory->create(PasswordChecker::class); } private function createAclCacheClearer(): AclCacheClearer diff --git a/application/Espo/Tools/UserSecurity/Password/Checker.php b/application/Espo/Tools/UserSecurity/Password/Checker.php new file mode 100644 index 0000000000..baa717316c --- /dev/null +++ b/application/Espo/Tools/UserSecurity/Password/Checker.php @@ -0,0 +1,108 @@ +config = $config; + } + + public function checkStrength(string $password): bool + { + $minLength = $this->config->get('passwordStrengthLength'); + + if ($minLength) { + if (mb_strlen($password) < $minLength) { + return false; + } + } + + $requiredLetterCount = $this->config->get('passwordStrengthLetterCount'); + + if ($requiredLetterCount) { + $letterCount = 0; + + foreach (str_split($password) as $c) { + if (ctype_alpha($c)) { + $letterCount++; + } + } + + if ($letterCount < $requiredLetterCount) { + return false; + } + } + + $requiredNumberCount = $this->config->get('passwordStrengthNumberCount'); + + if ($requiredNumberCount) { + $numberCount = 0; + + foreach (str_split($password) as $c) { + if (is_numeric($c)) { + $numberCount++; + } + } + + if ($numberCount < $requiredNumberCount) { + return false; + } + } + + $bothCases = $this->config->get('passwordStrengthBothCases'); + + if ($bothCases) { + $ucCount = 0; + $lcCount = 0; + + foreach (str_split($password) as $c) { + if (ctype_alpha($c) && $c === mb_strtoupper($c)) { + $ucCount++; + } + + if (ctype_alpha($c) && $c === mb_strtolower($c)) { + $lcCount++; + } + } + if (!$ucCount || !$lcCount) { + return false; + } + } + + return true; + } +} diff --git a/application/Espo/Tools/UserSecurity/Password/Generator.php b/application/Espo/Tools/UserSecurity/Password/Generator.php new file mode 100644 index 0000000000..4a6591d841 --- /dev/null +++ b/application/Espo/Tools/UserSecurity/Password/Generator.php @@ -0,0 +1,81 @@ +config = $config; + } + + /** + * Generate a password. + */ + public function generate(): string + { + $length = $this->config->get('passwordStrengthLength'); + $letterCount = $this->config->get('passwordStrengthLetterCount'); + $numberCount = $this->config->get('passwordStrengthNumberCount'); + + $generateLength = $this->config->get('passwordGenerateLength', 10); + $generateLetterCount = $this->config->get('passwordGenerateLetterCount', 4); + $generateNumberCount = $this->config->get('passwordGenerateNumberCount', 2); + + $length = is_null($length) ? $generateLength : $length; + $letterCount = is_null($letterCount) ? $generateLetterCount : $letterCount; + $numberCount = is_null($letterCount) ? $generateNumberCount : $numberCount; + + if ($length < $generateLength) { + $length = $generateLength; + } + + if ($letterCount < $generateLetterCount) { + $letterCount = $generateLetterCount; + } + + if ($numberCount < $generateNumberCount) { + $numberCount = $generateNumberCount; + } + + return Util::generatePassword($length, $letterCount, $numberCount, true); + } +} diff --git a/application/Espo/Tools/UserSecurity/Password/Jobs/RemoveRecoveryRequest.php b/application/Espo/Tools/UserSecurity/Password/Jobs/RemoveRecoveryRequest.php new file mode 100644 index 0000000000..60b1252d86 --- /dev/null +++ b/application/Espo/Tools/UserSecurity/Password/Jobs/RemoveRecoveryRequest.php @@ -0,0 +1,63 @@ +entityManager = $entityManager; + } + + public function run(Data $data): void + { + $id = $data->get('id'); + + if (!$id) { + throw new RuntimeException(); + } + + $entity = $this->entityManager->getEntity(PasswordChangeRequest::ENTITY_TYPE, $id); + + if (!$entity) { + return; + } + + $this->entityManager->removeEntity($entity); + } +} diff --git a/application/Espo/Tools/UserSecurity/Password/Sender.php b/application/Espo/Tools/UserSecurity/Password/Sender.php new file mode 100644 index 0000000000..c66b7e7781 --- /dev/null +++ b/application/Espo/Tools/UserSecurity/Password/Sender.php @@ -0,0 +1,270 @@ +config = $config; + $this->emailSender = $emailSender; + $this->entityManager = $entityManager; + $this->htmlizerFactory = $htmlizerFactory; + $this->templateFileManager = $templateFileManager; + } + + /** + * Send access info for a new user. + * + * @throws Error + * @throws NoSmtp + * @throws SendingError + */ + public function sendAccessInfo(User $user, PasswordChangeRequest $request): void + { + $emailAddress = $user->getEmailAddress(); + + if (!$emailAddress) { + throw new Error("No email address."); + } + + [$subjectTpl, $bodyTpl, $data] = $this->getAccessInfoTemplateData($user, null, $request); + + if ($data === null) { + throw new Error("Could not send access info."); + } + + /** @var Email $email */ + $email = $this->entityManager->getNewEntity(Email::ENTITY_TYPE); + + $htmlizer = $this->htmlizerFactory->createNoAcl(); + + $subject = $htmlizer->render($user, $subjectTpl ?? '', null, $data, true); + $body = $htmlizer->render($user, $bodyTpl ?? '', null, $data, true); + + $email + ->addToAddress($emailAddress) + ->setSubject($subject) + ->setBody($body); + + $this->createSender()->send($email); + } + + /** + * Send a plain password in email. + * + * @throws SendingError + * @throws Error + */ + public function sendPassword(User $user, string $password): void + { + $emailAddress = $user->getEmailAddress(); + + if (empty($emailAddress)) { + return; + } + + /** @var Email $email */ + $email = $this->entityManager->getNewEntity(Email::ENTITY_TYPE); + + if (!$this->isSmtpConfigured()) { + return; + } + + [$subjectTpl, $bodyTpl, $data] = $this->getAccessInfoTemplateData($user, $password); + + if ($data === null) { + return; + } + + $htmlizer = $this->htmlizerFactory->createNoAcl(); + + $subject = $htmlizer->render($user, $subjectTpl ?? '', null, $data, true); + $body = $htmlizer->render($user, $bodyTpl ?? '', null, $data, true); + + $email + ->setSubject($subject) + ->setBody($body) + ->addToAddress($emailAddress); + + $this->createSender()->send($email); + } + + /** + * @throws NoSmtp + */ + private function createSender(): EmailSenderSender + { + $sender = $this->emailSender->create(); + + $smtpParams = $this->getSmtpParams(); + + if ($smtpParams) { + $sender = $sender->withSmtpParams($smtpParams); + } + + return $sender; + } + + /** + * @return array{?string, ?string, ?array} + */ + private function getAccessInfoTemplateData( + User $user, + ?string $password = null, + ?PasswordChangeRequest $passwordChangeRequest = null + ): array { + + $data = []; + + if ($password !== null) { + $data['password'] = $password; + } + + $urlSuffix = ''; + + if ($passwordChangeRequest !== null) { + $urlSuffix = '?entryPoint=changePassword&id=' . $passwordChangeRequest->getRequestId(); + } + + $siteUrl = $this->config->getSiteUrl() . '/' . $urlSuffix; + + if ($user->isPortal()) { + $subjectTpl = $this->templateFileManager + ->getTemplate('accessInfoPortal', 'subject', User::ENTITY_TYPE); + $bodyTpl = $this->templateFileManager + ->getTemplate('accessInfoPortal', 'body', User::ENTITY_TYPE); + + $urlList = []; + + $portalList = $this->entityManager + ->getRDBRepositoryByClass(Portal::class) + ->distinct() + ->join('users') + ->where([ + 'isActive' => true, + 'users.id' => $user->getId(), + ]) + ->find(); + + foreach ($portalList as $portal) { + /** @var Portal $portal */ + $this->getPortalRepository()->loadUrlField($portal); + + $urlList[] = $portal->getUrl() . $urlSuffix; + } + + if (count($urlList) === 0) { + return [null, null, null]; + } + + $data['siteUrlList'] = $urlList; + + return [$subjectTpl, $bodyTpl, $data]; + } + + $subjectTpl = $this->templateFileManager->getTemplate('accessInfo', 'subject', User::ENTITY_TYPE); + $bodyTpl = $this->templateFileManager->getTemplate('accessInfo', 'body', User::ENTITY_TYPE); + + $data['siteUrl'] = $siteUrl; + + return [$subjectTpl, $bodyTpl, $data]; + } + + private function isSmtpConfigured(): bool + { + return + $this->emailSender->hasSystemSmtp() || + $this->config->get('internalSmtpServer'); + } + + private function getPortalRepository(): PortalRepository + { + /** @var PortalRepository */ + return $this->entityManager->getRDBRepository(Portal::ENTITY_TYPE); + } + + /** + * @throws NoSmtp + */ + private function getSmtpParams(): ?SmtpParams + { + if ($this->emailSender->hasSystemSmtp()) { + return null; + } + + $server = $this->config->get('internalSmtpServer'); + + if (!$server) { + throw new NoSmtp("No SMTP configured to send access info."); + } + + /** @var int $port */ + $port = $this->config->get('internalSmtpPort'); + + return SmtpParams + ::create($server, $port) + ->withAuth($this->config->get('internalSmtpAuth')) + ->withUsername($this->config->get('internalSmtpUsername')) + ->withPassword($this->config->get('internalSmtpPassword')) + ->withSecurity($this->config->get('internalSmtpSecurity')) + ->withFromAddress( + $this->config->get('internalOutboundEmailFromAddress') ?? + $this->config->get('outboundEmailFromAddress') + ); + } +} diff --git a/application/Espo/Tools/UserSecurity/Password/Service.php b/application/Espo/Tools/UserSecurity/Password/Service.php new file mode 100644 index 0000000000..525210ce7f --- /dev/null +++ b/application/Espo/Tools/UserSecurity/Password/Service.php @@ -0,0 +1,332 @@ +user = $user; + $this->serviceContainer = $serviceContainer; + $this->emailSender = $emailSender; + $this->config = $config; + $this->generator = $generator; + $this->sender = $sender; + $this->passwordHash = $passwordHash; + $this->entityManager = $entityManager; + $this->recovery = $recovery; + $this->fieldValidationManager = $fieldValidationManager; + $this->checker = $checker; + } + + /** + * Create and send a password recovery link in an email. Only for admin. + * + * @throws Forbidden + * @throws NotFound + * @throws Error + */ + public function createAndSendPasswordRecovery(string $id): void + { + if (!$this->user->isAdmin()) { + throw new Forbidden(); + } + + /** @var ?User $user */ + $user = $this->entityManager->getEntityById(User::ENTITY_TYPE, $id); + + if (!$user) { + throw new NotFound(); + } + + if (!$user->isActive()) { + throw new Forbidden("User is not active."); + } + + if ( + !$user->isRegular() && + !$user->isAdmin() && + !$user->isPortal() + ) { + throw new Forbidden(); + } + + $this->recovery->createAndSendRequestForExistingUser($user); + } + + /** + * Change a password by a recovery request. + * + * @param string $requestId A request ID. + * @param string $password A new password. + * + * @return ?string A URL to suggest to a user. + * @throws Error + * @throws Forbidden + * @throws NotFound + */ + public function changePasswordByRecovery(string $requestId, string $password): ?string + { + $request = $this->recovery->getRequest($requestId); + + $this->changePassword($request->getUserId(), $password); + $this->recovery->removeRequest($requestId); + + return $request->getUrl(); + } + + /** + * Change a password with a current password check. + * + * @throws Forbidden + * @throws NotFound + * @throws Error + */ + public function changePasswordWithCheck(string $userId, string $password, string $currentPassword): void + { + $this->changePasswordInternal($userId, $password, true, $currentPassword); + } + + /** + * Change a password. + * + * @throws Forbidden + * @throws NotFound + * @throws Error + */ + private function changePassword(string $userId, string $password): void + { + $this->changePasswordInternal($userId, $password); + } + + /** + * @throws Forbidden + * @throws Error + * @throws NotFound + */ + private function changePasswordInternal( + string $userId, + string $password, + bool $checkCurrentPassword = false, + ?string $currentPassword = null + ): void { + + /** @var ?User $user */ + $user = $this->entityManager->getEntityById(User::ENTITY_TYPE, $userId); + + if (!$user) { + throw new NotFound(); + } + + if ( + $user->isSuperAdmin() && + !$this->user->isSuperAdmin() + ) { + throw new Forbidden(); + } + + $authenticationMethod = $this->config->get('authenticationMethod', Espo::NAME); + + if (!$user->isAdmin() && $authenticationMethod !== Espo::NAME) { + throw new Forbidden("Authentication method is not `Espo`."); + } + + if (empty($password)) { + throw new Error("Password can't be empty."); + } + + if ($checkCurrentPassword) { + $u = $this->entityManager + ->getRDBRepository(User::ENTITY_TYPE) + ->where([ + 'id' => $user->getId(), + 'password' => $this->passwordHash->hash($currentPassword ?? ''), + ]) + ->findOne(); + + if (!$u) { + throw new Forbidden("Wrong password."); + } + } + + if (!$this->checker->checkStrength($password)) { + throw new Forbidden("Password is weak."); + } + + $validLength = $this->fieldValidationManager->check( + $user, + 'password', + 'maxLength', + (object) ['password' => $password] + ); + + if (!$validLength) { + throw new Forbidden("Password exceeds max length."); + } + + $user->set('password', $this->passwordHash->hash($password)); + + $this->entityManager->saveEntity($user); + } + + /** + * Send access info for a new user. + * + * @throws Error + * @throws SendingError + */ + public function sendAccessInfoForNewUser(User $user): void + { + $emailAddress = $user->getEmailAddress(); + + if ($emailAddress === null) { + throw new Error("Can't send access info for user '{$user->getId()}' w/o email address."); + } + + if (!$this->isSmtpConfigured()) { + throw new Error("Can't send access info. SMTP is not configured."); + } + + $stubPassword = $this->generator->generate(); + + $this->savePasswordSilent($user, $stubPassword); + + $request = $this->recovery->createRequestForNewUser($user); + + $this->sender->sendAccessInfo($user, $request); + } + + /** + * Generate a new password and send it in an email. Only for admin. + * + * @throws Forbidden + * @throws NotFound + * @throws SendingError + * @throws Error + */ + public function generateAndSendNewPasswordForUser(string $id): void + { + if (!$this->user->isAdmin()) { + throw new Forbidden(); + } + + /** @var ?User $user */ + $user = $this->serviceContainer + ->get(User::ENTITY_TYPE) + ->getEntity($id); + + if (!$user) { + throw new NotFound(); + } + + if ($user->isApi()) { + throw new Forbidden(); + } + + if ($user->isSuperAdmin()) { + throw new Forbidden(); + } + + if ($user->isSystem()) { + throw new Forbidden(); + } + + if (!$user->getEmailAddress()) { + throw new Forbidden("Generate new password: Can't process because user doesn't have email address."); + } + + if (!$this->isSmtpConfigured()) { + throw new Forbidden("Generate new password: Can't process because SMTP is not configured."); + } + + $password = $this->generator->generate(); + + $this->sender->sendPassword($user, $password); + $this->savePassword($user, $password); + } + + private function savePassword(User $user, string $password): void + { + $user->set('password', $this->passwordHash->hash($password)); + + $this->entityManager->saveEntity($user); + } + + private function savePasswordSilent(User $user, string $password): void + { + $user->set('password', $this->passwordHash->hash($password)); + + $this->entityManager->saveEntity($user, ['silent' => true]); + } + + private function isSmtpConfigured(): bool + { + return + $this->emailSender->hasSystemSmtp() || + $this->config->get('internalSmtpServer'); + } +} diff --git a/application/Espo/Tools/UserSecurity/Service.php b/application/Espo/Tools/UserSecurity/Service.php index eb749adb3a..ce9d827ff0 100644 --- a/application/Espo/Tools/UserSecurity/Service.php +++ b/application/Espo/Tools/UserSecurity/Service.php @@ -29,6 +29,7 @@ namespace Espo\Tools\UserSecurity; +use Espo\Core\Exceptions\Error; use Espo\Core\Exceptions\Forbidden; use Espo\Core\Exceptions\NotFound; use Espo\Core\Exceptions\BadRequest; @@ -105,7 +106,7 @@ class Service /** * @throws BadRequest - * @throws \Espo\Core\Exceptions\Error + * @throws Error * @throws Forbidden * @throws NotFound */ @@ -171,6 +172,11 @@ class Service return $clientData; } + /** + * @throws Error + * @throws Forbidden + * @throws NotFound + */ public function update(string $id, stdClass $data): stdClass { if (!$this->user->isAdmin() && $id !== $this->user->getId()) {