diff --git a/appinfo/info.xml b/appinfo/info.xml index bf39775b75ae0ef44c8dfdb474ea8ea983a7daa1..318e406401361ee2ff7a3a4f72b22868fd70b15e 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -20,4 +20,7 @@ OCA\EmailRecovery\BackgroundJob\WeeklyRecoveryNotificationJob + + OCA\EmailRecovery\Command\UpdateBlacklistedDomains + diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 332e94a7a7ada01a4cbee9ba5ec990e1954364ee..277036d79f41b6af0ea4927c17d09de91d8125f0 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -28,6 +28,8 @@ namespace OCA\EmailRecovery\AppInfo; use OCA\EmailRecovery\Listeners\BeforeTemplateRenderedListener; use OCA\EmailRecovery\Listeners\UserConfigChangedListener; +use OCA\EmailRecovery\Listeners\UserChangedListener; +use OCA\EmailRecovery\Listeners\BeforeUserRegisteredListener; use OCA\EmailRecovery\Notification\Notifier; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; @@ -36,6 +38,8 @@ use OCP\AppFramework\Bootstrap\IRegistrationContext; use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent; use OCP\Notification\IManager as INotificationManager; use OCP\User\Events\UserConfigChangedEvent; +use OCP\User\Events\UserChangedEvent; +use OCA\EcloudAccounts\Event\BeforeUserRegisteredEvent; class Application extends App implements IBootstrap { public const APP_ID = 'email-recovery'; @@ -45,8 +49,10 @@ class Application extends App implements IBootstrap { } public function register(IRegistrationContext $context): void { + $context->registerEventListener(BeforeUserRegisteredEvent::class, BeforeUserRegisteredListener::class); $context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class); $context->registerEventListener(UserConfigChangedEvent::class, UserConfigChangedListener::class); + $context->registerEventListener(UserChangedEvent::class, UserChangedListener::class); } public function boot(IBootContext $context): void { $context->injectFn([$this, 'registerNotifier']); diff --git a/lib/Command/UpdateBlacklistedDomains.php b/lib/Command/UpdateBlacklistedDomains.php new file mode 100644 index 0000000000000000000000000000000000000000..b0bbed834579126e66b6e8be992a2239a1b452d6 --- /dev/null +++ b/lib/Command/UpdateBlacklistedDomains.php @@ -0,0 +1,39 @@ +blackListService = $blackListService; + $this->logger = $logger; + } + + protected function configure() { + $this->setName(Application::APP_ID.':update-blacklisted-domains')->setDescription('Update blacklisted domains'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + try { + $this->blackListService->updateBlacklistedDomains(); + $output->writeln('Updated blacklisted domains for creation.'); + } catch (\Throwable $th) { + $this->logger->error('Error while updating blacklisted domains. ' . $th->getMessage()); + $output->writeln('Error while updating blacklisted domains. '. $th->getMessage()); + } + return 1; + } +} diff --git a/lib/Controller/EmailRecoveryController.php b/lib/Controller/EmailRecoveryController.php index d572ed5ab32ad5baf6cfd99be6dadb9c3611acec..d9230c65595f0a82aeded45ab35b6145bb2e4d9e 100644 --- a/lib/Controller/EmailRecoveryController.php +++ b/lib/Controller/EmailRecoveryController.php @@ -41,9 +41,8 @@ use OCP\ILogger; use OCP\IRequest; use OCP\IUserManager; use OCP\IUserSession; -use OCP\Security\ICrypto; use OCP\Security\VerificationToken\InvalidTokenException; -use OCP\Security\VerificationToken\IVerificationToken; +use OCA\EmailRecovery\AppInfo\Application; class EmailRecoveryController extends Controller { private IConfig $config; @@ -52,11 +51,9 @@ class EmailRecoveryController extends Controller { private IUserSession $userSession; private RecoveryEmailService $recoveryEmailService; private IUserManager $userManager; - private IVerificationToken $verificationToken; - private ICrypto $crypto; public function __construct( - string $appName, + string $appName, IRequest $request, IConfig $config, ILogger $logger, @@ -64,8 +61,6 @@ class EmailRecoveryController extends Controller { IUserSession $userSession, RecoveryEmailService $recoveryEmailService, IUserManager $userManager, - IVerificationToken $verificationToken, - ICrypto $crypto ) { parent::__construct($appName, $request); $this->config = $config; @@ -74,8 +69,6 @@ class EmailRecoveryController extends Controller { $this->userSession = $userSession; $this->recoveryEmailService = $recoveryEmailService; $this->userManager = $userManager; - $this->verificationToken = $verificationToken; - $this->crypto = $crypto; } /** @@ -85,8 +78,8 @@ class EmailRecoveryController extends Controller { public function getRecoveryEmail() { $response = new JSONResponse(); $userId = $this->userSession->getUser()->getUID(); - $recoveryEmail = $this->config->getUserValue($userId, $this->appName, 'recovery-email'); - $unverifiedRecoveryEmail = $this->config->getUserValue($userId, $this->appName, 'unverified-recovery-email'); + $recoveryEmail = $this->config->getUserValue($userId, Application::APP_ID, 'recovery-email'); + $unverifiedRecoveryEmail = $this->config->getUserValue($userId, Application::APP_ID, 'unverified-recovery-email'); $data = ["recoveryEmail" => $recoveryEmail, "unverifiedRecoveryEmail" => $unverifiedRecoveryEmail]; $response->setData($data); return $response; @@ -188,25 +181,14 @@ class EmailRecoveryController extends Controller { } } catch (Exception $e) { $response->setStatus(500); - if ($e instanceof InvalidRecoveryEmailException) { + if ($e instanceof InvalidRecoveryEmailException || + $e instanceof SameRecoveryEmailAsEmailException || + $e instanceof RecoveryEmailAlreadyFoundException || + $e instanceof MurenaDomainDisallowedException || + $e instanceof BlacklistedEmailException + ) { $response->setStatus(400); - $response->setData(['message' => $this->l->t('Invalid Recovery Email')]); - } - if ($e instanceof SameRecoveryEmailAsEmailException) { - $response->setStatus(400); - $response->setData(['message' => $this->l->t('Error! User email address cannot be saved as recovery email address!')]); - } - if ($e instanceof RecoveryEmailAlreadyFoundException) { - $response->setStatus(400); - $response->setData(['message' => $this->l->t('Recovery email address is already taken.')]); - } - if ($e instanceof MurenaDomainDisallowedException) { - $response->setStatus(400); - $response->setData(['message' => $this->l->t('You cannot set an email address with a Murena domain as recovery email address.')]); - } - if ($e instanceof BlacklistedEmailException) { - $response->setStatus(400); - $response->setData(['message' => $this->l->t('The domain of this email address is blacklisted. Please provide another recovery address.')]); + $response->setData(['message' => $e->getMessage()]); } } } diff --git a/lib/Listeners/BeforeUserRegisteredListener.php b/lib/Listeners/BeforeUserRegisteredListener.php new file mode 100644 index 0000000000000000000000000000000000000000..5e598bfcef53ac4fc5a82092d42e3ca1bf1e16ca --- /dev/null +++ b/lib/Listeners/BeforeUserRegisteredListener.php @@ -0,0 +1,44 @@ +recoveryEmailService = $recoveryEmailService; + } + + /** + * @param Event $event + */ + public function handle(Event $event): void { + if ($event instanceof BeforeUserRegisteredEvent) { + $this->validateRecoveryEmail($event->getRecoveryEmail(), $event->getLanguage()); + } + } + + /** + * @param string $recoveryEmail + * @param string $language + */ + protected function validateRecoveryEmail(string $recoveryEmail, string $language): void { + try { + $this->recoveryEmailService->validateRecoveryEmail('', $recoveryEmail, $language); + } catch (\Exception $e) { + throw new RecoveryEmailValidationException($e->getMessage()); + } + } +} diff --git a/lib/Listeners/UserChangedListener.php b/lib/Listeners/UserChangedListener.php new file mode 100644 index 0000000000000000000000000000000000000000..afc0558c5ceb643b45f25c17bb48b1d27ff6b24e --- /dev/null +++ b/lib/Listeners/UserChangedListener.php @@ -0,0 +1,38 @@ +LDAPConnectionService = $LDAPConnectionService; + } + + public function handle(Event $event): void { + if (!($event instanceof UserChangedEvent)) { + return; + } + + $feature = $event->getFeature(); + $user = $event->getUser(); + $username = $user->getUID(); + + if ($feature === self::RECOVERY_EMAIL_FEATURE) { + $recoveryEmail = $event->getValue(); + $recoveryEmailAttribute = [ + 'recoveryMailAddress' => $recoveryEmail + ]; + + $this->LDAPConnectionService->updateAttributesInLDAP($username, $recoveryEmailAttribute); + } + } +} diff --git a/lib/Service/BlackListService.php b/lib/Service/BlackListService.php new file mode 100644 index 0000000000000000000000000000000000000000..891cf62f9368b163a7c19b96fc40cc52a667e391 --- /dev/null +++ b/lib/Service/BlackListService.php @@ -0,0 +1,138 @@ +logger = $logger; + $this->l10nFactory = $l10nFactory; + $this->LDAPConnectionService = $LDAPConnectionService; + $this->appData = $appData; + $this->appName = $appName; + } + + public function mapActiveAttributesInLDAP(string $username, bool $isEnabled): void { + $userActiveAttributes = $this->getActiveAttributes($isEnabled); + $this->LDAPConnectionService->updateAttributesInLDAP($username, $userActiveAttributes); + } + + private function getActiveAttributes(bool $isEnabled): array { + return [ + 'active' => $isEnabled ? 'TRUE' : 'FALSE', + 'mailActive' => $isEnabled ? 'TRUE' : 'FALSE', + ]; + } + /** + * Check if an email domain is blacklisted against a JSON list of disposable email domains. + * + * @param string $email The email address to check. + * @return bool True if the email domain is blacklisted, false otherwise. + */ + public function isBlacklistedEmail(string $email): bool { + if (!$this->ensureDocumentsFolder()) { + return false; + } + $blacklistedDomains = $this->getBlacklistedDomainData(); + if (empty($blacklistedDomains)) { + return false; + } + $emailParts = explode('@', $email); + $emailDomain = strtolower(end($emailParts)); + return in_array($emailDomain, $blacklistedDomains); + } + /** + * Update the blacklisted domains data by fetching it from a URL and saving it locally. + * + * @return void + */ + public function updateBlacklistedDomains(): void { + $blacklisted_domain_url = self::BLACKLISTED_DOMAINS_URL; + $json_data = file_get_contents($blacklisted_domain_url); + $this->setBlacklistedDomainsData($json_data); + } + /** + * Store blacklisted domain data in a file within AppData. + * + * @param string $data The data to be stored in the file. + * @return void + */ + private function setBlacklistedDomainsData(string $data): void { + $file = $this->getBlacklistedDomainsFile(); + $file->putContent($data); + } + /** + * Retrieve the blacklisted domain file path + * + * @return ISimpleFile + */ + private function getBlacklistedDomainsFile(): ISimpleFile { + try { + $currentFolder = $this->appData->getFolder('/'); + } catch (NotFoundException $e) { + $currentFolder = $this->appData->newFolder('/'); + } + $filename = self::BLACKLISTED_DOMAINS_FILE_NAME; + if ($currentFolder->fileExists($filename)) { + return $currentFolder->getFile($filename); + } + return $currentFolder->newFile($filename); + } + /** + * Retrieve the blacklisted domain data. + * + * @return array The array of blacklisted domains. + */ + public function getBlacklistedDomainData(): array { + $document = self::BLACKLISTED_DOMAINS_FILE_NAME; + $file = $this->getBlacklistedDomainsFile(); + try { + $blacklistedDomainsInJson = $file->getContent(); + if (empty($blacklistedDomainsInJson)) { + return []; + } + return json_decode($blacklistedDomainsInJson, true, 512, JSON_THROW_ON_ERROR); + } catch (NotFoundException $e) { + $this->logger->warning('Blacklisted domains file ' . $document . ' not found!'); + return []; + } catch (\Throwable $e) { + $this->logger->warning('Error decoding blacklisted domains file ' . $document . ': ' . $e->getMessage()); + return []; + } + } + + /** + * Ensure the specified folder exists within AppData. + * + * @return bool + */ + private function ensureDocumentsFolder(): bool { + try { + $this->appData->getFolder('/'); + } catch (NotFoundException $e) { + $this->logger->error($this->appName . ' AppData folder not found!'); + return false; + } catch (\RuntimeException $e) { + $this->logger->error($this->appName . ' AppData folder not found! Runtime Error: ' . $e->getMessage()); + return false; + } + return true; + } +} diff --git a/lib/Service/RecoveryEmailService.php b/lib/Service/RecoveryEmailService.php index 478d28bfaf16414bbd99fa2f804c32a1ee1b7f36..d6892c81cd2efd836d37d460b400a1abcb2579e9 100644 --- a/lib/Service/RecoveryEmailService.php +++ b/lib/Service/RecoveryEmailService.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace OCA\EmailRecovery\Service; use Exception; -use OCA\EcloudAccounts\Service\LDAPConnectionService; use OCA\EmailRecovery\Exception\BlacklistedEmailException; use OCA\EmailRecovery\Exception\InvalidRecoveryEmailException; use OCA\EmailRecovery\Exception\MurenaDomainDisallowedException; @@ -24,7 +23,8 @@ use OCP\Mail\IEMailTemplate; use OCP\Mail\IMailer; use OCP\Security\VerificationToken\IVerificationToken; use OCP\Util; -use OCA\EcloudAccounts\Service\BlackListService; +use OCP\IL10N; +use OCA\EcloudAccounts\Service\LDAPConnectionService; class RecoveryEmailService { private ILogger $logger; @@ -42,8 +42,9 @@ class RecoveryEmailService { protected const TOKEN_LIFETIME = 60 * 30; // 30 minutes private const ATTEMPT_KEY = "recovery_email_attempts"; private BlackListService $blackListService; + private IL10N $l; - public function __construct(string $appName, ILogger $logger, IConfig $config, LDAPConnectionService $LDAPConnectionService, ISession $session, IUserManager $userManager, IMailer $mailer, IFactory $l10nFactory, IURLGenerator $urlGenerator, Defaults $themingDefaults, IVerificationToken $verificationToken, CurlService $curlService, BlackListService $blackListService) { + public function __construct(string $appName, ILogger $logger, IConfig $config, LDAPConnectionService $LDAPConnectionService, ISession $session, IUserManager $userManager, IMailer $mailer, IFactory $l10nFactory, IURLGenerator $urlGenerator, Defaults $themingDefaults, IVerificationToken $verificationToken, CurlService $curlService, BlackListService $blackListService, IL10N $l) { $this->logger = $logger; $this->config = $config; $this->appName = $appName; @@ -57,6 +58,7 @@ class RecoveryEmailService { $this->verificationToken = $verificationToken; $this->curl = $curlService; $this->blackListService = $blackListService; + $this->l = $l; $commonServiceURL = $this->config->getSystemValue('common_services_url', ''); if (!empty($commonServiceURL)) { @@ -107,29 +109,33 @@ class RecoveryEmailService { return true; } - public function validateRecoveryEmail(string $username, string $recoveryEmail) : bool { - $user = $this->userManager->get($username); - $email = $user->getEMailAddress(); + public function validateRecoveryEmail(string $username = '', string $recoveryEmail, string $language = 'en') : bool { + $email = ''; + if ($username != '') { + $user = $this->userManager->get($username); + $email = $user->getEMailAddress(); + } + $l = $this->l10nFactory->get($this->appName, $language); if (!empty($recoveryEmail)) { - if (!filter_var($recoveryEmail, FILTER_VALIDATE_EMAIL)) { + if (!$this->isValidEmailFormat($recoveryEmail)) { $this->logger->info("User $username's requested recovery email does not match email format"); - throw new InvalidRecoveryEmailException(); + throw new InvalidRecoveryEmailException($l->t('Invalid Recovery Email')); } - if (strcmp($recoveryEmail, $email) === 0) { + if ($email != '' && strcmp($recoveryEmail, $email) === 0) { $this->logger->info("User ID $username's requested recovery email is the same as email"); - throw new SameRecoveryEmailAsEmailException(); + throw new SameRecoveryEmailAsEmailException($l->t('Error! User email address cannot be saved as recovery email address!')); } if ($this->isRecoveryEmailTaken($username, $recoveryEmail)) { $this->logger->info("User ID $username's requested recovery email address is already taken"); - throw new RecoveryEmailAlreadyFoundException(); + throw new RecoveryEmailAlreadyFoundException($l->t('Recovery email address is already taken.')); } if ($this->isRecoveryEmailDomainDisallowed($recoveryEmail)) { $this->logger->info("User ID $username's requested recovery email address is disallowed."); - throw new MurenaDomainDisallowedException(); + throw new MurenaDomainDisallowedException($l->t('You cannot set an email address with a Murena domain as recovery email address.')); } if ($this->blackListService->isBlacklistedEmail($recoveryEmail)) { $this->logger->info("User ID $username's requested recovery email address domain is blacklisted. Please provide another recovery address."); - throw new BlacklistedEmailException(); + throw new BlacklistedEmailException($l->t('The domain of this email address is blacklisted. Please provide another recovery address.')); } } return true; @@ -157,12 +163,12 @@ class RecoveryEmailService { return false; } - $usersWithEmailRecovery = $this->config->getUsersForUserValue('email-recovery', 'recovery-email', $recoveryEmail); + $usersWithEmailRecovery = $this->config->getUsersForUserValue($this->appName, 'recovery-email', $recoveryEmail); if (count($usersWithEmailRecovery)) { return true; } - $usersWithUnverifiedRecovery = $this->config->getUsersForUserValue('email-recovery', 'unverified-recovery-email', $recoveryEmail); + $usersWithUnverifiedRecovery = $this->config->getUsersForUserValue($this->appName, 'unverified-recovery-email', $recoveryEmail); if (count($usersWithUnverifiedRecovery)) { return true; } @@ -305,4 +311,14 @@ class RecoveryEmailService { $this->manageEmailRestriction($email, 'DELETE', $url); } + /** + * Check if a recovery email address is in valid format + * + * @param string $recoveryEmail The recovery email address to check. + * + * @return bool True if the recovery email address is valid, false otherwise. + */ + public function isValidEmailFormat(string $recoveryEmail): bool { + return filter_var($recoveryEmail, FILTER_VALIDATE_EMAIL) !== false; + } }