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) . '***'; } }