Initial commit
This commit is contained in:
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