harden(rate-limiter): fail secure login scopes with cache fallback
This commit is contained in:
35
core/Service/Security/RateLimiterFallbackGateway.php
Normal file
35
core/Service/Security/RateLimiterFallbackGateway.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user