Initial commit
This commit is contained in:
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
@@ -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
|
||||||
13
composer.json
Normal file
13
composer.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
73
lib/Module.php
Normal file
73
lib/Module.php
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXM\AuthenticationProviderMail;
|
||||||
|
|
||||||
|
use KTXC\Resource\ProviderManager;
|
||||||
|
use KTXF\Module\ModuleBrowserInterface;
|
||||||
|
use KTXF\Module\ModuleInstanceAbstract;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Email Challenge Authentication Provider Module
|
||||||
|
* Provides email-based OTP authentication
|
||||||
|
*/
|
||||||
|
class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ProviderManager $providerManager,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(): string
|
||||||
|
{
|
||||||
|
return 'authentication_provider_mail';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return 'Mail Authentication Provider';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function author(): string
|
||||||
|
{
|
||||||
|
return 'Ktrix';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function description(): string
|
||||||
|
{
|
||||||
|
return 'Ktrix Email Authentication Provider - authenticates users via one-time codes sent to their email address';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function version(): string
|
||||||
|
{
|
||||||
|
return '0.0.1';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function permissions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'authentication_provider_mail' => [
|
||||||
|
'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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
368
lib/Provider.php
Normal file
368
lib/Provider.php
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXM\AuthenticationProviderMail;
|
||||||
|
|
||||||
|
use KTXF\Mail\Entity\Address;
|
||||||
|
use KTXF\Mail\Entity\IMessageBase;
|
||||||
|
use KTXF\Mail\Queue\SendOptions;
|
||||||
|
use KTXF\Mail\Service\IServiceBase;
|
||||||
|
use KTXF\Mail\Service\IServiceSend;
|
||||||
|
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 their email.
|
||||||
|
* Uses the mail manager system for sending verification emails.
|
||||||
|
*/
|
||||||
|
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 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 user's email.
|
||||||
|
*/
|
||||||
|
public function beginChallenge(ProviderContext $context): ProviderResult
|
||||||
|
{
|
||||||
|
$tenantId = $context->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 <<<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;
|
||||||
|
}
|
||||||
|
}
|
||||||
203
lib/Stores/ChallengeStore.php
Normal file
203
lib/Stores/ChallengeStore.php
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXM\AuthenticationProviderMail\Stores;
|
||||||
|
|
||||||
|
use KTXF\Cache\CacheScope;
|
||||||
|
use KTXF\Cache\EphemeralCacheInterface;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Challenge Store
|
||||||
|
*
|
||||||
|
* Stores email verification challenges with time-based expiration.
|
||||||
|
* Uses the ephemeral cache system for automatic TTL handling.
|
||||||
|
*/
|
||||||
|
class ChallengeStore
|
||||||
|
{
|
||||||
|
private const DEFAULT_TTL = 300; // 5 minutes
|
||||||
|
private const CODE_LENGTH = 6;
|
||||||
|
private const CACHE_USAGE = 'auth-email';
|
||||||
|
private const MAX_ATTEMPTS = 5;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly EphemeralCacheInterface $cache,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new challenge for the given identity
|
||||||
|
*
|
||||||
|
* @param string $tenantId Tenant identifier
|
||||||
|
* @param string $identity User identity (email or user ID)
|
||||||
|
* @param int|null $ttl Time-to-live in seconds
|
||||||
|
*
|
||||||
|
* @return array{code: string, expires: int}
|
||||||
|
*/
|
||||||
|
public function create(string $tenantId, string $identity, ?int $ttl = null): array
|
||||||
|
{
|
||||||
|
$ttl = $ttl ?? self::DEFAULT_TTL;
|
||||||
|
$code = $this->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) . '***';
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user