Implement Enterprise Logic (Redis, Phone Auth, Full Schema)
This commit is contained in:
@@ -4,22 +4,16 @@ namespace App\Middlewares;
|
||||
|
||||
use App\Core\Request;
|
||||
use App\Core\Response;
|
||||
use App\Core\RedisClient;
|
||||
|
||||
/**
|
||||
* Rate Limit Middleware
|
||||
* Limits the number of requests per IP address using file-based counters.
|
||||
* Protects sensitive endpoints (login, register) from Brute Force attacks.
|
||||
* 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
|
||||
{
|
||||
/**
|
||||
* 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)
|
||||
@@ -31,40 +25,36 @@ class RateLimitMiddleware
|
||||
public function handle(Request $request, Response $response): void
|
||||
{
|
||||
$ip = $this->getClientIp();
|
||||
$key = 'rate_' . md5($ip . '_' . $request->getPath());
|
||||
$key = 'rate_limit:' . 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;
|
||||
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
|
||||
}
|
||||
// If window expired, fall through and reset (overwrite with fresh data below)
|
||||
|
||||
// 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());
|
||||
}
|
||||
|
||||
$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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user