harden(rate-limiter): fail secure login scopes with cache fallback

This commit is contained in:
2026-04-25 23:17:46 +02:00
parent f9c09f8746
commit a24dc0c235
4 changed files with 263 additions and 9 deletions

View File

@@ -0,0 +1,35 @@
<?php
namespace MintyPHP\Service\Security;
use MintyPHP\Cache;
class RateLimiterFallbackGateway
{
private const KEY_PREFIX = 'rate_limit_fallback:';
public function load(string $scope, string $subjectHash): ?array
{
$state = Cache::get($this->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);
}
}

View File

@@ -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

View File

@@ -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();
}
}

View File

@@ -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
// ---------------------------------------------------------------