<?php
namespace App\Middleware;
use App\Core\Database;
use App\Core\Security;
/**
* Rate Limiting Middleware
* Prevents abuse by limiting requests per IP/user within time windows
*/
class RateLimitMiddleware
{
private Database $db;
private array $config;
public function __construct()
{
$this->db = Database::getInstance();
$this->config = require __DIR__.'/../../config/app.php';
}
/**
* Handle rate limiting
*/
public function handle(): bool
{
if (!$this->config['rate_limit_enabled']) {
return true;
}
$identifier = $this->getIdentifier();
$action = $this->getAction();
// Check current rate limit status
$currentAttempts = $this->getCurrentAttempts($identifier, $action);
$maxAttempts = $this->getMaxAttempts($action);
$windowSeconds = $this->getWindowSeconds($action);
if ($currentAttempts >= $maxAttempts) {
$this->sendRateLimitResponse($maxAttempts, $windowSeconds);
return false;
}
// Record this attempt
$this->recordAttempt($identifier, $action, $windowSeconds);
// Set rate limit headers
$this->setRateLimitHeaders($currentAttempts + 1, $maxAttempts, $windowSeconds);
return true;
}
/**
* Get identifier for rate limiting (IP or user ID)
*/
private function getIdentifier(): string
{
// If user is authenticated, use user ID
if (isset($_SERVER['HTTP_AUTHORIZATION'])) {
// Extract user ID from JWT token if available
$token = str_replace('Bearer ', '', $_SERVER['HTTP_AUTHORIZATION']);
$userId = $this->getUserIdFromToken($token);
if ($userId) {
return 'user_'.$userId;
}
}
// Fall back to IP address
return 'ip_'.Security::getClientIp();
}
/**
* Get action type for different rate limits
*/
private function getAction(): string
{
$uri = $_SERVER['REQUEST_URI'];
$method = $_SERVER['REQUEST_METHOD'];
// Different actions have different rate limits
if (strpos($uri, '/auth/login') !== false) {
return 'login';
}
if (strpos($uri, '/auth/register') !== false) {
return 'register';
}
if (strpos($uri, '/auth/forgot-password') !== false) {
return 'password_reset';
}
if (strpos($uri, '/admin/') !== false) {
return 'admin_api';
}
if ($method === 'POST' || $method === 'PUT' || $method === 'DELETE') {
return 'write_api';
}
return 'general_api';
}
/**
* Get current attempts for identifier and action
*/
private function getCurrentAttempts(string $identifier, string $action): int
{
$resetTime = date('Y-m-d H:i:s', time() - $this->getWindowSeconds($action));
// Clean up old records first
$this->db->execute(
"DELETE FROM rate_limits WHERE reset_time < ?",
[$resetTime]
);
// Get current attempts
$result = $this->db->queryOne(
"SELECT attempts FROM rate_limits WHERE identifier = ? AND action = ? AND reset_time > ?",
[$identifier, $action, $resetTime]
);
return $result['attempts'] ?? 0;
}
/**
* Get maximum attempts for action type
*/
private function getMaxAttempts(string $action): int
{
$limits = [
'login' => 5, // 5 login attempts
'register' => 3, // 3 registration attempts
'password_reset' => 3, // 3 password reset attempts
'admin_api' => 50, // 50 admin API calls
'write_api' => 30, // 30 write operations
'general_api' => 100 // 100 general API calls
];
return $limits[$action] ?? $this->config['rate_limit_requests'];
}
/**
* Get time window for action type (in seconds)
*/
private function getWindowSeconds(string $action): int
{
$windows = [
'login' => 900, // 15 minutes
'register' => 3600, // 1 hour
'password_reset' => 3600, // 1 hour
'admin_api' => 3600, // 1 hour
'write_api' => 3600, // 1 hour
'general_api' => 3600 // 1 hour
];
return $windows[$action] ?? $this->config['rate_limit_window'];
}
/**
* Record rate limit attempt
*/
private function recordAttempt(string $identifier, string $action, int $windowSeconds): void
{
$resetTime = date('Y-m-d H:i:s', time() + $windowSeconds);
// Try to update existing record
$updated = $this->db->execute(
"UPDATE rate_limits SET attempts = attempts + 1, updated_at = CURRENT_TIMESTAMP
WHERE identifier = ? AND action = ? AND reset_time > ?",
[$identifier, $action, date('Y-m-d H:i:s')]
);
// If no existing record, create new one
if ($updated === 0) {
$this->db->execute(
"INSERT INTO rate_limits (identifier, action, attempts, reset_time)
VALUES (?, ?, 1, ?)
ON DUPLICATE KEY UPDATE attempts = attempts + 1, updated_at = CURRENT_TIMESTAMP",
[$identifier, $action, $resetTime]
);
}
}
/**
* Send rate limit exceeded response
*/
private function sendRateLimitResponse(int $maxAttempts, int $windowSeconds): void
{
$retryAfter = $windowSeconds;
http_response_code(429);
header('Content-Type: application/json');
header("Retry-After: {$retryAfter}");
header("X-RateLimit-Limit: {$maxAttempts}");
header("X-RateLimit-Remaining: 0");
header("X-RateLimit-Reset: ".(time() + $retryAfter));
$response = [
'error' => 'Too Many Requests',
'message' => 'Rate limit exceeded. Please try again later.',
'retry_after' => $retryAfter,
'limit' => $maxAttempts
];
echo json_encode($response);
exit(); // Stop execution here
// Log rate limit violation
Security::logSecurityEvent('rate_limit_exceeded', [
'identifier' => $this->getIdentifier(),
'action' => $this->getAction(),
'max_attempts' => $maxAttempts,
'window_seconds' => $windowSeconds
]);
}
/**
* Set rate limit headers
*/
private function setRateLimitHeaders(int $currentAttempts, int $maxAttempts, int $windowSeconds): void
{
if (headers_sent()) {
return;
}
$remaining = max(0, $maxAttempts - $currentAttempts);
$resetTime = time() + $windowSeconds;
header("X-RateLimit-Limit: {$maxAttempts}");
header("X-RateLimit-Remaining: {$remaining}");
header("X-RateLimit-Reset: {$resetTime}");
}
/**
* Extract user ID from JWT token
*/
private function getUserIdFromToken(string $token): ?int
{
try {
$parts = explode('.', $token);
if (count($parts) !== 3) {
return null;
}
$payload = json_decode(base64_decode($parts[1]), true);
return $payload['user_id'] ?? null;
} catch (\Exception $e) {
return null;
}
}
/**
* Clean up old rate limit records (called periodically)
*/
public static function cleanup(): void
{
$db = Database::getInstance();
// Delete records older than 24 hours
$cutoff = date('Y-m-d H:i:s', time() - 86400);
$deleted = $db->execute(
"DELETE FROM rate_limits WHERE reset_time < ?",
[$cutoff]
);
if ($deleted > 0) {
error_log("Rate limit cleanup: Deleted {$deleted} old records");
}
}
}