diff --git a/core/Service/Security/RateLimiterFallbackGateway.php b/core/Service/Security/RateLimiterFallbackGateway.php new file mode 100644 index 0000000..2037485 --- /dev/null +++ b/core/Service/Security/RateLimiterFallbackGateway.php @@ -0,0 +1,35 @@ +key($scope, $subjectHash)); + if (!is_array($state)) { + return null; + } + + return $state; + } + + public function save(string $scope, string $subjectHash, array $state, int $ttlSeconds): bool + { + return Cache::set($this->key($scope, $subjectHash), $state, max(1, $ttlSeconds)); + } + + public function remove(string $scope, string $subjectHash): void + { + Cache::delete($this->key($scope, $subjectHash)); + } + + private function key(string $scope, string $subjectHash): string + { + return self::KEY_PREFIX . strtolower(trim($scope)) . ':' . trim($subjectHash); + } +} diff --git a/core/Service/Security/RateLimiterService.php b/core/Service/Security/RateLimiterService.php index d9b6516..d3cbde5 100644 --- a/core/Service/Security/RateLimiterService.php +++ b/core/Service/Security/RateLimiterService.php @@ -9,9 +9,18 @@ use MintyPHP\Repository\Security\RateLimitRepositoryInterface; class RateLimiterService { private const HASH_ALGO = 'sha256'; + private const STORAGE_DEGRADED_RETRY_AFTER_SECONDS = 30; + private const FALLBACK_TTL_BUFFER_SECONDS = 60; + private const FAIL_SECURE_SCOPE_PREFIXES = [ + 'api.auth.login.', + 'login.resolve.', + 'login.password.', + ]; - public function __construct(private readonly RateLimitRepositoryInterface $rateLimitRepository) - { + public function __construct( + private readonly RateLimitRepositoryInterface $rateLimitRepository, + private readonly ?RateLimiterFallbackGateway $fallbackGateway = null + ) { } public function hit(string $scope, string $subject, int $maxHits, int $windowSeconds, int $blockSeconds): array @@ -57,9 +66,8 @@ class RateLimiterService 'allowed' => false, 'retry_after' => max(1, $blockedUntilTs - $nowTs), ]; - } catch (\Throwable $exception) { - // Fail-open: never lock users out because of rate limit storage issues. - return ['allowed' => true, 'retry_after' => 0]; + } catch (\Throwable) { + return $this->handleReadStorageFailure($normalized['scope'], $normalized['subject_hash']); } } @@ -173,11 +181,165 @@ class RateLimiterService return ['allowed' => true, 'retry_after' => 0]; } - return ['allowed' => true, 'retry_after' => 0]; - } catch (\Throwable $exception) { - // Fail-open on storage failures. + return $this->handleApplyStorageFailure( + $normalized['scope'], + $normalized['subject_hash'], + $maxHits, + $windowSeconds, + $blockSeconds, + $increment + ); + } catch (\Throwable) { + return $this->handleApplyStorageFailure( + $normalized['scope'], + $normalized['subject_hash'], + $maxHits, + $windowSeconds, + $blockSeconds, + $increment + ); + } + } + + private function handleReadStorageFailure(string $scope, string $subjectHash): array + { + if ($this->fallbackGateway !== null) { + try { + return $this->fallbackIsBlocked($scope, $subjectHash); + } catch (\Throwable) { + // Fall through to policy fallback. + } + } + + return $this->storageFailurePolicy($scope); + } + + private function handleApplyStorageFailure( + string $scope, + string $subjectHash, + int $maxHits, + int $windowSeconds, + int $blockSeconds, + bool $increment + ): array { + if ($this->fallbackGateway !== null) { + try { + return $this->fallbackApply($scope, $subjectHash, $maxHits, $windowSeconds, $blockSeconds, $increment); + } catch (\Throwable) { + // Fall through to policy fallback. + } + } + + return $this->storageFailurePolicy($scope); + } + + private function fallbackIsBlocked(string $scope, string $subjectHash): array + { + $state = $this->fallbackGateway->load($scope, $subjectHash); + if (!is_array($state)) { return ['allowed' => true, 'retry_after' => 0]; } + + $blockedUntilTs = max(0, (int) ($state['blocked_until'] ?? 0)); + if ($blockedUntilTs <= 0) { + return ['allowed' => true, 'retry_after' => 0]; + } + + $nowTs = time(); + if ($blockedUntilTs <= $nowTs) { + $this->fallbackGateway->remove($scope, $subjectHash); + return ['allowed' => true, 'retry_after' => 0]; + } + + return [ + 'allowed' => false, + 'retry_after' => max(1, $blockedUntilTs - $nowTs), + ]; + } + + private function fallbackApply( + string $scope, + string $subjectHash, + int $maxHits, + int $windowSeconds, + int $blockSeconds, + bool $increment + ): array { + $nowTs = time(); + $state = $this->fallbackGateway->load($scope, $subjectHash); + + $hits = 0; + $windowStartedTs = $nowTs; + $blockedUntilTs = 0; + + if (is_array($state)) { + $blockedUntilTs = max(0, (int) ($state['blocked_until'] ?? 0)); + if ($blockedUntilTs > $nowTs) { + return [ + 'allowed' => false, + 'retry_after' => max(1, $blockedUntilTs - $nowTs), + ]; + } + + $windowStartedTs = max(0, (int) ($state['window_started_at'] ?? 0)); + $hits = max(0, (int) ($state['hits'] ?? 0)); + if ($windowStartedTs <= 0 || ($windowStartedTs + $windowSeconds) <= $nowTs) { + $windowStartedTs = $nowTs; + $hits = 0; + } + } elseif (!$increment) { + return ['allowed' => true, 'retry_after' => 0]; + } + + if ($increment) { + $hits++; + } + + $blockedUntilTs = 0; + if ($hits > $maxHits) { + $blockedUntilTs = $nowTs + $blockSeconds; + } + + $expiresAtTs = $blockedUntilTs > 0 ? $blockedUntilTs : ($windowStartedTs + $windowSeconds); + $ttlSeconds = max(1, ($expiresAtTs - $nowTs) + self::FALLBACK_TTL_BUFFER_SECONDS); + + $this->fallbackGateway->save($scope, $subjectHash, [ + 'hits' => $hits, + 'window_started_at' => $windowStartedTs, + 'blocked_until' => $blockedUntilTs > 0 ? $blockedUntilTs : null, + ], $ttlSeconds); + + if ($blockedUntilTs > 0) { + return [ + 'allowed' => false, + 'retry_after' => max(1, $blockedUntilTs - $nowTs), + ]; + } + + return ['allowed' => true, 'retry_after' => 0]; + } + + private function storageFailurePolicy(string $scope): array + { + if ($this->isFailSecureScope($scope)) { + return [ + 'allowed' => false, + 'retry_after' => self::STORAGE_DEGRADED_RETRY_AFTER_SECONDS, + ]; + } + + return ['allowed' => true, 'retry_after' => 0]; + } + + private function isFailSecureScope(string $scope): bool + { + foreach (self::FAIL_SECURE_SCOPE_PREFIXES as $prefix) { + if (str_starts_with($scope, $prefix)) { + return true; + } + } + + return false; } private function normalize(string $scope, string $subject): ?array diff --git a/core/Service/Security/SecurityServicesFactory.php b/core/Service/Security/SecurityServicesFactory.php index ca82af2..2ee198c 100644 --- a/core/Service/Security/SecurityServicesFactory.php +++ b/core/Service/Security/SecurityServicesFactory.php @@ -7,6 +7,7 @@ use MintyPHP\Repository\Security\RateLimitRepositoryInterface; class SecurityServicesFactory { private ?RateLimiterService $rateLimiterService = null; + private ?RateLimiterFallbackGateway $rateLimiterFallbackGateway = null; public function __construct( private readonly SecurityRepositoryFactory $securityRepositoryFactory @@ -20,6 +21,14 @@ class SecurityServicesFactory public function createRateLimiterService(): RateLimiterService { - return $this->rateLimiterService ??= new RateLimiterService($this->createRateLimitRepository()); + return $this->rateLimiterService ??= new RateLimiterService( + $this->createRateLimitRepository(), + $this->createRateLimiterFallbackGateway() + ); + } + + public function createRateLimiterFallbackGateway(): RateLimiterFallbackGateway + { + return $this->rateLimiterFallbackGateway ??= new RateLimiterFallbackGateway(); } } diff --git a/tests/Service/Security/RateLimiterServiceTest.php b/tests/Service/Security/RateLimiterServiceTest.php index 3bc12f9..445eb47 100644 --- a/tests/Service/Security/RateLimiterServiceTest.php +++ b/tests/Service/Security/RateLimiterServiceTest.php @@ -3,6 +3,7 @@ namespace MintyPHP\Tests\Service\Security; use MintyPHP\Repository\Security\RateLimitRepositoryInterface; +use MintyPHP\Service\Security\RateLimiterFallbackGateway; use MintyPHP\Service\Security\RateLimiterService; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -284,6 +285,53 @@ class RateLimiterServiceTest extends TestCase $this->assertSame(0, $result['retry_after']); } + public function testIsBlockedFailsSecureOnExceptionForLoginAuthScope(): void + { + $this->repository->method('findByScopeAndHash') + ->willThrowException(new \RuntimeException('DB down')); + + $result = $this->service->isBlocked('api.auth.login.ip', '127.0.0.1'); + + $this->assertFalse($result['allowed']); + $this->assertSame(30, $result['retry_after']); + } + + public function testRegisterFailureFailsSecureOnExceptionForLoginAuthScope(): void + { + $this->repository->method('findByScopeAndHash') + ->willThrowException(new \RuntimeException('DB down')); + + $result = $this->service->registerFailure('login.password.email_ip', 'user@example.com|127.0.0.1', 5, 900, 900); + + $this->assertFalse($result['allowed']); + $this->assertSame(30, $result['retry_after']); + } + + public function testIsBlockedUsesFallbackGatewayWhenStorageFails(): void + { + $subject = '127.0.0.1'; + $subjectHash = hash('sha256', $subject); + $fallback = $this->createMock(RateLimiterFallbackGateway::class); + + $fallback->expects($this->once()) + ->method('load') + ->with('api.auth.login.ip', $subjectHash) + ->willReturn([ + 'hits' => 7, + 'window_started_at' => time(), + 'blocked_until' => time() + 120, + ]); + + $this->repository->method('findByScopeAndHash') + ->willThrowException(new \RuntimeException('DB down')); + + $service = new RateLimiterService($this->repository, $fallback); + $result = $service->isBlocked('api.auth.login.ip', $subject); + + $this->assertFalse($result['allowed']); + $this->assertGreaterThan(0, $result['retry_after']); + } + // --------------------------------------------------------------- // reset // ---------------------------------------------------------------