user password ref

This commit is contained in:
Yuri Kuznetsov
2022-10-18 20:29:23 +03:00
parent d3e65f9ba1
commit b43d9f8784
12 changed files with 1060 additions and 594 deletions
@@ -40,7 +40,6 @@ use Espo\Entities\PasswordChangeRequest;
class PasswordChangeRequests implements Cleanup
{
private Config $config;
private EntityManager $entityManager;
private string $cleanupPeriod = '30 days';
+79 -31
View File
@@ -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 */
@@ -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);
}
}
+47 -38
View File
@@ -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
@@ -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;
}
}
+42 -516
View File
@@ -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<string,mixed>}
*/
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
@@ -0,0 +1,108 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM - Open Source CRM application.
* Copyright (C) 2014-2022 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.
************************************************************************/
namespace Espo\Tools\UserSecurity\Password;
use Espo\Core\Utils\Config;
class Checker
{
private Config $config;
public function __construct(
Config $config
) {
$this->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;
}
}
@@ -0,0 +1,81 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM - Open Source CRM application.
* Copyright (C) 2014-2022 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.
************************************************************************/
namespace Espo\Tools\UserSecurity\Password;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Util;
/**
* A password generator.
*
* @todo Use an interface with binding.
*/
class Generator
{
private Config $config;
public function __construct(
Config $config
) {
$this->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);
}
}
@@ -0,0 +1,63 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM - Open Source CRM application.
* Copyright (C) 2014-2022 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.
************************************************************************/
namespace Espo\Tools\UserSecurity\Password\Jobs;
use Espo\Core\Job\Job;
use Espo\Core\Job\Job\Data;
use Espo\Entities\PasswordChangeRequest;
use Espo\ORM\EntityManager;
use RuntimeException;
class RemoveRecoveryRequest implements Job
{
private EntityManager $entityManager;
public function __construct(EntityManager $entityManager)
{
$this->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);
}
}
@@ -0,0 +1,270 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM - Open Source CRM application.
* Copyright (C) 2014-2022 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.
************************************************************************/
namespace Espo\Tools\UserSecurity\Password;
use Espo\Core\Exceptions\Error;
use Espo\Core\Htmlizer\HtmlizerFactory;
use Espo\Core\Mail\EmailSender;
use Espo\Core\Mail\Exceptions\NoSmtp;
use Espo\Core\Mail\Exceptions\SendingError;
use Espo\Core\Mail\Sender as EmailSenderSender;
use Espo\Core\Mail\SmtpParams;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\TemplateFileManager;
use Espo\Entities\Email;
use Espo\Entities\PasswordChangeRequest;
use Espo\Entities\Portal;
use Espo\Entities\User;
use Espo\ORM\EntityManager;
use Espo\Repositories\Portal as PortalRepository;
class Sender
{
private Config $config;
private EmailSender $emailSender;
private EntityManager $entityManager;
private HtmlizerFactory $htmlizerFactory;
private TemplateFileManager $templateFileManager;
public function __construct(
Config $config,
EmailSender $emailSender,
EntityManager $entityManager,
HtmlizerFactory $htmlizerFactory,
TemplateFileManager $templateFileManager
) {
$this->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<string, mixed>}
*/
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')
);
}
}
@@ -0,0 +1,332 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM - Open Source CRM application.
* Copyright (C) 2014-2022 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.
************************************************************************/
namespace Espo\Tools\UserSecurity\Password;
use Espo\Core\Authentication\Logins\Espo;
use Espo\Core\Exceptions\Error;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\NotFound;
use Espo\Core\FieldValidation\FieldValidationManager;
use Espo\Core\Mail\EmailSender;
use Espo\Core\Mail\Exceptions\SendingError;
use Espo\Core\Password\Recovery;
use Espo\Core\Record\ServiceContainer;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\PasswordHash;
use Espo\Entities\User;
use Espo\ORM\EntityManager;
class Service
{
private User $user;
private ServiceContainer $serviceContainer;
private EmailSender $emailSender;
private Config $config;
private Generator $generator;
private Sender $sender;
private PasswordHash $passwordHash;
private EntityManager $entityManager;
private Recovery $recovery;
private FieldValidationManager $fieldValidationManager;
private Checker $checker;
public function __construct(
User $user,
ServiceContainer $serviceContainer,
EmailSender $emailSender,
Config $config,
Generator $generator,
Sender $sender,
PasswordHash $passwordHash,
EntityManager $entityManager,
Recovery $recovery,
FieldValidationManager $fieldValidationManager,
Checker $checker
) {
$this->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');
}
}
@@ -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()) {