commit 5ffa224a37085d3b75081fe7df9818b198372d9c Author: root Date: Sun Dec 21 09:59:57 2025 -0500 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..812e4cd --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Frontend development +node_modules/ +*.local +.env.local +.env.*.local +.cache/ +.vite/ +.temp/ +.tmp/ + +# Frontend build +/static/ + +# Backend development +/lib/vendor/ +coverage/ +phpunit.xml.cache +.phpunit.result.cache +.php-cs-fixer.cache +.phpstan.cache +.phpactor/ + +# Editors +.DS_Store +.vscode/ +.idea/ + +# Logs +*.log diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..3bf662c --- /dev/null +++ b/composer.json @@ -0,0 +1,13 @@ +{ + "name": "ktrix/authentication-provider-mail", + "description": "Ktrix mail challenge authentication provider", + "type": "project", + "autoload": { + "psr-4": { + "KTXM\\AuthenticationProviderMail\\": "lib/" + } + }, + "require": { + "php": ">=8.2" + } +} diff --git a/lib/Module.php b/lib/Module.php new file mode 100644 index 0000000..39b3416 --- /dev/null +++ b/lib/Module.php @@ -0,0 +1,73 @@ + [ + 'label' => 'Access Mail Authentication Provider', + 'description' => 'View and access the mail authentication provider module', + 'group' => 'Authentication Providers' + ], + ]; + } + + public function boot(): void + { + $this->providerManager->register('authentication', 'mail', Provider::class); + } + + public function registerBI(): array + { + return [ + 'handle' => $this->handle(), + 'namespace' => 'AuthenticationProviderEmail', + 'version' => $this->version(), + 'label' => $this->label(), + 'author' => $this->author(), + 'description' => $this->description(), + ]; + } +} diff --git a/lib/Provider.php b/lib/Provider.php new file mode 100644 index 0000000..3818712 --- /dev/null +++ b/lib/Provider.php @@ -0,0 +1,368 @@ +tenantId; + $userIdentity = $context->userIdentity; // Email address + + if (empty($tenantId)) { + return ProviderResult::failed( + ProviderResult::ERROR_INTERNAL, + 'Invalid tenant context' + ); + } + + if (empty($userIdentity)) { + return ProviderResult::failed( + ProviderResult::ERROR_INVALID_CREDENTIALS, + 'Email address is required' + ); + } + + // Validate email format + if (!filter_var($userIdentity, FILTER_VALIDATE_EMAIL)) { + return ProviderResult::failed( + ProviderResult::ERROR_INVALID_CREDENTIALS, + 'Invalid email address format' + ); + } + + // Check for existing pending challenge (rate limiting) + if ($this->challengeStore->hasPending($tenantId, $userIdentity)) { + // Allow resend but inform user + $this->logger->debug('Resending email challenge', [ + 'tenantId' => $tenantId, + ]); + } + + // Generate and store challenge + $ttl = $context->getConfig('code_ttl', self::DEFAULT_CODE_TTL); + $challenge = $this->challengeStore->create($tenantId, $userIdentity, $ttl); + + // Send verification email + $emailResult = $this->sendVerificationEmail( + $tenantId, + $userIdentity, + $challenge['code'], + $challenge['expires'], + $context + ); + + if (!$emailResult['success']) { + // Invalidate the challenge since email failed + $this->challengeStore->invalidate($tenantId, $userIdentity); + + $this->logger->error('Failed to send verification email', [ + 'tenantId' => $tenantId, + 'error' => $emailResult['error'] ?? 'Unknown error', + ]); + + return ProviderResult::failed( + ProviderResult::ERROR_INTERNAL, + 'Failed to send verification email' + ); + } + + $this->logger->info('Email challenge initiated', [ + 'tenantId' => $tenantId, + 'expires' => $challenge['expires'], + ]); + + return ProviderResult::challenge( + [ + 'type' => 'email', + 'message' => 'A verification code has been sent to your email address', + 'digits' => self::DEFAULT_DIGITS, + 'expires_in' => $ttl, + 'masked_email' => $this->maskEmail($userIdentity), + ], + [ + 'identity' => $userIdentity, + 'challenge_expires' => $challenge['expires'], + ] + ); + } + + /** + * Verify the email challenge code + */ + public function verifyChallenge(ProviderContext $context, string $code): ProviderResult + { + $tenantId = $context->tenantId; + $userIdentity = $context->userIdentity ?? $context->getMeta('identity'); + + if (empty($tenantId)) { + return ProviderResult::failed( + ProviderResult::ERROR_INTERNAL, + 'Invalid tenant context' + ); + } + + if (empty($userIdentity)) { + return ProviderResult::failed( + ProviderResult::ERROR_INVALID_CREDENTIALS, + 'Identity is required' + ); + } + + // Normalize code (remove spaces, dashes) + $code = preg_replace('/[\s\-]/', '', $code); + + // Verify the challenge + $result = $this->challengeStore->verify($tenantId, $userIdentity, $code); + + if (!$result['success']) { + $this->logger->debug('Email challenge verification failed', [ + 'tenantId' => $tenantId, + 'error' => $result['error'] ?? 'Unknown', + ]); + + return ProviderResult::failed( + ProviderResult::ERROR_FACTOR_FAILED, + $result['error'] ?? 'Invalid verification code' + ); + } + + $this->logger->info('Email challenge verified successfully', [ + 'tenantId' => $tenantId, + ]); + + return ProviderResult::success([ + 'identity' => $userIdentity, + 'provider' => $this->identifier(), + 'verified_email' => $userIdentity, + ]); + } + + /** + * Direct verify (not typically used for email, but implemented for interface) + */ + public function verify(ProviderContext $context, string $secret): ProviderResult + { + return $this->verifyChallenge($context, $secret); + } + + // ========================================================================= + // Email Sending + // ========================================================================= + + /** + * Send the verification email + */ + private function sendVerificationEmail( + string $tenantId, + string $recipientEmail, + string $code, + int $expires, + ProviderContext $context + ): array { + try { + // Get the default system mail service + $service = $this->mailManager->serviceFindByAddress( + $tenantId, + null, + 'authentication@system' + ); + + if ($service instanceof IServiceSend === false) { + return [ + 'success' => false, + 'error' => 'No mail service configured', + ]; + } + + // Build the message + $message = $service->messageFresh(); + + $message->setTo([new Address($recipientEmail)]); + $message->setSubject('Your verification code'); + + // Format code with spaces for readability (123 456) + $formattedCode = wordwrap($code, 3, ' ', true); + + // Calculate remaining time + $remainingMinutes = ceil(($expires - time()) / 60); + + // Build email body + $textBody = $this->buildTextBody($formattedCode, (int)$remainingMinutes); + $htmlBody = $this->buildHtmlBody($formattedCode, (int)$remainingMinutes); + + $message->setBodyText($textBody); + $message->setBodyHtml($htmlBody); + + // Send immediately (2FA is time-sensitive) + $this->mailManager->send( + $tenantId, + null, + $message, + new SendOptions(immediate: true) + ); + + return ['success' => true]; + + } catch (\Throwable $e) { + $this->logger->error('Email send failed', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + return [ + 'success' => false, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Build plain text email body + */ + private function buildTextBody(string $code, int $minutes): string + { + return << + + + + + Verification Code + + +
+

+ Verification Code +

+ +

+ Enter the following code to verify your identity: +

+ +
+ + $code + +
+ +

+ This code will expire in $minutes minute(s). +

+ +
+ +

+ If you did not request this code, please ignore this email. +

+
+ + +HTML; + } + + /** + * Mask email address for display + */ + private function maskEmail(string $email): string + { + $parts = explode('@', $email); + if (count($parts) !== 2) { + return '***@***'; + } + + $local = $parts[0]; + $domain = $parts[1]; + + // Show first 2 chars of local part + $maskedLocal = substr($local, 0, 2) . str_repeat('*', max(3, strlen($local) - 2)); + + // Show domain + return $maskedLocal . '@' . $domain; + } +} diff --git a/lib/Stores/ChallengeStore.php b/lib/Stores/ChallengeStore.php new file mode 100644 index 0000000..73f6cf5 --- /dev/null +++ b/lib/Stores/ChallengeStore.php @@ -0,0 +1,203 @@ +generateCode(); + $expires = time() + $ttl; + + $challenge = [ + 'identity' => $identity, + 'code' => $code, + 'created' => time(), + 'expires' => $expires, + 'attempts' => 0, + ]; + + $key = $this->getChallengeKey($identity); + $this->cache->set($key, $challenge, CacheScope::Tenant, self::CACHE_USAGE, $ttl); + + $this->logger->debug('Email challenge created', [ + 'tenantId' => $tenantId, + 'identity' => $this->maskIdentity($identity), + 'expires' => $expires, + ]); + + return [ + 'code' => $code, + 'expires' => $expires, + ]; + } + + /** + * Verify a challenge code + * + * @param string $tenantId Tenant identifier + * @param string $identity User identity + * @param string $code Code to verify + * + * @return array{success: bool, error?: string} + */ + public function verify(string $tenantId, string $identity, string $code): array + { + $key = $this->getChallengeKey($identity); + $challenge = $this->cache->get($key, CacheScope::Tenant, self::CACHE_USAGE); + + if ($challenge === null) { + $this->logger->debug('Challenge not found', [ + 'tenantId' => $tenantId, + 'identity' => $this->maskIdentity($identity), + ]); + return ['success' => false, 'error' => 'No pending challenge']; + } + + // Check expiration (cache handles TTL, but we store explicit expiry too) + if (time() > $challenge['expires']) { + $this->cache->delete($key, CacheScope::Tenant, self::CACHE_USAGE); + $this->logger->debug('Challenge expired', [ + 'tenantId' => $tenantId, + 'identity' => $this->maskIdentity($identity), + ]); + return ['success' => false, 'error' => 'Challenge expired']; + } + + // Increment attempt counter + $challenge['attempts']++; + + // Rate limit: max attempts + if ($challenge['attempts'] > self::MAX_ATTEMPTS) { + $this->cache->delete($key, CacheScope::Tenant, self::CACHE_USAGE); + $this->logger->warning('Challenge max attempts exceeded', [ + 'tenantId' => $tenantId, + 'identity' => $this->maskIdentity($identity), + ]); + return ['success' => false, 'error' => 'Too many attempts']; + } + + // Timing-safe comparison + if (!hash_equals($challenge['code'], $code)) { + // Save updated attempt count (preserve remaining TTL) + $remainingTtl = max(1, $challenge['expires'] - time()); + $this->cache->set($key, $challenge, CacheScope::Tenant, self::CACHE_USAGE, $remainingTtl); + + $this->logger->debug('Challenge verification failed', [ + 'tenantId' => $tenantId, + 'identity' => $this->maskIdentity($identity), + 'attempts' => $challenge['attempts'], + ]); + return ['success' => false, 'error' => 'Invalid code']; + } + + // Success - remove challenge + $this->cache->delete($key, CacheScope::Tenant, self::CACHE_USAGE); + + $this->logger->info('Challenge verified successfully', [ + 'tenantId' => $tenantId, + 'identity' => $this->maskIdentity($identity), + ]); + + return ['success' => true]; + } + + /** + * Check if a pending challenge exists + * + * @param string $tenantId Tenant identifier + * @param string $identity User identity + * + * @return bool + */ + public function hasPending(string $tenantId, string $identity): bool + { + $key = $this->getChallengeKey($identity); + return $this->cache->has($key, CacheScope::Tenant, self::CACHE_USAGE); + } + + /** + * Invalidate any pending challenge + * + * @param string $tenantId Tenant identifier + * @param string $identity User identity + */ + public function invalidate(string $tenantId, string $identity): void + { + $key = $this->getChallengeKey($identity); + $this->cache->delete($key, CacheScope::Tenant, self::CACHE_USAGE); + } + + /** + * Generate a numeric verification code + */ + private function generateCode(): string + { + $code = ''; + for ($i = 0; $i < self::CODE_LENGTH; $i++) { + $code .= random_int(0, 9); + } + return $code; + } + + /** + * Get the cache key for an identity + */ + private function getChallengeKey(string $identity): string + { + return 'challenge:' . hash('sha256', $identity); + } + + /** + * Mask identity for logging (privacy) + */ + private function maskIdentity(string $identity): string + { + if (str_contains($identity, '@')) { + $parts = explode('@', $identity); + $local = $parts[0]; + $domain = $parts[1] ?? ''; + + $masked = substr($local, 0, 2) . '***'; + if (!empty($domain)) { + $masked .= '@' . $domain; + } + + return $masked; + } + + return substr($identity, 0, 4) . '***'; + } +}