diff --git a/application/Espo/Core/Rebuild/Actions/AddSystemData.php b/application/Espo/Core/Rebuild/Actions/AddSystemData.php new file mode 100644 index 0000000000..eec0016ae5 --- /dev/null +++ b/application/Espo/Core/Rebuild/Actions/AddSystemData.php @@ -0,0 +1,52 @@ +entityManager->getEntityById(SystemData::ENTITY_TYPE, SystemData::ONLY_ID); + + if ($entity) { + return; + } + + $this->entityManager->createEntity(SystemData::ENTITY_TYPE, ['id' => SystemData::ONLY_ID]); + } +} diff --git a/application/Espo/Resources/defaults/systemConfig.php b/application/Espo/Resources/defaults/systemConfig.php index 099078d781..8ef82a4353 100644 --- a/application/Espo/Resources/defaults/systemConfig.php +++ b/application/Espo/Resources/defaults/systemConfig.php @@ -108,6 +108,7 @@ return [ 'passwordRecoveryRequestLifetime', 'passwordChangeRequestNewUserLifetime', 'passwordChangeRequestExistingUserLifetime', + 'passwordRecoveryInternalIntervalPeriod', ], 'adminItems' => [ 'devMode', diff --git a/application/Espo/Resources/i18n/en_US/Global.json b/application/Espo/Resources/i18n/en_US/Global.json index 22a029ce27..35988853ac 100644 --- a/application/Espo/Resources/i18n/en_US/Global.json +++ b/application/Espo/Resources/i18n/en_US/Global.json @@ -378,7 +378,8 @@ "cannotRelateForbiddenLink": "No access to link '{link}'.", "error404": "The url you requested can't be handled.", "error403": "You don't have an access to this area.", - "emptyMassUpdate": "No fields available for Mass Update." + "emptyMassUpdate": "No fields available for Mass Update.", + "attemptIntervalFailure": "The operation is not allowed during a specific time interval. Wait for some time before the next attempt." }, "boolFilters": { "onlyMy": "Only My", diff --git a/application/Espo/Tools/UserSecurity/Password/RecoveryService.php b/application/Espo/Tools/UserSecurity/Password/RecoveryService.php index f30997de1d..b93224fd9d 100644 --- a/application/Espo/Tools/UserSecurity/Password/RecoveryService.php +++ b/application/Espo/Tools/UserSecurity/Password/RecoveryService.php @@ -41,6 +41,7 @@ use Espo\Core\Mail\Exceptions\SendingError; use Espo\Core\Mail\SmtpParams; 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; @@ -63,6 +64,7 @@ class RecoveryService 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, @@ -233,7 +235,9 @@ class RecoveryService $this->send($request->getRequestId(), $emailAddress, $user); } catch (SendingError $e) { - $this->log->error("Email sending error: " . $e->getMessage()); + $message = "Email sending error. " . $e->getMessage(); + + $this->log->error($message); throw new Error("Email sending error."); } @@ -289,6 +293,7 @@ class RecoveryService /** * @throws Error + * @throws Forbidden */ public function createAndSendRequestForExistingUser(User $user, ?string $url = null): PasswordChangeRequest { @@ -314,7 +319,9 @@ class RecoveryService $this->send($entity->getRequestId(), $emailAddress, $user); } catch (SendingError $e) { - throw new Error("Email sending error. " . $e->getMessage()); + $this->log->error("Email sending error. " . $e->getMessage()); + + throw new Error("Email sending error."); } return $entity; @@ -369,6 +376,7 @@ class RecoveryService /** * @throws Error * @throws SendingError + * @throws Forbidden */ private function send(string $requestId, string $emailAddress, User $user): void { @@ -383,6 +391,10 @@ class RecoveryService 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'); @@ -440,7 +452,7 @@ class RecoveryService $port = $this->config->get('internalSmtpPort'); if (!$server || $port === null) { - throw new NoSmtp(); + throw new NoSmtp("No internal SMTP"); } $smtpParams = SmtpParams @@ -458,6 +470,8 @@ class RecoveryService } $sender->send($email); + + $this->lastPasswordRecoveryDate(); } /** @@ -495,4 +509,55 @@ class RecoveryService /** @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()->getString()); + + $this->entityManager->saveEntity($data); + } }