diff --git a/application/Espo/Classes/FieldProcessing/Portal/UrlLoader.php b/application/Espo/Classes/FieldProcessing/Portal/UrlLoader.php index 46e024ba37..b2b0c84f15 100644 --- a/application/Espo/Classes/FieldProcessing/Portal/UrlLoader.php +++ b/application/Espo/Classes/FieldProcessing/Portal/UrlLoader.php @@ -32,6 +32,7 @@ namespace Espo\Classes\FieldProcessing\Portal; use Espo\ORM\Entity; use Espo\Repositories\Portal as PortalRepository; +use Espo\Entities\Portal; use Espo\Core\{ FieldProcessing\Loader, @@ -50,6 +51,8 @@ class UrlLoader implements Loader public function process(Entity $entity, Params $params): void { + /** @var Portal $entity */ + $this->getPortalRepository()->loadUrlField($entity); } diff --git a/application/Espo/Controllers/User.php b/application/Espo/Controllers/User.php index 81279fec5f..38a4429956 100644 --- a/application/Espo/Controllers/User.php +++ b/application/Espo/Controllers/User.php @@ -156,6 +156,23 @@ class User extends Record return true; } + public function postActionSendPasswordChangeLink(Request $request): bool + { + if (!$this->user->isAdmin()) { + throw new Forbidden(); + } + + $id = $request->getParsedBody()->id ?? null; + + if (!$id) { + throw new BadRequest(); + } + + $this->getUserService()->sendPasswordChangeLink($id); + + return true; + } + public function postActionCreateLink(Request $request): bool { if (!$this->user->isAdmin()) { diff --git a/application/Espo/Core/Password/Recovery.php b/application/Espo/Core/Password/Recovery.php index d22e957233..27eb682a39 100644 --- a/application/Espo/Core/Password/Recovery.php +++ b/application/Espo/Core/Password/Recovery.php @@ -32,11 +32,16 @@ namespace Espo\Core\Password; use Espo\Core\Utils\Util; use Espo\Entities\User; use Espo\Entities\PasswordChangeRequest; +use Espo\Entities\Portal; + +use Espo\Repositories\Portal as PortalRepository; use Espo\Core\Exceptions\Forbidden; use Espo\Core\Exceptions\NotFound; use Espo\Core\Exceptions\Error; +use Espo\Core\Field\DateTime; + use Espo\Core\{ ORM\EntityManager, Utils\Config, @@ -47,46 +52,30 @@ use Espo\Core\{ Job\QueueName, }; -use DateTime; - class Recovery { /** * Milliseconds. */ - const REQUEST_DELAY = 3000; + private const REQUEST_DELAY = 3000; - const REQUEST_LIFETIME = '3 hours'; + private const REQUEST_LIFETIME = '3 hours'; - /** - * @var EntityManager - */ - protected $entityManager; + private const NEW_USER_REQUEST_LIFETIME = '2 days'; - /** - * @var Config - */ - protected $config; + private const EXISTING_USER_REQUEST_LIFETIME = '2 days'; - /** - * @var EmailSender - */ - protected $emailSender; + protected EntityManager $entityManager; - /** - * @var HtmlizerFactory - */ - protected $htmlizerFactory; + protected Config $config; - /** - * @var TemplateFileManager - */ - protected $templateFileManager; + protected EmailSender $emailSender; - /** - * @var Log - */ - private $log; + protected HtmlizerFactory $htmlizerFactory; + + protected TemplateFileManager $templateFileManager; + + private Log $log; public function __construct( EntityManager $entityManager, @@ -107,13 +96,12 @@ class Recovery public function getRequest(string $id): PasswordChangeRequest { $config = $this->config; - $em = $this->entityManager; if ($config->get('passwordRecoveryDisabled')) { throw new Forbidden("Password recovery: Disabled."); } - $request = $em + $request = $this->entityManager ->getRDBRepository('PasswordChangeRequest') ->where([ 'requestId' => $id, @@ -132,11 +120,9 @@ class Recovery return $request; } - public function removeRequest(string $id) + public function removeRequest(string $id): void { - $em = $this->entityManager; - - $request = $em + $request = $this->entityManager ->getRDBRepository('PasswordChangeRequest') ->where([ 'requestId' => $id, @@ -144,14 +130,13 @@ class Recovery ->findOne(); if ($request) { - $em->removeEntity($request); + $this->entityManager->removeEntity($request); } } public function request(string $emailAddress, string $userName, ?string $url): bool { $config = $this->config; - $em = $this->entityManager; $noExposure = $config->get('passwordRecoveryNoExposure') ?? false; @@ -159,8 +144,8 @@ class Recovery throw new Forbidden("Password recovery: Disabled."); } - $user = $em - ->getRDBRepository('User') + $user = $this->entityManager + ->getRDBRepository('User') ->where([ 'userName' => $userName, 'emailAddress' => $emailAddress, @@ -213,14 +198,14 @@ class Recovery return false; } - $passwordChangeRequest = $em + $existingRequest = $this->entityManager ->getRDBRepository('PasswordChangeRequest') ->where([ 'userId' => $user->getId(), ]) ->findOne(); - if ($passwordChangeRequest) { + if ($existingRequest) { if (!$noExposure) { throw new Forbidden(json_encode(['reason' => 'Already-Sent'])); } @@ -230,39 +215,17 @@ class Recovery return false; } - $requestId = Util::generateCryptId(); - - $passwordChangeRequest = $em->getEntity('PasswordChangeRequest'); - - $passwordChangeRequest->set([ - 'userId' => $user->id, - 'requestId' => $requestId, - 'url' => $url, - ]); + $request = $this->createRequestNoSave($user, $url); $microtime = microtime(true); - $this->send($requestId, $emailAddress, $user); + $this->send($request->getRequestId(), $emailAddress, $user); - $em->saveEntity($passwordChangeRequest); - - if (!$passwordChangeRequest->id) { - throw new Error(); - } + $this->entityManager->saveEntity($request); $lifetime = $config->get('passwordRecoveryRequestLifetime') ?? self::REQUEST_LIFETIME; - $dt = new DateTime(); - - $dt->modify('+' . $lifetime); - - $em->createEntity('Job', [ - 'serviceName' => 'User', - 'methodName' => 'removeChangePasswordRequestJob', - 'data' => ['id' => $passwordChangeRequest->id], - 'executeTime' => $dt->format('Y-m-d H:i:s'), - 'queue' => QueueName::Q1, - ]); + $this->createCleanupRequestJob($request->getId(), $lifetime); $timeDiff = $this->getDelay() - floor((microtime(true) - $microtime) / 1000); @@ -273,45 +236,104 @@ class Recovery return true; } - private function getDelay() + private function createRequestNoSave(User $user, ?string $url = null): PasswordChangeRequest + { + $entity = $this->entityManager->getNewEntity(PasswordChangeRequest::ENTITY_TYPE); + + $entity->set([ + 'userId' => $user->getId(), + 'requestId' => Util::generateCryptId(), + 'url' => $url, + ]); + + return $entity; + } + + public function createRequestForNewUser(User $user, ?string $url = null): PasswordChangeRequest + { + $entity = $this->createRequestNoSave($user, $url); + + $this->entityManager->saveEntity($entity); + + $lifetime = $this->config->get('passwordChangeRequestNewUserLifetime') ?? self::NEW_USER_REQUEST_LIFETIME; + + $this->createCleanupRequestJob($entity->getId(), $lifetime); + + return $entity; + } + + public function createAndSendRequestForExistingUser(User $user, ?string $url = null): PasswordChangeRequest + { + if (!$user->getEmailAddressGroup()->getPrimary()) { + throw new Error("No email address."); + } + + $emailAddress = $user->getEmailAddressGroup()->getPrimary()->getAddress(); + + $entity = $this->createRequestNoSave($user, $url); + + $this->entityManager->saveEntity($entity); + + $lifetime = $this->config->get('passwordChangeRequestExistingUserLifetime') ?? + self::EXISTING_USER_REQUEST_LIFETIME; + + $this->createCleanupRequestJob($entity->getId(), $lifetime); + + $this->send($entity->getRequestId(), $emailAddress, $user); + + return $entity; + } + + 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, + ]); + } + + private function getDelay(): int { return $this->config->get('passwordRecoveryRequestDelay') ?? self::REQUEST_DELAY; } - protected function delay(?int $delay = null) + protected function delay(?int $delay = null): void { - $delay = $delay ?? $this->getDelay(); + if ($delay === null) { + $delay = $this->getDelay(); + } usleep($delay * 1000); } - protected function send(string $requestId, string $emailAddress, User $user) + protected function send(string $requestId, string $emailAddress, User $user): void { - $config = $this->config; - $em = $this->entityManager; - $htmlizerFactory = $this->htmlizerFactory; - - $templateFileManager = $this->templateFileManager; - if (!$emailAddress) { return; } - $email = $em->getEntity('Email'); + $email = $this->entityManager->getEntity('Email'); - if (!$this->emailSender->hasSystemSmtp() && !$config->get('internalSmtpServer')) { + if (!$this->emailSender->hasSystemSmtp() && !$this->config->get('internalSmtpServer')) { throw new Error("Password recovery: SMTP credentials are not defined."); } $sender = $this->emailSender->create(); - $subjectTpl = $templateFileManager->getTemplate('passwordChangeLink', 'subject', 'User'); - $bodyTpl = $templateFileManager->getTemplate('passwordChangeLink', 'body', 'User'); + $subjectTpl = $this->templateFileManager->getTemplate('passwordChangeLink', 'subject', 'User'); + $bodyTpl = $this->templateFileManager->getTemplate('passwordChangeLink', 'body', 'User'); - $siteUrl = $config->getSiteUrl(); + $siteUrl = $this->config->getSiteUrl(); if ($user->isPortal()) { - $portal = $em->getRDBRepository('Portal') + /** @var Portal|null $portal */ + $portal = $this->entityManager + ->getRDBRepository(Portal::ENTITY_TYPE) ->distinct() ->join('users') ->where([ @@ -320,10 +342,16 @@ class Recovery ]) ->findOne(); - if ($portal) { - if ($portal->get('customUrl')) { - $siteUrl = $portal->get('customUrl'); - } + if (!$portal) { + throw new Error("Portal user does not belong to any potral."); + } + + $this->getPortalRepository()->loadUrlField($portal); + + $siteUrl = $portal->getUrl(); + + if (!$siteUrl) { + throw new Error("Portal does not have URL."); } } @@ -333,7 +361,7 @@ class Recovery $data['link'] = $link; - $htmlizer = $htmlizerFactory->create(true); + $htmlizer = $this->htmlizerFactory->create(true); $subject = $htmlizer->render($user, $subjectTpl, null, $data, true); $body = $htmlizer->render($user, $bodyTpl, null, $data, true); @@ -347,15 +375,15 @@ class Recovery if (!$this->emailSender->hasSystemSmtp()) { $sender->withSmtpParams([ - 'server' => $config->get('internalSmtpServer'), - 'port' => $config->get('internalSmtpPort'), - 'auth' => $config->get('internalSmtpAuth'), - 'username' => $config->get('internalSmtpUsername'), - 'password' => $config->get('internalSmtpPassword'), - 'security' => $config->get('internalSmtpSecurity'), - 'fromAddress' => $config->get( + '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', - $config->get('outboundEmailFromAddress') + $this->config->get('outboundEmailFromAddress') ), ]); } @@ -363,11 +391,9 @@ class Recovery $sender->send($email); } - private function fail(?string $msg = null, int $errorCode = 403) + private function fail(?string $msg = null, int $errorCode = 403): void { - $config = $this->config; - - $noExposure = $config->get('passwordRecoveryNoExposure') ?? false; + $noExposure = $this->config->get('passwordRecoveryNoExposure') ?? false; if ($msg) { $this->log->warning($msg); @@ -377,14 +403,20 @@ class Recovery if ($errorCode === 403) { throw new Forbidden(); } - else if ($errorCode === 404) { + + if ($errorCode === 404) { throw new NotFound(); } - else { - throw new Error(); - } + + throw new Error(); } $this->delay(); } + + private function getPortalRepository(): PortalRepository + { + /** @var PortalRepository */ + return $this->entityManager->getRDBRepository(Portal::ENTITY_TYPE); + } } diff --git a/application/Espo/Core/Utils/Util.php b/application/Espo/Core/Utils/Util.php index 5676ce1bf9..1046e473ef 100644 --- a/application/Espo/Core/Utils/Util.php +++ b/application/Espo/Core/Utils/Util.php @@ -888,8 +888,13 @@ class Util return $url; } - public static function generatePassword(int $length = 8, int $letters = 5, int $numbers = 3, bool $bothCases = false) - { + public static function generatePassword( + int $length = 8, + int $letters = 5, + int $numbers = 3, + bool $bothCases = false + ): string { + $chars = [ 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', '0123456789', @@ -900,28 +905,39 @@ class Util $shuffle = function ($array) { $currentIndex = count($array); + while (0 !== $currentIndex) { $rand = (0 + (1 - 0) * (mt_rand() / mt_getrandmax())); $randomIndex = intval(floor($rand * $currentIndex)); $currentIndex -= 1; $temporaryValue = $array[$currentIndex]; + $array[$currentIndex] = $array[$randomIndex]; $array[$randomIndex] = $temporaryValue; } + return $array; }; $upperCase = 0; $lowerCase = 0; + if ($bothCases) { $upperCase = 1; $lowerCase = 1; - if ($letters >= 2) $letters = $letters - 2; - else $letters = 0; + + if ($letters >= 2) { + $letters = $letters - 2; + } else { + $letters = 0; + } } $either = $length - ($letters + $numbers + $upperCase + $lowerCase); - if ($either < 0) $either = 0; + + if ($either < 0) { + $either = 0; + } $array = []; @@ -930,6 +946,7 @@ class Util $subArray = []; $j = 0; + while ($j < $len) { $rand = (0 + (1 - 0) * (mt_rand() / mt_getrandmax())); $index = intval(floor($rand * strlen($set))); diff --git a/application/Espo/Entities/PasswordChangeRequest.php b/application/Espo/Entities/PasswordChangeRequest.php index 5fdacbcff4..ee3360e89f 100644 --- a/application/Espo/Entities/PasswordChangeRequest.php +++ b/application/Espo/Entities/PasswordChangeRequest.php @@ -32,4 +32,9 @@ namespace Espo\Entities; class PasswordChangeRequest extends \Espo\Core\ORM\Entity { public const ENTITY_TYPE = 'PasswordChangeRequest'; + + public function getRequestId(): string + { + return $this->get('requestId'); + } } diff --git a/application/Espo/Entities/Portal.php b/application/Espo/Entities/Portal.php index dcdce32aa0..d27bf11926 100644 --- a/application/Espo/Entities/Portal.php +++ b/application/Espo/Entities/Portal.php @@ -52,4 +52,9 @@ class Portal extends \Espo\Core\ORM\Entity { return $this->settingsAttributeList; } + + public function getUrl(): ?string + { + return $this->get('url'); + } } diff --git a/application/Espo/EntryPoints/ChangePassword.php b/application/Espo/EntryPoints/ChangePassword.php index 44beee24f3..8f858d95e1 100644 --- a/application/Espo/EntryPoints/ChangePassword.php +++ b/application/Espo/EntryPoints/ChangePassword.php @@ -29,31 +29,29 @@ namespace Espo\EntryPoints; -use Espo\Core\Exceptions\NotFound; use Espo\Core\Exceptions\BadRequest; -use Espo\Core\{ - EntryPoint\EntryPoint, - EntryPoint\Traits\NoAuth, - Utils\Config, - Utils\ClientManager, - ORM\EntityManager, - Api\Request, - Api\Response, -}; +use Espo\Entities\PasswordChangeRequest; + +use Espo\Core\EntryPoint\EntryPoint; +use Espo\Core\EntryPoint\Traits\NoAuth; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\ClientManager; + +use Espo\Core\Api\Request; +use Espo\Core\Api\Response; + +use Espo\ORM\EntityManager; class ChangePassword implements EntryPoint { use NoAuth; - /** @var Config */ - protected $config; + private Config $config; - /** @var ClientManager */ - protected $clientManager; + private ClientManager $clientManager; - /** @var EntityManager */ - protected $entityManager; + private EntityManager $entityManager; public function __construct(Config $config, ClientManager $clientManager, EntityManager $entityManager) { @@ -71,34 +69,36 @@ class ChangePassword implements EntryPoint } $passwordChangeRequest = $this->entityManager - ->getRDBRepository('PasswordChangeRequest') + ->getRDBRepository(PasswordChangeRequest::ENTITY_TYPE) ->where([ 'requestId' => $requestId, ]) ->findOne(); $strengthParams = [ + 'passwordGenerateLength' => $this->config->get('passwordGenerateLength'), + 'passwordGenerateLetterCount' => $this->config->get('passwordGenerateLetterCount'), + 'generateNumberCount' => $this->config->get('generateNumberCount'), 'passwordStrengthLength' => $this->config->get('passwordStrengthLength'), 'passwordStrengthLetterCount' => $this->config->get('passwordStrengthLetterCount'), 'passwordStrengthNumberCount' => $this->config->get('passwordStrengthNumberCount'), 'passwordStrengthBothCases' => $this->config->get('passwordStrengthBothCases'), ]; - if (!$passwordChangeRequest) { - throw new NotFound(); - } - $options = [ 'id' => $requestId, 'strengthParams' => $strengthParams, + 'notFound' => !$passwordChangeRequest, ]; $runScript = " - app.getController('PasswordChangeRequest', function (controller) { - controller.doAction('passwordChange', ".json_encode($options)."); + app.getController('PasswordChangeRequest', controller => { + controller.doAction('passwordChange', " . json_encode($options) . "); }); "; - $this->clientManager->display($runScript); + $response->writeBody( + $this->clientManager->render($runScript) + ); } } diff --git a/application/Espo/Repositories/Portal.php b/application/Espo/Repositories/Portal.php index efdcf8d3e1..de032b8ee1 100644 --- a/application/Espo/Repositories/Portal.php +++ b/application/Espo/Repositories/Portal.php @@ -29,7 +29,7 @@ namespace Espo\Repositories; -use Espo\ORM\Entity; +use Espo\Entities\Portal as PortalEntity; use Espo\Core\Repositories\Database; @@ -41,7 +41,7 @@ class Portal extends Database implements { use Di\ConfigSetter; - public function loadUrlField(Entity $entity) + public function loadUrlField(PortalEntity $entity): void { if ($entity->get('customUrl')) { $entity->set('url', $entity->get('customUrl')); diff --git a/application/Espo/Resources/i18n/en_US/User.json b/application/Espo/Resources/i18n/en_US/User.json index ca4aad54f3..7027f81263 100644 --- a/application/Espo/Resources/i18n/en_US/User.json +++ b/application/Espo/Resources/i18n/en_US/User.json @@ -72,6 +72,7 @@ "Proceed w/o Contact": "Proceed w/o Contact", "Generate New API Key": "Generate New API Key", "Generate New Password": "Generate New Password", + "Send Password Change Link": "Send Password Change Link", "Back to login form": "Back to login form", "Requirements": "Requirements", "Security": "Security", @@ -91,6 +92,7 @@ "portals": "Portals which this user has access to." }, "messages": { + "sendPasswordChangeLinkConfirmation": "An email with a unique link will be sent to the user allowing them to change their password. The link will expire after a specific amount of time.", "passwordRecoverySentIfMatched": "Assuming the entered data matched any user account.", "passwordStrengthLength": "Must be at least {length} characters long.", "passwordStrengthLetterCount": "Must contain at least {count} letter(s).", @@ -120,7 +122,8 @@ "security2FaResetConfimation": "Are you sure you want to reset the current 2FA settings?", "auth2FARequiredHeader": "2 factor authentication required", "auth2FARequired": "You need to set up 2 factor authentication. Use an authenticator application on your mobile phone (e.g. Google Authenticator).", - "ldapUserInEspoNotFound": "User is not found in EspoCRM. Contact your administrator to create the user." + "ldapUserInEspoNotFound": "User is not found in EspoCRM. Contact your administrator to create the user.", + "passwordChangeRequestNotFound": "The password change request is not found. It might be expired. Try to initiate a new password recovery from the [login page]({url})." }, "options": { "gender": { diff --git a/application/Espo/Resources/templates/accessInfo/cs_CZ/body.tpl b/application/Espo/Resources/templates/accessInfo/cs_CZ/body.tpl index 3955acca33..85e1e7d678 100644 --- a/application/Espo/Resources/templates/accessInfo/cs_CZ/body.tpl +++ b/application/Espo/Resources/templates/accessInfo/cs_CZ/body.tpl @@ -1,6 +1,6 @@
Přihlašovací jméno: {{userName}}
-Heslo: {{password}}
+{{#if password}}Heslo: {{password}}{{/if}}
\ No newline at end of file diff --git a/application/Espo/Resources/templates/accessInfo/da_DK/body.tpl b/application/Espo/Resources/templates/accessInfo/da_DK/body.tpl index 1a519584bd..cde2dda800 100644 --- a/application/Espo/Resources/templates/accessInfo/da_DK/body.tpl +++ b/application/Espo/Resources/templates/accessInfo/da_DK/body.tpl @@ -1,6 +1,6 @@Brugernavn: {{userName}}
-Kodeord: {{password}}
+{{#if password}}Kodeord: {{password}}{{/if}}
\ No newline at end of file diff --git a/application/Espo/Resources/templates/accessInfo/de_DE/body.tpl b/application/Espo/Resources/templates/accessInfo/de_DE/body.tpl index 5ac61fd432..19fcbbab81 100644 --- a/application/Espo/Resources/templates/accessInfo/de_DE/body.tpl +++ b/application/Espo/Resources/templates/accessInfo/de_DE/body.tpl @@ -1,6 +1,6 @@Benutzername: {{userName}}
-Passwort: {{password}}
+{{#if password}}Passwort: {{password}}{{/if}}
\ No newline at end of file diff --git a/application/Espo/Resources/templates/accessInfo/en_US/body.tpl b/application/Espo/Resources/templates/accessInfo/en_US/body.tpl index 34bd1f5872..448f0ebe0c 100644 --- a/application/Espo/Resources/templates/accessInfo/en_US/body.tpl +++ b/application/Espo/Resources/templates/accessInfo/en_US/body.tpl @@ -1,6 +1,6 @@Username: {{userName}}
-Password: {{password}}
+{{#if password}}Password: {{password}}{{/if}}
\ No newline at end of file diff --git a/application/Espo/Resources/templates/accessInfo/es_ES/body.tpl b/application/Espo/Resources/templates/accessInfo/es_ES/body.tpl index e74f8b2fa6..59bfbeb470 100644 --- a/application/Espo/Resources/templates/accessInfo/es_ES/body.tpl +++ b/application/Espo/Resources/templates/accessInfo/es_ES/body.tpl @@ -1,6 +1,6 @@Nombre Usuario: {{userName}}
-Contraseña: {{password}}
+{{#if password}}Contraseña: {{password}}{{/if}}
\ No newline at end of file diff --git a/application/Espo/Resources/templates/accessInfo/es_MX/body.tpl b/application/Espo/Resources/templates/accessInfo/es_MX/body.tpl index e74f8b2fa6..59bfbeb470 100644 --- a/application/Espo/Resources/templates/accessInfo/es_MX/body.tpl +++ b/application/Espo/Resources/templates/accessInfo/es_MX/body.tpl @@ -1,6 +1,6 @@Nombre Usuario: {{userName}}
-Contraseña: {{password}}
+{{#if password}}Contraseña: {{password}}{{/if}}
\ No newline at end of file diff --git a/application/Espo/Resources/templates/accessInfo/fr_FR/body.tpl b/application/Espo/Resources/templates/accessInfo/fr_FR/body.tpl index 89c46705dd..5036df7153 100644 --- a/application/Espo/Resources/templates/accessInfo/fr_FR/body.tpl +++ b/application/Espo/Resources/templates/accessInfo/fr_FR/body.tpl @@ -1,6 +1,6 @@Nom d'utilisateur: {{userName}}
-Mot de passe: {{password}}
+{{#if password}}Mot de passe: {{password}}{{/if}}
\ No newline at end of file diff --git a/application/Espo/Resources/templates/accessInfo/hr_HR/body.tpl b/application/Espo/Resources/templates/accessInfo/hr_HR/body.tpl index 67055f556a..dcf1dcc1e1 100644 --- a/application/Espo/Resources/templates/accessInfo/hr_HR/body.tpl +++ b/application/Espo/Resources/templates/accessInfo/hr_HR/body.tpl @@ -1,6 +1,6 @@Korisničko ime: {{userName}}
-Lozinka: {{password}}
+{{#if password}}Lozinka: {{password}}{{/if}}
\ No newline at end of file diff --git a/application/Espo/Resources/templates/accessInfo/hu_HU/body.tpl b/application/Espo/Resources/templates/accessInfo/hu_HU/body.tpl index f08b4b8eed..ad596d6f11 100644 --- a/application/Espo/Resources/templates/accessInfo/hu_HU/body.tpl +++ b/application/Espo/Resources/templates/accessInfo/hu_HU/body.tpl @@ -1,6 +1,6 @@Felhasználónév: {{userName}}
-Jelszó: {{password}}
+{{#if password}}Jelszó: {{password}}{{/if}}
\ No newline at end of file diff --git a/application/Espo/Resources/templates/accessInfo/id_ID/body.tpl b/application/Espo/Resources/templates/accessInfo/id_ID/body.tpl index 6541dd2dd7..043246b031 100644 --- a/application/Espo/Resources/templates/accessInfo/id_ID/body.tpl +++ b/application/Espo/Resources/templates/accessInfo/id_ID/body.tpl @@ -1,6 +1,6 @@Username: {{userName}}
-Password: {{password}}
+{{#if password}}Password: {{password}}{{/if}}
\ No newline at end of file diff --git a/application/Espo/Resources/templates/accessInfo/it_IT/body.tpl b/application/Espo/Resources/templates/accessInfo/it_IT/body.tpl index 4eb84ff622..416d204e28 100644 --- a/application/Espo/Resources/templates/accessInfo/it_IT/body.tpl +++ b/application/Espo/Resources/templates/accessInfo/it_IT/body.tpl @@ -1,6 +1,6 @@Username: {{userName}}
-Password: {{password}}
+{{#if password}}Password: {{password}}{{/if}}
\ No newline at end of file diff --git a/application/Espo/Resources/templates/accessInfo/lt_LT/body.tpl b/application/Espo/Resources/templates/accessInfo/lt_LT/body.tpl index a27fe3528f..675cbc9f29 100644 --- a/application/Espo/Resources/templates/accessInfo/lt_LT/body.tpl +++ b/application/Espo/Resources/templates/accessInfo/lt_LT/body.tpl @@ -1,6 +1,6 @@Vartotojo vardas: {{userName}}
-Slaptažodis: {{password}}
+{{#if password}}Slaptažodis: {{password}}{{/if}}
\ No newline at end of file diff --git a/application/Espo/Resources/templates/accessInfo/nb_NO/body.tpl b/application/Espo/Resources/templates/accessInfo/nb_NO/body.tpl index 2199f0a6cc..c1ee6eeac6 100644 --- a/application/Espo/Resources/templates/accessInfo/nb_NO/body.tpl +++ b/application/Espo/Resources/templates/accessInfo/nb_NO/body.tpl @@ -1,6 +1,6 @@Brukernavn: {{userName}}
-Passord: {{password}}
+{{#if password}}Passord: {{password}}{{/if}}
\ No newline at end of file diff --git a/application/Espo/Resources/templates/accessInfo/nl_NL/body.tpl b/application/Espo/Resources/templates/accessInfo/nl_NL/body.tpl index c9eea2520e..da266fdc10 100644 --- a/application/Espo/Resources/templates/accessInfo/nl_NL/body.tpl +++ b/application/Espo/Resources/templates/accessInfo/nl_NL/body.tpl @@ -1,6 +1,6 @@Gebruikersnaam: {{userName}}
-Wachtwoord: {{password}}
+{{#if password}}Wachtwoord: {{password}}{{/if}}
\ No newline at end of file diff --git a/application/Espo/Resources/templates/accessInfo/pl_PL/body.tpl b/application/Espo/Resources/templates/accessInfo/pl_PL/body.tpl index fa4e9ec0bf..c43edd3856 100644 --- a/application/Espo/Resources/templates/accessInfo/pl_PL/body.tpl +++ b/application/Espo/Resources/templates/accessInfo/pl_PL/body.tpl @@ -1,6 +1,6 @@Użytkownik: {{userName}}
-Hasło: {{password}}
+{{#if password}}Hasło: {{password}}{{/if}}
\ No newline at end of file diff --git a/application/Espo/Resources/templates/accessInfo/pt_BR/body.tpl b/application/Espo/Resources/templates/accessInfo/pt_BR/body.tpl index a6d72b6263..b9191dbf74 100644 --- a/application/Espo/Resources/templates/accessInfo/pt_BR/body.tpl +++ b/application/Espo/Resources/templates/accessInfo/pt_BR/body.tpl @@ -1,6 +1,6 @@Usuário: {{userName}}
-Senha: {{password}}
+{{#if password}}Senha: {{password}}{{/if}}
\ No newline at end of file diff --git a/application/Espo/Resources/templates/accessInfo/ro_RO/body.tpl b/application/Espo/Resources/templates/accessInfo/ro_RO/body.tpl index b5a767ba50..796efd091a 100644 --- a/application/Espo/Resources/templates/accessInfo/ro_RO/body.tpl +++ b/application/Espo/Resources/templates/accessInfo/ro_RO/body.tpl @@ -1,6 +1,6 @@Username: {{userName}}
-Password: {{password}}
+{{#if password}}Password: {{password}}{{/if}}
\ No newline at end of file diff --git a/application/Espo/Resources/templates/accessInfo/ru_RU/body.tpl b/application/Espo/Resources/templates/accessInfo/ru_RU/body.tpl index 7c14edd2bf..4a1e190fe7 100644 --- a/application/Espo/Resources/templates/accessInfo/ru_RU/body.tpl +++ b/application/Espo/Resources/templates/accessInfo/ru_RU/body.tpl @@ -1,6 +1,6 @@Имя пользователя: {{userName}}
-Пароль: {{password}}
+{{#if password}}Пароль: {{password}}{{/if}}
\ No newline at end of file diff --git a/application/Espo/Resources/templates/accessInfo/sk_SK/body.tpl b/application/Espo/Resources/templates/accessInfo/sk_SK/body.tpl index adddb0b047..586772f746 100644 --- a/application/Espo/Resources/templates/accessInfo/sk_SK/body.tpl +++ b/application/Espo/Resources/templates/accessInfo/sk_SK/body.tpl @@ -1,6 +1,6 @@Username: {{userName}}
-Password: {{password}}
+{{#if password}}Password: {{password}}{{/if}}
\ No newline at end of file diff --git a/application/Espo/Resources/templates/accessInfo/sr_RS/body.tpl b/application/Espo/Resources/templates/accessInfo/sr_RS/body.tpl index 67055f556a..dcf1dcc1e1 100644 --- a/application/Espo/Resources/templates/accessInfo/sr_RS/body.tpl +++ b/application/Espo/Resources/templates/accessInfo/sr_RS/body.tpl @@ -1,6 +1,6 @@Korisničko ime: {{userName}}
-Lozinka: {{password}}
+{{#if password}}Lozinka: {{password}}{{/if}}
\ No newline at end of file diff --git a/application/Espo/Resources/templates/accessInfo/tr_TR/body.tpl b/application/Espo/Resources/templates/accessInfo/tr_TR/body.tpl index fce17784aa..252f5b0fcd 100644 --- a/application/Espo/Resources/templates/accessInfo/tr_TR/body.tpl +++ b/application/Espo/Resources/templates/accessInfo/tr_TR/body.tpl @@ -1,4 +1,4 @@Username: {{userName}}
-Password: {{password}}
+{{#if password}}Password: {{password}}{{/if}}
\ No newline at end of file diff --git a/application/Espo/Resources/templates/accessInfo/uk_UA/body.tpl b/application/Espo/Resources/templates/accessInfo/uk_UA/body.tpl index d391b134ed..e53880e276 100644 --- a/application/Espo/Resources/templates/accessInfo/uk_UA/body.tpl +++ b/application/Espo/Resources/templates/accessInfo/uk_UA/body.tpl @@ -1,6 +1,6 @@Ім'я користувача: {{userName}}
-Пароль: {{password}}
+{{#if password}}Пароль: {{password}}{{/if}}
\ No newline at end of file diff --git a/application/Espo/Resources/templates/accessInfo/vi_VN/body.tpl b/application/Espo/Resources/templates/accessInfo/vi_VN/body.tpl index fce17784aa..252f5b0fcd 100644 --- a/application/Espo/Resources/templates/accessInfo/vi_VN/body.tpl +++ b/application/Espo/Resources/templates/accessInfo/vi_VN/body.tpl @@ -1,4 +1,4 @@Username: {{userName}}
-Password: {{password}}
+{{#if password}}Password: {{password}}{{/if}}
\ No newline at end of file diff --git a/application/Espo/Resources/templates/accessInfo/zh_CN/body.tpl b/application/Espo/Resources/templates/accessInfo/zh_CN/body.tpl index d640c323aa..3edb0a22fd 100644 --- a/application/Espo/Resources/templates/accessInfo/zh_CN/body.tpl +++ b/application/Espo/Resources/templates/accessInfo/zh_CN/body.tpl @@ -1,6 +1,6 @@用户名: {{userName}}
-密码: {{password}}
+{{#if password}}密码: {{password}}{{/if}}
\ No newline at end of file diff --git a/application/Espo/Resources/templates/accessInfoPortal/de_DE/body.tpl b/application/Espo/Resources/templates/accessInfoPortal/de_DE/body.tpl index da78f47a44..beae4b309a 100644 --- a/application/Espo/Resources/templates/accessInfoPortal/de_DE/body.tpl +++ b/application/Espo/Resources/templates/accessInfoPortal/de_DE/body.tpl @@ -1,7 +1,7 @@Benutzername: {{userName}}
-Passwort: {{password}}
+{{#if password}}Passwort: {{password}}{{/if}}
{{#each siteUrlList}} diff --git a/application/Espo/Resources/templates/accessInfoPortal/en_US/body.tpl b/application/Espo/Resources/templates/accessInfoPortal/en_US/body.tpl index 5eb1c7ff4b..d019d9fbf2 100644 --- a/application/Espo/Resources/templates/accessInfoPortal/en_US/body.tpl +++ b/application/Espo/Resources/templates/accessInfoPortal/en_US/body.tpl @@ -1,7 +1,7 @@Username: {{userName}}
-Password: {{password}}
+{{#if password}}Password: {{password}}{{/if}}
{{#each siteUrlList}} diff --git a/application/Espo/Resources/templates/accessInfoPortal/es_ES/body.tpl b/application/Espo/Resources/templates/accessInfoPortal/es_ES/body.tpl index 30f9d1c3ec..15b6e355af 100644 --- a/application/Espo/Resources/templates/accessInfoPortal/es_ES/body.tpl +++ b/application/Espo/Resources/templates/accessInfoPortal/es_ES/body.tpl @@ -1,7 +1,7 @@Nombre Usuario: {{userName}}
-Contraseña: {{password}}
+{{#if password}}Contraseña: {{password}}{{/if}}
{{#each siteUrlList}} diff --git a/application/Espo/Services/User.php b/application/Espo/Services/User.php index c4399efc4f..df9cf19962 100644 --- a/application/Espo/Services/User.php +++ b/application/Espo/Services/User.php @@ -31,11 +31,18 @@ namespace Espo\Services; use Espo\Entities\User as UserEntity; use Espo\Entities\Email as EmailEntity; +use Espo\Entities\PasswordChangeRequest; +use Espo\Entities\Portal as PortalEntity; + +use Espo\Repositories\Portal as PortalRepository; + +use Espo\Core\Mail\Sender; use Espo\Core\{ Exceptions\Forbidden, Exceptions\Error, Exceptions\NotFound, + Exceptions\BadRequest, Utils\Util, Utils\PasswordHash, Utils\ApiKey as ApiKeyUtil, @@ -122,13 +129,11 @@ class User extends Record implements } if ($checkCurrentPassword) { - $passwordHash = new PasswordHash($this->getConfig()); - $u = $this->getEntityManager() ->getRDBRepository('User') ->where([ 'id' => $user->getId(), - 'password' => $passwordHash->hash($currentPassword), + 'password' => $this->createPasswordHashUtil()->hash($currentPassword), ]) ->findOne(); @@ -211,16 +216,19 @@ class User extends Record implements return true; } + private function createRecoveryService(): Recovery + { + return $this->injectableFactory->create(Recovery::class); + } + public function passwordChangeRequest(string $userName, string $emailAddress, ?string $url = null): void { - $recovery = $this->injectableFactory->create(Recovery::class); - - $recovery->request($emailAddress, $userName, $url); + $this->createRecoveryService()->request($emailAddress, $userName, $url); } public function changePasswordByRequest(string $requestId, string $password): stdClass { - $recovery = $this->injectableFactory->create(Recovery::class); + $recovery = $this->createRecoveryService(); $request = $recovery->getRequest($requestId); @@ -247,7 +255,7 @@ class User extends Record implements $id = $data->id; - $p = $this->getEntityManager()->getEntity('PasswordChangeRequest', $id); + $p = $this->getEntityManager()->getEntity(PasswordChangeRequest::ENTITY_TYPE, $id); if ($p) { $this->getEntityManager()->removeEntity($p); @@ -280,11 +288,17 @@ class User extends Record implements public function create(stdClass $data, CreateParams $params): Entity { - $newPassword = null; + $newPassword = $data->password ?? null; - if (property_exists($data, 'password')) { - $newPassword = $data->password; + if ($newPassword === '') { + $newPassword = null; + } + if ($newPassword !== null && !is_string($newPassword)) { + throw new BadRequest(); + } + + if ($newPassword !== null) { if (!$this->checkPasswordStrength($newPassword)) { throw new Forbidden("Password is weak."); } @@ -295,13 +309,23 @@ class User extends Record implements /** @var UserEntity $user */ $user = parent::create($data, $params); - if (!is_null($newPassword) && !empty($data->sendAccessInfo)) { - if ($user->isActive()) { - try { - $this->sendPassword($user, $newPassword); - } - catch (Exception $e) {} + $sendAccessInfo = !empty($data->sendAccessInfo); + + if (!$sendAccessInfo || !$user->isActive() || $user->isApi()) { + return $user; + } + + try { + if ($newPassword !== null) { + $this->sendPassword($user, $newPassword); + + return $user; } + + $this->sendAccessInfoNew($user); + } + catch (Exception $e) { + $this->log->error("Could not send user access info. " . $e->getMessage()); } return $user; @@ -406,10 +430,67 @@ class User extends Record implements return $entity; } - public function generateNewPasswordForUser(string $id, bool $allowNonAdmin = false) + 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); + } + + public function generateNewPasswordForUser(string $id, bool $allowNonAdmin = false): void { if (!$allowNonAdmin) { - if (!$this->getUser()->isAdmin()) { + if (!$this->user->isAdmin()) { throw new Forbidden(); } } @@ -439,45 +520,34 @@ class User extends Record implements ); } - if (!$this->emailSender->hasSystemSmtp() && !$this->getConfig()->get('internalSmtpServer')) { + if (!$this->isSmtpConfigured()) { throw new Forbidden( "Generate new password: Can't process because SMTP is not configured." ); } - $length = $this->getConfig()->get('passwordStrengthLength'); - $letterCount = $this->getConfig()->get('passwordStrengthLetterCount'); - $numberCount = $this->getConfig()->get('passwordStrengthNumberCount'); - - $generateLength = $this->getConfig()->get('passwordGenerateLength', 10); - $generateLetterCount = $this->getConfig()->get('passwordGenerateLetterCount', 4); - $generateNumberCount = $this->getConfig()->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; - } - - $password = Util::generatePassword($length, $letterCount, $numberCount, true); + $password = $this->generatePassword(); $this->sendPassword($user, $password); - $passwordHash = new PasswordHash($this->getConfig()); + $this->saveUserPassword($user, $password); + } - $user->set('password', $passwordHash->hash($password)); + private function isSmtpConfigured(): bool + { + return $this->emailSender->hasSystemSmtp() || $this->config->get('internalSmtpServer'); + } - $this->getEntityManager()->saveEntity($user); + 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() @@ -619,7 +689,70 @@ class User extends Record implements } } - protected function sendPassword(UserEntity $user, $password) + /** + * @return array{?string,?string,?array{{complexText notFoundMessage}}
+ {{/unless}}