95 lines
2.7 KiB
PHP
95 lines
2.7 KiB
PHP
<?php
|
|
|
|
namespace App\Middlewares;
|
|
|
|
use App\Core\Request;
|
|
use App\Core\Response;
|
|
|
|
/**
|
|
* Rate Limit Middleware
|
|
* Limits the number of requests per IP address using file-based counters.
|
|
* Protects sensitive endpoints (login, register) from Brute Force attacks.
|
|
*/
|
|
class RateLimitMiddleware
|
|
{
|
|
/**
|
|
* Maximum allowed requests within the time window
|
|
*/
|
|
private int $maxAttempts;
|
|
|
|
/**
|
|
* Time window in seconds
|
|
*/
|
|
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_' . md5($ip . '_' . $request->getPath());
|
|
|
|
$storageDir = APP_ROOT . '/storage/rate_limits';
|
|
if (!is_dir($storageDir)) {
|
|
mkdir($storageDir, 0750, true);
|
|
}
|
|
|
|
$filePath = $storageDir . '/' . $key . '.json';
|
|
|
|
$data = ['count' => 0, 'expires_at' => time() + $this->decaySeconds];
|
|
|
|
if (file_exists($filePath)) {
|
|
$raw = json_decode(file_get_contents($filePath), true);
|
|
if ($raw && isset($raw['expires_at']) && $raw['expires_at'] > time()) {
|
|
// Window still active — use existing data
|
|
$data = $raw;
|
|
}
|
|
// If window expired, fall through and reset (overwrite with fresh data below)
|
|
}
|
|
|
|
$data['count']++;
|
|
|
|
if ($data['count'] > $this->maxAttempts) {
|
|
$retryAfter = max(0, $data['expires_at'] - time());
|
|
$response->setHeader('Retry-After', (string)$retryAfter);
|
|
$response->json([
|
|
'error' => 'Too Many Requests',
|
|
'message' => "You have exceeded the maximum number of {$this->maxAttempts} attempts. Please try again in {$retryAfter} seconds."
|
|
], 429);
|
|
return;
|
|
}
|
|
|
|
// Persist the updated counter
|
|
file_put_contents($filePath, json_encode($data), LOCK_EX);
|
|
}
|
|
|
|
/**
|
|
* 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';
|
|
}
|
|
}
|