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"); } } }