Files
authentication_provider_mail/lib/Provider.php
T
2026-07-19 18:36:45 -04:00

399 lines
13 KiB
PHP

<?php
declare(strict_types=1);
namespace KTXM\AuthenticationProviderMail;
use KTXC\Service\UserAccountsService;
use KTXF\Mail\Object\Address;
use KTXF\Mail\Object\MessagePropertiesBaseInterface;
use KTXF\Mail\Provider\ProviderBaseInterface;
use KTXF\Mail\Submission\EntitySubmitResult;
use KTXF\Security\Authentication\AuthenticationProviderAbstract;
use KTXF\Security\Authentication\ProviderContext;
use KTXF\Security\Authentication\ProviderResult;
use KTXM\AuthenticationProviderMail\Stores\ChallengeStore;
use KTXM\MailManager\Manager as MailManager;
use Psr\Log\LoggerInterface;
/**
* Email Challenge Authentication Provider
*
* Authenticates users by sending a one-time verification code to the email
* address on the user's profile. The login identity is only used to resolve
* the user account, never as a mail recipient.
*/
class Provider extends AuthenticationProviderAbstract
{
private const DEFAULT_CODE_TTL = 300; // 5 minutes
private const DEFAULT_DIGITS = 6;
public function __construct(
private readonly ChallengeStore $challengeStore,
private readonly MailManager $mailManager,
private readonly UserAccountsService $userService,
private readonly LoggerInterface $logger,
) {}
// =========================================================================
// Provider Implementation
// =========================================================================
public function type(): string
{
return 'authentication';
}
public function identifier(): string
{
return 'mail';
}
public function method(): string
{
return self::METHOD_CHALLENGE;
}
public function label(): string
{
return 'Email Code';
}
public function description(): string
{
return 'Authenticate using a one-time verification code sent to your email address.';
}
public function icon(): string
{
return 'mail';
}
/**
* Begin the email challenge
*
* Generates a verification code and sends it to the email address on the
* user's profile.
*/
public function beginChallenge(ProviderContext $context): ProviderResult
{
$tenantId = $context->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 <<<TEXT
Your verification code is: $code
This code will expire in $minutes minute(s).
If you did not request this code, please ignore this email.
---
This is an automated message. Please do not reply.
TEXT;
}
/**
* Build HTML email body
*/
private function buildHtmlBody(string $code, int $minutes): string
{
return <<<HTML
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Verification Code</title>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; background-color: #f5f5f5; margin: 0; padding: 20px;">
<div style="max-width: 480px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; padding: 40px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<h1 style="color: #333333; font-size: 24px; margin: 0 0 20px 0; text-align: center;">
Verification Code
</h1>
<p style="color: #666666; font-size: 16px; margin: 0 0 30px 0; text-align: center;">
Enter the following code to verify your identity:
</p>
<div style="background-color: #f0f0f0; border-radius: 8px; padding: 20px; text-align: center; margin: 0 0 30px 0;">
<span style="font-family: 'Monaco', 'Menlo', 'Courier New', monospace; font-size: 32px; font-weight: bold; letter-spacing: 4px; color: #333333;">
$code
</span>
</div>
<p style="color: #999999; font-size: 14px; margin: 0 0 20px 0; text-align: center;">
This code will expire in <strong>$minutes minute(s)</strong>.
</p>
<hr style="border: none; border-top: 1px solid #eeeeee; margin: 30px 0;">
<p style="color: #999999; font-size: 12px; margin: 0; text-align: center;">
If you did not request this code, please ignore this email.
</p>
</div>
</body>
</html>
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;
}
}