tenantId; if (empty($tenantId)) { return ProviderResult::failed( ProviderResult::ERROR_INTERNAL, 'Invalid tenant context' ); } $recipientAddress = $this->resolveRecipientAddress($context); if ($recipientAddress === null) { return ProviderResult::failed( ProviderResult::ERROR_INVALID_CREDENTIALS, 'No valid email address on file for this account' ); } // Check for existing pending challenge (rate limiting) if ($this->challengeStore->hasPending($tenantId, $recipientAddress)) { // 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, $recipientAddress, $ttl); // Send verification email $emailResult = $this->sendVerificationEmail( $tenantId, $recipientAddress, $challenge['code'], $challenge['expires'], $context ); if (!$emailResult['success']) { // Invalidate the challenge since email failed $this->challengeStore->invalidate($tenantId, $recipientAddress); $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($recipientAddress), ], [ 'identity' => $recipientAddress, 'challenge_expires' => $challenge['expires'], ] ); } /** * Verify the email challenge code */ public function verifyChallenge(ProviderContext $context, string $code): ProviderResult { $tenantId = $context->tenantId; // The address the code was sent to, stored when the challenge began $recipientAddress = $context->getMeta('identity') ?? $this->resolveRecipientAddress($context); if (empty($tenantId)) { return ProviderResult::failed( ProviderResult::ERROR_INTERNAL, 'Invalid tenant context' ); } if (empty($recipientAddress)) { return ProviderResult::failed( ProviderResult::ERROR_INVALID_CREDENTIALS, 'No valid email address on file for this account' ); } // Normalize code (remove spaces, dashes) $code = preg_replace('/[\s\-]/', '', $code); // Verify the challenge $result = $this->challengeStore->verify($tenantId, $recipientAddress, $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' => $context->userIdentity ?? $recipientAddress, 'provider' => $this->identifier(), 'verified_email' => $recipientAddress, ]); } /** * Direct verify (not typically used for email, but implemented for interface) */ public function verify(ProviderContext $context, string $secret): ProviderResult { return $this->verifyChallenge($context, $secret); } private function resolveRecipientAddress(ProviderContext $context): ?string { $userIdentifier = $context->userIdentifier; if ($userIdentifier === null && !empty($context->userIdentity)) { $user = $this->userService->fetchByIdentityRaw($context->userIdentity); $userIdentifier = $user['uid'] ?? null; } if ($userIdentifier === null) { return null; } $profile = $this->userService->fetchProfile($userIdentifier); $address = trim((string)($profile['profile']['email'] ?? '')); if ($address === '' || !filter_var($address, FILTER_VALIDATE_EMAIL)) { $this->logger->debug('No valid profile email address for email challenge', [ 'tenantId' => $context->tenantId, ]); return null; } return $address; } // ========================================================================= // Email Sending // ========================================================================= /** * Send the verification email */ private function sendVerificationEmail( string $tenantId, string $recipientEmail, string $code, int $expires, ProviderContext $context ): array { try { // 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); // Submit through the system mail route for the authentication channel $result = $this->mailManager->entitySubmit( $tenantId, ProviderBaseInterface::USER_SYSTEM, 'authentication@system', null, [ MessagePropertiesBaseInterface::PROPERTY_TO => [(new Address($recipientEmail))->toArray()], MessagePropertiesBaseInterface::PROPERTY_SUBJECT => 'Your verification code', MessagePropertiesBaseInterface::PROPERTY_BODY => [ 'partId' => 'body', 'type' => 'multipart/alternative', 'subParts' => [ [ 'partId' => 'body.text', 'type' => 'text/plain', 'charset' => 'utf-8', 'content' => $textBody, 'disposition' => 'inline', ], [ 'partId' => 'body.html', 'type' => 'text/html', 'charset' => 'utf-8', 'content' => $htmlBody, 'disposition' => 'inline', ], ], ], ] ); if ($result->disposition !== EntitySubmitResult::DISPOSITION_SENT) { return [ 'success' => false, 'error' => $result->errorMessage ?? 'Mail submission failed', ]; } 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; } }