273 lines
9.1 KiB
PHP
273 lines
9.1 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use DateTimeImmutable;
|
|
use DateTimeZone;
|
|
use PDO;
|
|
use RuntimeException;
|
|
|
|
final class RateLimiterService
|
|
{
|
|
private readonly DateTimeZone $utc;
|
|
|
|
public function __construct(
|
|
private readonly PDO $pdo,
|
|
private readonly array $config = []
|
|
) {
|
|
$this->utc = new DateTimeZone('UTC');
|
|
}
|
|
|
|
public function inspect(string $action, string $scope): array
|
|
{
|
|
$policy = $this->resolvePolicy($action);
|
|
$statement = $this->pdo->prepare(
|
|
'SELECT attempts, window_start, last_attempt_at, blocked_until
|
|
FROM rate_limits
|
|
WHERE action_key = :action_key
|
|
AND bucket_key = :bucket_key
|
|
LIMIT 1'
|
|
);
|
|
$statement->execute([
|
|
'action_key' => $policy['action'],
|
|
'bucket_key' => $this->bucketKey($scope),
|
|
]);
|
|
|
|
return $this->buildState($policy, $statement->fetch() ?: null, time());
|
|
}
|
|
|
|
public function hit(string $action, string $scope): array
|
|
{
|
|
$policy = $this->resolvePolicy($action);
|
|
$bucketKey = $this->bucketKey($scope);
|
|
$nowTs = time();
|
|
$now = $this->formatTimestamp($nowTs);
|
|
|
|
$this->pdo->beginTransaction();
|
|
|
|
try {
|
|
$bootstrapStatement = $this->pdo->prepare(
|
|
'INSERT INTO rate_limits (
|
|
action_key,
|
|
bucket_key,
|
|
attempts,
|
|
window_start,
|
|
last_attempt_at,
|
|
blocked_until,
|
|
created_at
|
|
) VALUES (
|
|
:action_key,
|
|
:bucket_key,
|
|
0,
|
|
:window_start,
|
|
:last_attempt_at,
|
|
NULL,
|
|
:created_at
|
|
)
|
|
ON DUPLICATE KEY UPDATE
|
|
action_key = action_key'
|
|
);
|
|
$bootstrapStatement->execute([
|
|
'action_key' => $policy['action'],
|
|
'bucket_key' => $bucketKey,
|
|
'window_start' => $now,
|
|
'last_attempt_at' => $now,
|
|
'created_at' => $now,
|
|
]);
|
|
|
|
$selectStatement = $this->pdo->prepare(
|
|
'SELECT attempts, window_start, last_attempt_at, blocked_until
|
|
FROM rate_limits
|
|
WHERE action_key = :action_key
|
|
AND bucket_key = :bucket_key
|
|
LIMIT 1
|
|
FOR UPDATE'
|
|
);
|
|
$selectStatement->execute([
|
|
'action_key' => $policy['action'],
|
|
'bucket_key' => $bucketKey,
|
|
]);
|
|
$row = $selectStatement->fetch();
|
|
|
|
if (!$row) {
|
|
throw new RuntimeException('Der Rate-Limit-Zustand konnte nicht geladen werden.');
|
|
}
|
|
|
|
$windowStartTs = $this->timestampFromDatabase($row['window_start']);
|
|
$blockedUntilTs = $this->timestampFromDatabase($row['blocked_until']);
|
|
$isBlocked = $blockedUntilTs !== null && $blockedUntilTs > $nowTs;
|
|
$resetWindow = $windowStartTs === null
|
|
|| ($windowStartTs + $policy['window_seconds']) <= $nowTs
|
|
|| ($blockedUntilTs !== null && $blockedUntilTs <= $nowTs);
|
|
|
|
if ($isBlocked) {
|
|
$updatedRow = $row;
|
|
} else {
|
|
$attempts = $resetWindow ? 1 : ((int) $row['attempts'] + 1);
|
|
$windowStart = $resetWindow ? $now : (string) $row['window_start'];
|
|
$blockedUntil = $attempts >= $policy['max_attempts']
|
|
? $this->formatTimestamp($nowTs + $policy['block_seconds'])
|
|
: null;
|
|
|
|
$updateStatement = $this->pdo->prepare(
|
|
'UPDATE rate_limits
|
|
SET attempts = :attempts,
|
|
window_start = :window_start,
|
|
last_attempt_at = :last_attempt_at,
|
|
blocked_until = :blocked_until
|
|
WHERE action_key = :action_key
|
|
AND bucket_key = :bucket_key'
|
|
);
|
|
$updateStatement->execute([
|
|
'attempts' => $attempts,
|
|
'window_start' => $windowStart,
|
|
'last_attempt_at' => $now,
|
|
'blocked_until' => $blockedUntil,
|
|
'action_key' => $policy['action'],
|
|
'bucket_key' => $bucketKey,
|
|
]);
|
|
|
|
$updatedRow = [
|
|
'attempts' => $attempts,
|
|
'window_start' => $windowStart,
|
|
'last_attempt_at' => $now,
|
|
'blocked_until' => $blockedUntil,
|
|
];
|
|
}
|
|
|
|
$this->pdo->commit();
|
|
} catch (\Throwable $throwable) {
|
|
if ($this->pdo->inTransaction()) {
|
|
$this->pdo->rollBack();
|
|
}
|
|
|
|
throw $throwable;
|
|
}
|
|
|
|
return $this->buildState($policy, $updatedRow, $nowTs);
|
|
}
|
|
|
|
public function clear(string $action, string $scope): void
|
|
{
|
|
$policy = $this->resolvePolicy($action);
|
|
$statement = $this->pdo->prepare(
|
|
'DELETE FROM rate_limits
|
|
WHERE action_key = :action_key
|
|
AND bucket_key = :bucket_key'
|
|
);
|
|
$statement->execute([
|
|
'action_key' => $policy['action'],
|
|
'bucket_key' => $this->bucketKey($scope),
|
|
]);
|
|
}
|
|
|
|
public function tooManyAttempts(string $action, string $scope): bool
|
|
{
|
|
return !$this->inspect($action, $scope)['allowed'];
|
|
}
|
|
|
|
public function prune(int $staleAfterSeconds = 604800): int
|
|
{
|
|
$cutoff = $this->formatTimestamp(time() - max(3600, $staleAfterSeconds));
|
|
$statement = $this->pdo->prepare(
|
|
'DELETE FROM rate_limits
|
|
WHERE (blocked_until IS NULL AND last_attempt_at < :cutoff)
|
|
OR (blocked_until IS NOT NULL AND blocked_until < :cutoff)'
|
|
);
|
|
$statement->execute(['cutoff' => $cutoff]);
|
|
|
|
return $statement->rowCount();
|
|
}
|
|
|
|
private function buildState(array $policy, ?array $row, int $nowTs): array
|
|
{
|
|
$attempts = 0;
|
|
$blockedUntil = null;
|
|
$retryAfter = 0;
|
|
$allowed = true;
|
|
$resetAtTs = $nowTs + $policy['window_seconds'];
|
|
|
|
if ($row) {
|
|
$windowStartTs = $this->timestampFromDatabase($row['window_start']);
|
|
$blockedUntilTs = $this->timestampFromDatabase($row['blocked_until']);
|
|
|
|
if ($blockedUntilTs !== null && $blockedUntilTs > $nowTs) {
|
|
$attempts = (int) $row['attempts'];
|
|
$blockedUntil = (string) $row['blocked_until'];
|
|
$retryAfter = max(1, $blockedUntilTs - $nowTs);
|
|
$allowed = false;
|
|
$resetAtTs = $blockedUntilTs;
|
|
} elseif ($windowStartTs !== null && ($windowStartTs + $policy['window_seconds']) > $nowTs) {
|
|
$attempts = (int) $row['attempts'];
|
|
$resetAtTs = $windowStartTs + $policy['window_seconds'];
|
|
}
|
|
}
|
|
|
|
return [
|
|
'action' => $policy['action'],
|
|
'allowed' => $allowed,
|
|
'attempts' => $attempts,
|
|
'max_attempts' => $policy['max_attempts'],
|
|
'remaining_attempts' => max(0, $policy['max_attempts'] - $attempts),
|
|
'window_seconds' => $policy['window_seconds'],
|
|
'block_seconds' => $policy['block_seconds'],
|
|
'retry_after_seconds' => $retryAfter,
|
|
'reset_at' => $this->formatTimestamp($resetAtTs),
|
|
'blocked_until' => $blockedUntil,
|
|
];
|
|
}
|
|
|
|
private function resolvePolicy(string $action): array
|
|
{
|
|
$normalizedAction = mb_strtolower(trim($action));
|
|
|
|
if ($normalizedAction === '') {
|
|
throw new RuntimeException('Die Rate-Limit-Aktion ist erforderlich.');
|
|
}
|
|
|
|
$policy = $this->config[$normalizedAction] ?? null;
|
|
|
|
if (!is_array($policy)) {
|
|
throw new RuntimeException(sprintf('Fuer die Aktion "%s" ist kein Rate-Limit konfiguriert.', $normalizedAction));
|
|
}
|
|
|
|
return [
|
|
'action' => $normalizedAction,
|
|
'max_attempts' => max(1, (int) ($policy['max_attempts'] ?? 5)),
|
|
'window_seconds' => max(60, (int) ($policy['window_seconds'] ?? 900)),
|
|
'block_seconds' => max(60, (int) ($policy['block_seconds'] ?? 900)),
|
|
];
|
|
}
|
|
|
|
private function bucketKey(string $scope): string
|
|
{
|
|
$scope = trim($scope);
|
|
|
|
if ($scope === '') {
|
|
throw new RuntimeException('Der Rate-Limit-Scope ist erforderlich.');
|
|
}
|
|
|
|
return hash('sha256', $scope);
|
|
}
|
|
|
|
private function formatTimestamp(int $timestamp): string
|
|
{
|
|
return gmdate('Y-m-d H:i:s', $timestamp);
|
|
}
|
|
|
|
private function timestampFromDatabase(?string $value): ?int
|
|
{
|
|
if ($value === null || trim($value) === '') {
|
|
return null;
|
|
}
|
|
|
|
$date = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $value, $this->utc);
|
|
|
|
if (!$date instanceof DateTimeImmutable) {
|
|
return null;
|
|
}
|
|
|
|
return $date->getTimestamp();
|
|
}
|
|
}
|