RateLimitMiddleware.php

7.81 KB
04/08/2025 06:04
PHP
RateLimitMiddleware.php
<?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");
        }
    }
}