Files

385 lines
12 KiB
PHP
Raw Permalink Normal View History

<?php
namespace MintyPHP\Service\Security;
use DateTimeImmutable;
use DateTimeZone;
2026-03-05 08:26:51 +01:00
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,
private readonly ?RateLimiterFallbackGateway $fallbackGateway = null
) {
}
2026-02-23 12:58:19 +01:00
public function hit(string $scope, string $subject, int $maxHits, int $windowSeconds, int $blockSeconds): array
{
2026-02-23 12:58:19 +01:00
return $this->apply($scope, $subject, $maxHits, $windowSeconds, $blockSeconds, true);
}
2026-02-23 12:58:19 +01:00
public function registerFailure(string $scope, string $subject, int $maxHits, int $windowSeconds, int $blockSeconds): array
{
2026-02-23 12:58:19 +01:00
return $this->apply($scope, $subject, $maxHits, $windowSeconds, $blockSeconds, true);
}
public function isBlocked(string $scope, string $subject): array
{
$normalized = $this->normalize($scope, $subject);
if ($normalized === null) {
return ['allowed' => true, 'retry_after' => 0];
}
try {
2026-02-23 12:58:19 +01:00
$row = $this->rateLimitRepository->findByScopeAndHash($normalized['scope'], $normalized['subject_hash']);
if (!is_array($row)) {
return ['allowed' => true, 'retry_after' => 0];
}
2026-02-23 12:58:19 +01:00
$blockedUntilTs = $this->parseTimestamp((string) ($row['blocked_until'] ?? ''));
if ($blockedUntilTs === null) {
return ['allowed' => true, 'retry_after' => 0];
}
$nowTs = time();
if ($blockedUntilTs <= $nowTs) {
2026-02-23 12:58:19 +01:00
$this->rateLimitRepository->updateStateById(
(int) ($row['id'] ?? 0),
0,
gmdate('Y-m-d H:i:s', $nowTs),
null
);
return ['allowed' => true, 'retry_after' => 0];
}
return [
'allowed' => false,
'retry_after' => max(1, $blockedUntilTs - $nowTs),
];
} catch (\Throwable) {
return $this->handleReadStorageFailure($normalized['scope'], $normalized['subject_hash']);
}
}
2026-02-23 12:58:19 +01:00
public function reset(string $scope, string $subject): void
{
2026-02-23 12:58:19 +01:00
$normalized = $this->normalize($scope, $subject);
if ($normalized === null) {
return;
}
try {
2026-02-23 12:58:19 +01:00
$this->rateLimitRepository->deleteByScopeAndHash($normalized['scope'], $normalized['subject_hash']);
} catch (\Throwable $exception) {
// Ignore reset failures.
}
}
2026-02-23 12:58:19 +01:00
private function apply(
string $scope,
string $subject,
int $maxHits,
int $windowSeconds,
int $blockSeconds,
bool $increment
): array {
2026-02-23 12:58:19 +01:00
$normalized = $this->normalize($scope, $subject);
if ($normalized === null) {
return ['allowed' => true, 'retry_after' => 0];
}
$maxHits = max(1, $maxHits);
$windowSeconds = max(1, $windowSeconds);
$blockSeconds = max(1, $blockSeconds);
try {
$nowTs = time();
$nowSql = gmdate('Y-m-d H:i:s', $nowTs);
2026-03-06 00:44:52 +01:00
// Two-attempt loop handles a creation race: if two requests arrive simultaneously for a
// new subject, both may find no row and try to INSERT. The second INSERT fails (unique key),
// so we retry once and the second attempt reads the now-existing row.
for ($attempt = 0; $attempt < 2; $attempt++) {
2026-02-23 12:58:19 +01:00
$row = $this->rateLimitRepository->findByScopeAndHash($normalized['scope'], $normalized['subject_hash']);
if (!is_array($row)) {
if (!$increment) {
return ['allowed' => true, 'retry_after' => 0];
}
$hits = 1;
2026-02-23 12:58:19 +01:00
$created = $this->rateLimitRepository->create(
$normalized['scope'],
$normalized['subject_hash'],
$hits,
$nowSql,
2026-02-23 12:58:19 +01:00
null
);
if ($created) {
return ['allowed' => true, 'retry_after' => 0];
}
continue;
}
$id = (int) ($row['id'] ?? 0);
if ($id <= 0) {
return ['allowed' => true, 'retry_after' => 0];
}
2026-02-23 12:58:19 +01:00
$blockedUntilTs = $this->parseTimestamp((string) ($row['blocked_until'] ?? ''));
if ($blockedUntilTs !== null && $blockedUntilTs > $nowTs) {
return [
'allowed' => false,
'retry_after' => max(1, $blockedUntilTs - $nowTs),
];
}
2026-02-23 12:58:19 +01:00
$windowStartedTs = $this->parseTimestamp((string) ($row['window_started_at'] ?? ''));
2026-03-06 00:44:52 +01:00
// Sliding window expired — reset the window start and hit count.
if ($windowStartedTs === null || ($windowStartedTs + $windowSeconds) <= $nowTs) {
$windowStartedTs = $nowTs;
$hits = 0;
} else {
$hits = max(0, (int) ($row['hits'] ?? 0));
}
if ($increment) {
$hits++;
}
$newBlockedUntilTs = null;
if ($hits > $maxHits) {
$newBlockedUntilTs = $nowTs + $blockSeconds;
}
2026-02-23 12:58:19 +01:00
$this->rateLimitRepository->updateStateById(
$id,
$hits,
gmdate('Y-m-d H:i:s', $windowStartedTs),
$newBlockedUntilTs !== null ? gmdate('Y-m-d H:i:s', $newBlockedUntilTs) : null
);
if ($newBlockedUntilTs !== null) {
return [
'allowed' => false,
'retry_after' => max(1, $newBlockedUntilTs - $nowTs),
];
}
return ['allowed' => true, 'retry_after' => 0];
}
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;
}
2026-02-23 12:58:19 +01:00
private function normalize(string $scope, string $subject): ?array
{
$scope = strtolower(trim($scope));
$subject = trim($subject);
if ($scope === '' || $subject === '') {
return null;
}
if (strlen($scope) > 64) {
$scope = substr($scope, 0, 64);
}
2026-03-06 00:44:52 +01:00
// Hash the subject (e.g. email or IP) so PII is never stored in the rate limit table.
return [
'scope' => $scope,
'subject_hash' => hash(self::HASH_ALGO, $subject),
];
}
2026-02-23 12:58:19 +01:00
private function parseTimestamp(string $value): ?int
{
$value = trim($value);
if ($value === '') {
return null;
}
$date = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $value, new DateTimeZone('UTC'));
if ($date instanceof DateTimeImmutable) {
return $date->getTimestamp();
}
$timestamp = strtotime($value . ' UTC');
if ($timestamp === false || $timestamp <= 0) {
return null;
}
return $timestamp;
}
}