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; } }