Files
breadcrumb-the-shire/core/Service/Security/RateLimiterService.php

223 lines
7.4 KiB
PHP
Raw 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';
2026-03-05 08:26:51 +01:00
public function __construct(private readonly RateLimitRepositoryInterface $rateLimitRepository)
{
}
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 $exception) {
// Fail-open: never lock users out because of rate limit storage issues.
return ['allowed' => true, 'retry_after' => 0];
}
}
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 ['allowed' => true, 'retry_after' => 0];
} catch (\Throwable $exception) {
// Fail-open on storage failures.
return ['allowed' => true, 'retry_after' => 0];
}
}
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;
}
}