Files
Siro/backend/core/Auth/RateLimiter.php
2026-06-16 02:14:35 +03:00

125 lines
4.7 KiB
PHP

<?php
// ============================================================
// core/Auth/RateLimiter.php
// Sliding Window Rate Limiting باستخدام Redis
// ============================================================
class RateLimiter
{
private ?Redis $redis;
// حدود مختلفة لكل نوع endpoint
private const LIMITS = [
'login' => ['requests' => 5, 'window' => 60], // 5 محاولات / دقيقة
'otp' => ['requests' => 3, 'window' => 300], // 3 محاولات / 5 دقائق
'register' => ['requests' => 3, 'window' => 3600], // 3 محاولات / ساعة
'api' => ['requests' => 120, 'window' => 60], // 120 طلب / دقيقة
'ride' => ['requests' => 30, 'window' => 60], // 30 طلب / دقيقة
'upload' => ['requests' => 10, 'window' => 300], // 10 رفع / 5 دقائق
];
public function __construct(?Redis $redis)
{
$this->redis = $redis;
}
// ── فحص الحد ─────────────────────────────────────────────
// $identifier: IP:userId أو IP فقط
// $type: login | otp | api | ride | upload
public function check(string $identifier, string $type = 'api'): bool
{
if (!$this->redis) {
// HIGH-01 FIX: fallback مع ملف بدلاً من تمرير كل الطلبات
return $this->fileBasedCheck($identifier, $type);
}
$limit = self::LIMITS[$type] ?? self::LIMITS['api'];
$window = $limit['window'];
$max = $limit['requests'];
$key = "rate:{$type}:{$identifier}";
$current = $this->redis->incr($key);
if ($current === 1) {
$this->redis->expire($key, $window);
}
return $current <= $max;
}
// ── تطبيق الحد وإيقاف الطلب إن تجاوز ─────────────────────
public function enforce(string $identifier, string $type = 'api'): void
{
if (!$this->check($identifier, $type)) {
$limit = self::LIMITS[$type] ?? self::LIMITS['api'];
$window = $limit['window'];
error_log("[RATE_LIMIT] Blocked: $identifier | type: $type");
http_response_code(429);
header("Retry-After: $window");
echo json_encode([
'error' => 'Too many requests. Please slow down.',
'retry_after' => $window,
]);
exit;
}
}
// ── بناء معرّف المستخدم ────────────────────────────────────
public static function identifier(?string $userId = null): string
{
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
return $userId ? "{$ip}:{$userId}" : $ip;
}
// ── إعادة تعيين عداد (مثلاً بعد تسجيل دخول ناجح) ───────────
public function reset(string $identifier, string $type = 'login'): void
{
if ($this->redis) {
$this->redis->del("rate:{$type}:{$identifier}");
} else {
// HIGH-01: مسح ملف الفل باك عند إعادة التعيين
$key = self::sanitizeKey("rate:{$type}:{$identifier}");
$tmpFile = sys_get_temp_dir() . "/rate_{$key}.json";
if (file_exists($tmpFile)) {
@unlink($tmpFile);
}
}
}
// ── Fallback باستخدام ملفات مؤقتة عند تعطل Redis ───────────
private function fileBasedCheck(string $identifier, string $type): bool
{
$limit = self::LIMITS[$type] ?? self::LIMITS['api'];
$window = $limit['window'];
$max = $limit['requests'];
$key = self::sanitizeKey("rate:{$type}:{$identifier}");
$tmpFile = sys_get_temp_dir() . "/rate_{$key}.json";
$now = time();
$data = [];
if (file_exists($tmpFile)) {
$data = json_decode(file_get_contents($tmpFile), true) ?: [];
}
// تنظيف النوافذ القديمة
$data = array_filter($data, fn($ts) => $ts > ($now - $window));
if (count($data) >= $max) {
error_log("[RATE_LIMIT_FB] File-based block: $identifier | type: $type");
return false;
}
$data[] = $now;
file_put_contents($tmpFile, json_encode($data));
return true;
}
private static function sanitizeKey(string $key): string
{
return preg_replace('/[^a-zA-Z0-9_\-:]/', '_', $key);
}
}