85 lines
2.7 KiB
PHP
85 lines
2.7 KiB
PHP
<?php
|
|
|
|
namespace App\Middlewares;
|
|
|
|
use App\Core\Request;
|
|
use App\Core\Response;
|
|
use App\Core\RedisClient;
|
|
|
|
/**
|
|
* Rate Limit Middleware (Redis Powered)
|
|
* Limits the number of requests per IP address using Redis atomic counters.
|
|
* Protects sensitive endpoints (login, register, otp) from Brute Force attacks.
|
|
*/
|
|
class RateLimitMiddleware
|
|
{
|
|
private int $maxAttempts;
|
|
private int $decaySeconds;
|
|
|
|
public function __construct(int $maxAttempts = 5, int $decaySeconds = 60)
|
|
{
|
|
$this->maxAttempts = $maxAttempts;
|
|
$this->decaySeconds = $decaySeconds;
|
|
}
|
|
|
|
public function handle(Request $request, Response $response): void
|
|
{
|
|
$ip = $this->getClientIp();
|
|
$key = 'rate_limit:' . md5($ip . '_' . $request->getPath());
|
|
|
|
try {
|
|
$redis = RedisClient::getInstance();
|
|
|
|
$current = $redis->get($key);
|
|
|
|
if ($current !== false && (int)$current >= $this->maxAttempts) {
|
|
$retryAfter = $redis->ttl($key);
|
|
$retryAfter = $retryAfter > 0 ? $retryAfter : $this->decaySeconds;
|
|
|
|
$response->setHeader('Retry-After', (string)$retryAfter);
|
|
$response->json([
|
|
'error' => 'Too Many Requests',
|
|
'message' => "لقد تجاوزت الحد الأقصى للمحاولات ({$this->maxAttempts}). يرجى المحاولة بعد {$retryAfter} ثانية."
|
|
], 429);
|
|
exit; // End request
|
|
}
|
|
|
|
// Increment atomically
|
|
$count = $redis->incr($key);
|
|
if ($count === 1) {
|
|
// First request, set expiration
|
|
$redis->expire($key, $this->decaySeconds);
|
|
}
|
|
} catch (\Exception $e) {
|
|
// If Redis fails, log it but don't block the request completely,
|
|
// or we could choose to block it. We'll let it pass to avoid downtime.
|
|
error_log("RateLimit Redis Error: " . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get real client IP, accounting for proxies
|
|
*/
|
|
private function getClientIp(): string
|
|
{
|
|
$headers = [
|
|
'HTTP_CF_CONNECTING_IP', // Cloudflare
|
|
'HTTP_X_FORWARDED_FOR',
|
|
'HTTP_X_REAL_IP',
|
|
'REMOTE_ADDR'
|
|
];
|
|
|
|
foreach ($headers as $header) {
|
|
if (!empty($_SERVER[$header])) {
|
|
// X-Forwarded-For can be a comma-separated list; take first
|
|
$ip = trim(explode(',', $_SERVER[$header])[0]);
|
|
if (filter_var($ip, FILTER_VALIDATE_IP)) {
|
|
return $ip;
|
|
}
|
|
}
|
|
}
|
|
|
|
return '0.0.0.0';
|
|
}
|
|
}
|