diff --git a/lib/Command/CreatePopularDomain.php b/lib/Command/CreatePopularDomain.php new file mode 100644 index 0000000000000000000000000000000000000000..96d6d51fd8b37410c04603b6e1cbd4ff0a01761b --- /dev/null +++ b/lib/Command/CreatePopularDomain.php @@ -0,0 +1,57 @@ +domainService = $domainService; + $this->logger = $logger; + } + + protected function configure() { + $this + ->setName('emailrecovery:fetch-popular-domains') + ->setDescription('Fetch popular email domains and create a popular_domain.json file'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + try { + $domains = $this->domainService->fetchPopularDomains(); + $file = $this->getPopularDomainsFile(); + $file->putContent(json_encode($domains, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + $output->writeln('Popular domains list created successfully at ' . $filePath); + } catch (\Throwable $th) { + $this->logger->error('Error while fetching popular domains. ' . $th->getMessage()); + $output->writeln('Error while fetching popular domains: ' . $th->getMessage()); + return Command::FAILURE; + } + return Command::SUCCESS; + } + + private function getPopularDomainsFile(): \OCP\Files\ISimpleFile { + try { + $currentFolder = $this->appData->getFolder('/'); + } catch (NotFoundException $e) { + $currentFolder = $this->appData->newFolder('/'); + } + + $filename = self::POPULAR_DOMAINS_FILE_NAME; + if ($currentFolder->fileExists($filename)) { + return $currentFolder->getFile($filename); + } + + return $currentFolder->newFile($filename); + } +} diff --git a/lib/Service/BlackListService.php b/lib/Service/BlackListService.php index 020c58fac70e3e4112cb8aab125e17070d6d3f96..095f41bcd4b2498ac8ccf19cd99ae6a83709e06c 100644 --- a/lib/Service/BlackListService.php +++ b/lib/Service/BlackListService.php @@ -17,7 +17,7 @@ class BlackListService { private $appName; private const BLACKLISTED_DOMAINS_FILE_NAME = 'blacklisted_domains.json'; private const BLACKLISTED_DOMAINS_URL = 'https://raw.githubusercontent.com/disposable/disposable-email-domains/master/domains.json'; - + private const POPULAR_DOMAINS_FILE_NAME = 'domains.json'; public function __construct(string $appName, ILogger $logger, IFactory $l10nFactory, IAppData $appData) { $this->logger = $logger; @@ -120,4 +120,64 @@ class BlackListService { } return true; } + + /** + * Check if an domain is popular domain a JSON list of popular domains. + * + * @param string $email The email address to check. + * @return bool True if the email domain is popular, false otherwise. + */ + public function isPopularDomain(string $email): bool { + if (!$this->ensureDocumentsFolder()) { + return false; + } + $popularlistedDomains = $this->getPopularlistedDomainData(); + if (empty($popularlistedDomains)) { + return false; + } + $emailParts = explode('@', $email); + $emailDomain = strtolower(end($emailParts)); + return in_array($emailDomain, $popularlistedDomains); + } + + /** + * Retrieve the Popular domain file path + * + * @return ISimpleFile + */ + private function getPopularDomainsFile(): ISimpleFile { + try { + $currentFolder = $this->appData->getFolder('/'); + } catch (NotFoundException $e) { + $currentFolder = $this->appData->newFolder('/'); + } + $filename = self::POPULAR_DOMAINS_FILE_NAME; + if ($currentFolder->fileExists($filename)) { + return $currentFolder->getFile($filename); + } + return $currentFolder->newFile($filename); + } + + /** + * Retrieve the Popular domain data. + * + * @return array The array of popular domains. + */ + public function getPopularlistedDomainData(): array { + $document = self::POPULAR_DOMAINS_FILE_NAME; + $file = $this->getPopularDomainsFile(); + try { + $popularlistedDomainsInJson = $file->getContent(); + if (empty($popularlistedDomainsInJson)) { + return []; + } + return json_decode($popularlistedDomainsInJson, true, 512, JSON_THROW_ON_ERROR); + } catch (NotFoundException $e) { + $this->logger->warning('Popular domains file ' . $document . ' not found!'); + return []; + } catch (\Throwable $e) { + $this->logger->warning('Error decoding Popular domains file ' . $document . ': ' . $e->getMessage()); + return []; + } + } } diff --git a/lib/Service/PopularDomainService.php b/lib/Service/PopularDomainService.php new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/Service/RecoveryEmailService.php b/lib/Service/RecoveryEmailService.php index 0fe18447e926ecdf79a2c14559dfea77d0ab4826..b7e6e4fedab69468661d3106ca8134efb8ce574d 100644 --- a/lib/Service/RecoveryEmailService.php +++ b/lib/Service/RecoveryEmailService.php @@ -57,6 +57,8 @@ class RecoveryEmailService { $this->curl = $curlService; $this->blackListService = $blackListService; $this->l = $l; + // Initialize Redis cache directly + $this->redisCache = \OC::$server->getMemCacheFactory()->createDistributed(); $commonServiceURL = $this->config->getSystemValue('common_services_url', ''); if (!empty($commonServiceURL)) { @@ -135,6 +137,47 @@ class RecoveryEmailService { $this->logger->info("User ID $username's requested recovery email address domain is blacklisted. Please provide another recovery address."); throw new BlacklistedEmailException($l->t('The domain of this email address is blacklisted. Please provide another recovery address.')); } + $apiKey = getenv('VERIFYMAIL_API_KEY'); + + if ($this->blackListService->isPopularDomain($recoveryEmail)) { + // Ensure rate limit for email verification (2 requests per second) + $this->ensureRealTimeRateLimit('rate_limit_email', 2); + + // Step 3.1: Verify the email address using verifymail.io + $url = "https://verifymail.io/api/$recoveryEmail?key=$apiKey"; + $response = file_get_contents($url); + $data = json_decode($response, true); + + if ($data['disposable'] ?? false) { + $this->logger->info("User ID $username's requested recovery email address is disposable."); + throw new BlacklistedEmailException("The email address is disposable. Please provide another recovery address."); + } elseif (!$data['deliverable_email'] ?? true) { + $this->logger->info("User ID $username's requested recovery email address is not deliverable."); + throw new BlacklistedEmailException("The email address is not deliverable. Please provide another recovery address."); + } else { + $this->logger->info("User ID $username's requested recovery email address is valid and not disposable."); + return true; // Email is valid + } + } + + $this->ensureRealTimeRateLimit('rate_limit_domain', 15); + $domain = substr(strrchr($recoveryEmail, "@"), 1); + $url = "https://verifymail.io/api/$domain?key=$apiKey"; + $response = file_get_contents($url); + $data = json_decode($response, true); + + if ($data['disposable'] ?? false) { + $this->logger->info("User ID $username's requested recovery email address is from a disposable domain."); + // Store domain and related domains in custom_disposable_domains.json + $this->blackListService->addToCustomDisposableDomains($domain, $data['related_domains'] ?? []); + throw new BlacklistedEmailException("The domain of this email address is disposable. Please provide another recovery address."); + } elseif (!$data['mx'] ?? true) { + $this->logger->info("User ID $username's requested recovery email address domain is not valid."); + throw new BlacklistedEmailException("The domain of this email address is invalid. Please provide another recovery address."); + } else { + $this->logger->info("User ID $username's requested recovery email address domain is valid."); + return true; // Domain and email are valid + } } return true; } @@ -307,4 +350,29 @@ class RecoveryEmailService { public function isValidEmailFormat(string $recoveryEmail): bool { return filter_var($recoveryEmail, FILTER_VALIDATE_EMAIL) !== false; } + + private function ensureRealTimeRateLimit(string $key, int $rateLimit, int $maxRetries = 5): void { + $now = microtime(true); + + for ($retry = 0; $retry < $maxRetries; $retry++) { + $requests = $this->redisCache->get($key) ?? []; + + // Remove old requests (keep only those within a 1-second window) + $requests = array_filter($requests, function ($timestamp) use ($now) { + return ($now - $timestamp) < 1; + }); + + if (count($requests) < $rateLimit) { + // Under the rate limit, add the current request timestamp and proceed + $requests[] = $now; + $this->redisCache->set($key, $requests, 2); // Set expiry of 2 seconds for cached timestamps + return; + } + + // If rate limit exceeded, wait a short time and retry + usleep(100000); // Wait 100ms before retrying (adjust as needed) + } + + throw new \Exception("Rate limit exceeded. Please try again later."); + } }