Files
espocrm/application/Espo/Tools/UserSecurity/Password/RecoveryService.php
bsiggel 127fa6503b chore: Update copyright year from 2025 to 2026 across core files
- Updated copyright headers in 3,055 core application files
- Changed 'Copyright (C) 2014-2025' to 'Copyright (C) 2014-2026'
- Added 123 new files from EspoCRM core updates
- Removed 4 deprecated files
- Total changes: 61,637 insertions, 54,283 deletions

This is a routine maintenance update for the new year 2026.
2026-02-07 16:05:21 +01:00

569 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2026 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://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 Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero 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\Ldap\LdapLogin;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\NotFound;
use Espo\Core\Exceptions\Error;
use Espo\Core\ApplicationState;
use Espo\Core\Authentication\Util\MethodProvider as AuthenticationMethodProvider;
use Espo\Core\Exceptions\ForbiddenSilent;
use Espo\Core\Job\JobSchedulerFactory;
use Espo\Core\Mail\Exceptions\NoSmtp;
use Espo\Core\Mail\Exceptions\SendingError;
use Espo\Core\Mail\SmtpParams;
use Espo\Core\Utils\Config\ApplicationConfig;
use Espo\Core\Utils\Util;
use Espo\Entities\Email;
use Espo\Entities\SystemData;
use Espo\Entities\User;
use Espo\Entities\PasswordChangeRequest;
use Espo\Entities\Portal;
use Espo\Repositories\Portal as PortalRepository;
use Espo\Core\Field\DateTime;
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;
use Espo\Tools\UserSecurity\Password\Recovery\UrlValidator;
class RecoveryService
{
/** 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';
private const INTERNAL_SMTP_INTERVAL_PERIOD = '1 hour';
public function __construct(
private EntityManager $entityManager,
private Config $config,
private EmailSender $emailSender,
private HtmlizerFactory $htmlizerFactory,
private TemplateFileManager $templateFileManager,
private Log $log,
private JobSchedulerFactory $jobSchedulerFactory,
private ApplicationState $applicationState,
private AuthenticationMethodProvider $authenticationMethodProvider,
private UrlValidator $urlValidator,
private ApplicationConfig $applicationConfig,
) {}
/**
* @throws Forbidden
* @throws Error
* @throws NotFound
*/
public function getRequest(string $id): PasswordChangeRequest
{
$config = $this->config;
if ($config->get('passwordRecoveryDisabled')) {
throw new Forbidden("Password recovery: Disabled.");
}
$request = $this->entityManager
->getRDBRepositoryByClass(PasswordChangeRequest::class)
->where(['requestId' => $id])
->findOne();
if (!$request) {
throw new NotFound("Password recovery: Request not found by id.");
}
$userId = $request->get('userId');
if (!$userId) {
throw new Error();
}
return $request;
}
public function removeRequest(string $id): void
{
$request = $this->entityManager
->getRDBRepositoryByClass(PasswordChangeRequest::class)
->where(['requestId' => $id])
->findOne();
if ($request) {
$this->entityManager->removeEntity($request);
}
}
/**
* @throws Forbidden
* @throws NotFound
* @throws Error
*/
public function request(string $emailAddress, string $userName, ?string $url): bool
{
$config = $this->config;
$noExposure = $config->get('passwordRecoveryNoExposure') ?? false;
if ($config->get('passwordRecoveryDisabled')) {
throw new Forbidden("Password recovery: Disabled.");
}
if ($url) {
$this->urlValidator->validate($url);
}
$user = $this->entityManager
->getRDBRepositoryByClass(User::class)
->where([
'userName' => $userName,
'emailAddress' => $emailAddress,
])
->findOne();
if (!$user) {
$this->fail("User $emailAddress not found.", 404);
return false;
}
$userId = $user->getId();
if (!$user->isActive()) {
$this->fail("User $userId is not active.");
return false;
}
if (
!$user->isAdmin() &&
$this->authenticationMethodProvider->get() !== EspoLogin::NAME &&
!$this->isPortalLdapDisabled()
) {
$this->fail("User $userId is not allowed, authentication method is not 'Espo'.");
return false;
}
if ($user->isApi() || $user->isSystem() || $user->isSuperAdmin()) {
$this->fail("User $userId is not allowed.");
return false;
}
if ($config->get('passwordRecoveryForInternalUsersDisabled')) {
if ($user->isRegular() || $user->isAdmin()) {
$this->fail("User $userId is not allowed, disabled for internal users.");
return false;
}
}
if ($config->get('passwordRecoveryForAdminDisabled')) {
if ($user->isAdmin()) {
$this->fail("User $userId is not allowed, disabled for admin users.");
return false;
}
}
if ($this->applicationState->isPortal()) {
if (!$user->isPortal()) {
$this->fail("User $userId is not allowed, as it's not portal user.");
return false;
}
$portalId = $this->applicationState->getPortalId();
if (!$user->getPortals()->hasId($portalId)) {
$this->fail("User $userId is from another portal.");
return false;
}
}
$existingRequest = $this->entityManager
->getRDBRepositoryByClass(PasswordChangeRequest::class)
->where(['userId' => $user->getId()])
->findOne();
if ($existingRequest) {
if (!$noExposure) {
throw new ForbiddenSilent('Already-Sent');
}
$this->fail("Denied for $userId, already sent.");
return false;
}
$request = $this->createRequestNoSave($user, $url);
$microtime = microtime(true);
try {
$this->send($request->getRequestId(), $emailAddress, $user);
} catch (SendingError $e) {
$message = "Email sending error. " . $e->getMessage();
$this->log->error($message);
throw new Error("Email sending error.");
}
$this->entityManager->saveEntity($request);
$lifetime = $config->get('passwordRecoveryRequestLifetime') ?? self::REQUEST_LIFETIME;
$this->createCleanupRequestJob($request->getId(), $lifetime);
$timeDiff = $this->getDelay() - floor((microtime(true) - $microtime) / 1000);
if ($noExposure && $timeDiff > 0) {
$this->delay((int) $timeDiff);
}
return true;
}
/**
* @throws Error
*/
private function createRequestNoSave(User $user, ?string $url = null): PasswordChangeRequest
{
$this->checkUser($user);
$entity = $this->entityManager->getRDBRepositoryByClass(PasswordChangeRequest::class)->getNew();
$entity->set([
'userId' => $user->getId(),
'requestId' => Util::generateCryptId(),
'url' => $url,
]);
return $entity;
}
/**
* @throws Error
*/
public function createRequestForNewUser(User $user, ?string $url = null): PasswordChangeRequest
{
$this->checkUser($user);
$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;
}
/**
* @throws Error
* @throws Forbidden
*/
public function createAndSendRequestForExistingUser(User $user, ?string $url = null): PasswordChangeRequest
{
$this->checkUser($user);
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);
try {
$this->send($entity->getRequestId(), $emailAddress, $user);
} catch (SendingError $e) {
$this->log->error("Email sending error. " . $e->getMessage());
throw new Error("Email sending error.");
}
return $entity;
}
/**
* @throws Error
*/
private function checkUser(User $user): void
{
if (
!$user->isActive() ||
!(
$user->isAdmin() ||
$user->isRegular() ||
$user->isPortal()
)
) {
throw new Error("User is not allowed for password change request.");
}
}
private function createCleanupRequestJob(string $id, string $lifetime): void
{
$this->jobSchedulerFactory
->create()
->setClassName(RemoveRecoveryRequest::class)
->setData(['id' => $id])
->setTime(
DateTime::createNow()
->modify('+' . $lifetime)
->toDateTime()
)
->setQueue(QueueName::Q1)
->schedule();
}
private function getDelay(): int
{
return $this->config->get('passwordRecoveryRequestDelay') ?? self::REQUEST_DELAY;
}
private function delay(?int $delay = null): void
{
if ($delay === null) {
$delay = $this->getDelay();
}
usleep($delay * 1000);
}
/**
* @throws Error
* @throws SendingError
* @throws Forbidden
*/
private function send(string $requestId, string $emailAddress, User $user): void
{
if (!$emailAddress) {
return;
}
$email = $this->entityManager->getRDBRepositoryByClass(Email::class)->getNew();
if (!$this->emailSender->hasSystemSmtp() && !$this->config->get('internalSmtpServer')) {
throw new Error("Password recovery: SMTP credentials are not defined.");
}
if (!$this->emailSender->hasSystemSmtp()) {
$this->checkIntervalForInternalSmtp();
}
$sender = $this->emailSender->create();
$subjectTpl = $this->templateFileManager->getTemplate('passwordChangeLink', 'subject', 'User');
$bodyTpl = $this->templateFileManager->getTemplate('passwordChangeLink', 'body', 'User');
$siteUrl = $this->applicationConfig->getSiteUrl();
if ($user->isPortal()) {
$portal = $this->entityManager
->getRDBRepositoryByClass(Portal::class)
->distinct()
->join('users')
->where([
'isActive' => true,
'users.id' => $user->getId(),
])
->findOne();
if (!$portal) {
throw new Error("Portal user does not belong to any portal.");
}
$this->getPortalRepository()->loadUrlField($portal);
$siteUrl = $portal->getUrl();
if (!$siteUrl) {
throw new Error("Portal does not have URL.");
}
}
$data = [];
$link = $siteUrl . '?entryPoint=changePassword&id=' . $requestId;
$data['link'] = $link;
$htmlizer = $this->htmlizerFactory->create(true);
$subject = $htmlizer->render($user, $subjectTpl, null, $data, true);
$body = $htmlizer->render($user, $bodyTpl, null, $data, true);
$email
->setSubject($subject)
->setBody($body)
->addToAddress($emailAddress);
$email->set([
'isSystem' => true,
]);
if (!$this->emailSender->hasSystemSmtp()) {
$server = $this->config->get('internalSmtpServer');
$port = $this->config->get('internalSmtpPort');
if (!$server || $port === null) {
throw new NoSmtp("No internal SMTP");
}
$smtpParams = SmtpParams::create($server, $port)
->withAuth($this->config->get('internalSmtpAuth'))
->withUsername($this->config->get('internalSmtpUsername'))
->withPassword($this->config->get('internalSmtpPassword'))
->withSecurity($this->config->get('internalSmtpSecurity'))
->withFromName(
$this->config->get('outboundEmailFromName')
);
$sender->withSmtpParams($smtpParams);
}
$sender->send($email);
$this->lastPasswordRecoveryDate();
}
/**
* @throws Forbidden
* @throws Error
* @throws NotFound
*/
private function fail(?string $msg = null, int $errorCode = 403): void
{
$noExposure = $this->config->get('passwordRecoveryNoExposure') ?? false;
if ($msg) {
$msg = 'Password recovery: ' . $msg;
$this->log->warning($msg);
}
if (!$noExposure) {
if ($errorCode === 403) {
throw new Forbidden();
}
if ($errorCode === 404) {
throw new NotFound();
}
throw new Error();
}
$this->delay();
}
private function getPortalRepository(): PortalRepository
{
/** @var PortalRepository */
return $this->entityManager->getRDBRepository(Portal::ENTITY_TYPE);
}
/**
* @throws Forbidden
*/
private function checkIntervalForInternalSmtp(): void
{
/** @var string $period */
$period = $this->config->get('passwordRecoveryInternalIntervalPeriod') ??
self::INTERNAL_SMTP_INTERVAL_PERIOD;
$data = $this->entityManager->getEntityById(SystemData::ENTITY_TYPE, SystemData::ONLY_ID);
if (!$data) {
return;
}
/** @var ?string $lastPasswordRecoveryDate */
$lastPasswordRecoveryDate = $data->get('lastPasswordRecoveryDate');
if (!$lastPasswordRecoveryDate) {
return;
}
$notPassed = DateTime::fromString($lastPasswordRecoveryDate)
->modify('+' . $period)
->isGreaterThan(DateTime::createNow());
if (!$notPassed) {
return;
}
throw Forbidden::createWithBody(
'Internal password recovery attempt interval failure.',
Error\Body::create()
->withMessageTranslation('attemptIntervalFailure')
->encode()
);
}
private function lastPasswordRecoveryDate(): void
{
$data = $this->entityManager->getEntityById(SystemData::ENTITY_TYPE, SystemData::ONLY_ID);
if (!$data) {
return;
}
$data->set('lastPasswordRecoveryDate', DateTime::createNow()->toString());
$this->entityManager->saveEntity($data);
}
private function isPortalLdapDisabled(): bool
{
return $this->applicationState->isPortal() &&
$this->authenticationMethodProvider->get() === LdapLogin::NAME &&
!$this->config->get('ldapPortalUserLdapAuth');
}
}