entityManager = $entityManager; $this->config = $config; $this->mailSender = $mailSender; $this->htmlizerFactory = $htmlizerFactory; $this->templateFileManager = $templateFileManager; } public function getRequest(string $id) : PasswordChangeRequest { $config = $this->config; $em = $this->entityManager; if ($config->get('passwordRecoveryDisabled')) { throw new Forbidden("Password recovery: Disabled."); } $request = $em->getRepository('PasswordChangeRequest')->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) { $em = $this->entityManager; $request = $em->getRepository('PasswordChangeRequest')->where([ 'requestId' => $id, ])->findOne(); if ($request) { $em->removeEntity($request); } } public function request(string $emailAddress, ?string $userName = null, ?string $url) : bool { $config = $this->config; $em = $this->entityManager; $noExposure = $config->get('passwordRecoveryNoExposure') ?? false; if ($config->get('passwordRecoveryDisabled')) { throw new Forbidden("Password recovery: Disabled."); } $user = $em->getRepository('User')->where([ 'userName' => $userName, 'emailAddress' => $emailAddress, ])->findOne(); if (!$user) { $this->fail("Password recovery: User {$emailAddress} not found.", 404); return false; } if (!$user->isActive()) { $this->fail("Password recovery: User {$user->id} is not active."); return false; } if ($user->isApi() || $user->isSystem() || $user->isSuperAdmin()) { $this->fail("Password recovery: User {$user->id} is not allowed."); return false; } if ($config->get('passwordRecoveryForInternalUsersDisabled')) { if ($user->isRegular() || $user->isAdmin()) { $this->fail("Password recovery: User {$user->id} is not allowed, disabled for internal users."); return false; } } if ($config->get('passwordRecoveryForAdminDisabled')) { if ($user->isAdmin()) { $this->fail("Password recovery: User {$user->id} is not allowed, disabled for admin users."); return false; } } if (!$user->isAdmin() && $config->get('authenticationMethod', 'Espo') !== 'Espo') { $this->fail("Password recovery: User {$user->id} is not allowed, authentication method is not 'Espo'."); return false; } $passwordChangeRequest = $em->getRepository('PasswordChangeRequest')->where([ 'userId' => $user->id, ])->findOne(); if ($passwordChangeRequest) { if (!$noExposure) { throw new Forbidden(json_encode(['reason' => 'Already-Sent'])); } $this->fail("Password recovery: Denied for {$user->id}, already sent."); return false; } $requestId = Util::generateCryptId(); $passwordChangeRequest = $em->getEntity('PasswordChangeRequest'); $passwordChangeRequest->set([ 'userId' => $user->id, 'requestId' => $requestId, 'url' => $url, ]); $microtime = microtime(true); $this->send($requestId, $emailAddress, $user); $em->saveEntity($passwordChangeRequest); if (!$passwordChangeRequest->id) throw new Error(); $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' => 'q1', ]); $timeDiff = $this->getDelay() - floor((microtime(true) - $microtime) / 1000); if ($noExposure && $timeDiff > 0) { $this->delay($timeDiff); } return true; } private function getDelay() { return $this->config->get('passwordRecoveryRequestDelay') ?? self::REQUEST_DELAY; } protected function delay(?int $delay = null) { $delay = $delay ?? $this->getDelay(); usleep($delay * 1000); } protected function send(string $requestId, string $emailAddress, User $user) { $config = $this->config; $em = $this->entityManager; $mailSender = $this->mailSender; $htmlizerFactory = $this->htmlizerFactory; $templateFileManager = $this->templateFileManager; if (!$emailAddress) return; $email = $em->getEntity('Email'); if (!$mailSender->hasSystemSmtp() && !$config->get('internalSmtpServer')) { throw new Error("Password recovery: SMTP credentials are not defined."); } $subjectTpl = $templateFileManager->getTemplate('passwordChangeLink', 'subject', 'User'); $bodyTpl = $templateFileManager->getTemplate('passwordChangeLink', 'body', 'User'); $siteUrl = $config->getSiteUrl(); if ($user->isPortal()) { $portal = $em->getRepository('Portal')->distinct()->join('users')->where([ 'isActive' => true, 'users.id' => $user->id, ])->findOne(); if ($portal) { if ($portal->get('customUrl')) { $siteUrl = $portal->get('customUrl'); } } } $data = []; $link = $siteUrl . '?entryPoint=changePassword&id=' . $requestId; $data['link'] = $link; $htmlizer = $htmlizerFactory->create(true); $subject = $htmlizer->render($user, $subjectTpl, null, $data, true); $body = $htmlizer->render($user, $bodyTpl, null, $data, true); $email->set([ 'subject' => $subject, 'body' => $body, 'to' => $emailAddress, 'isSystem' => true, ]); if ($mailSender->hasSystemSmtp()) { $mailSender->useGlobal(); } else { $mailSender->useSmtp([ '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('internalOutboundEmailFromAddress', $config->get('outboundEmailFromAddress')), ]); } $mailSender->send($email); } private function fail(?string $msg = null, int $errorCode = 403) { $config = $this->config; $noExposure = $config->get('passwordRecoveryNoExposure') ?? false; if ($msg) { $GLOBALS['log']->warning($msg); } if (!$noExposure) { if ($errorCode === 403) { throw new Forbidden(); } else if ($errorCode === 404) { throw new NotFound(); } else { throw new Error(); } } $this->delay(); } }