From 6ff14fcbee9ffb6ae6fa2ebf8ef22fb0a6ebec16 Mon Sep 17 00:00:00 2001 From: din9xtr Date: Sat, 17 Jan 2026 01:54:33 +0700 Subject: [PATCH] throttle middleware --- public/index.php | 3 +- src/Controllers/DashboardController.php | 4 +- src/Middlewares/ThrottleMiddleware.php | 133 +++++++++++++++++------- 3 files changed, 100 insertions(+), 40 deletions(-) diff --git a/public/index.php b/public/index.php index a1a5541..cc1b16c 100755 --- a/public/index.php +++ b/public/index.php @@ -11,6 +11,7 @@ use Din9xtrCloud\Controllers\StorageController; use Din9xtrCloud\Controllers\StorageTusController; use Din9xtrCloud\Middlewares\AuthMiddleware; use Din9xtrCloud\Middlewares\CsrfMiddleware; +use Din9xtrCloud\Middlewares\ThrottleMiddleware; use Din9xtrCloud\Router; use Din9xtrCloud\Storage\Drivers\LocalStorageDriver; use Din9xtrCloud\Storage\Drivers\StorageDriverInterface; @@ -119,7 +120,7 @@ $routes = static function (RouteCollector $r): void { // route,middlewares $router = new Router($routes, $container); -//$router->middlewareFor('/', AuthMiddleware::class); +$router->middlewareFor('/login', ThrottleMiddleware::class); //$router->middlewareFor('/login', AuthMiddleware::class); diff --git a/src/Controllers/DashboardController.php b/src/Controllers/DashboardController.php index f516eb6..9ce38d2 100755 --- a/src/Controllers/DashboardController.php +++ b/src/Controllers/DashboardController.php @@ -47,9 +47,7 @@ final readonly class DashboardController 'percent' => $percent, ]; } - - $this->logger->info('Dashboard loaded successfully'); - + } catch (Throwable $exception) { $this->logger->error($exception->getMessage(), ['exception' => $exception]); return View::display(new DashboardViewModel( diff --git a/src/Middlewares/ThrottleMiddleware.php b/src/Middlewares/ThrottleMiddleware.php index 224e2f5..0a9d6e1 100755 --- a/src/Middlewares/ThrottleMiddleware.php +++ b/src/Middlewares/ThrottleMiddleware.php @@ -3,63 +3,124 @@ namespace Din9xtrCloud\Middlewares; use Nyholm\Psr7\Response; +use PDO; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; -use PDO; +use Psr\Log\LoggerInterface; -final class ThrottleMiddleware implements MiddlewareInterface +final readonly class ThrottleMiddleware implements MiddlewareInterface { - private int $maxAttempts = 5; - private int $lockTime = 300; // seconds + private const int MAX_ATTEMPTS = 10; + private const int LOCK_TIME = 300; - public function __construct(private readonly PDO $db) + public function __construct( + private PDO $db, + private LoggerInterface $logger, + ) { } - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + public function process( + ServerRequestInterface $request, + RequestHandlerInterface $handler + ): ResponseInterface { $ip = getClientIp(); - - $stmt = $this->db->prepare("SELECT * FROM login_throttle WHERE ip = :ip ORDER BY id DESC LIMIT 1"); - $stmt->execute(['ip' => $ip]); - $row = $stmt->fetch(PDO::FETCH_ASSOC); - $now = time(); - $blockedUntil = null; - if ($row) { - if ($row['attempts'] >= $this->maxAttempts && ($now - $row['last_attempt']) < $this->lockTime) { - $blockedUntil = $row['last_attempt'] + $this->lockTime; - } - } + $row = $this->getLastAttempt($ip); - if ($blockedUntil && $now < $blockedUntil) { - return new Response(429, [], 'Too Many Requests'); - } + if ($row !== null) { + $attempts = (int)$row['attempts']; + $lastAttempt = (int)$row['last_attempt']; - $response = $handler->handle($request); + if ($now - $lastAttempt > self::LOCK_TIME) { + $this->clearAttempts($ip); + $this->logger->info('Throttle window expired, reset attempts', [ + 'ip' => $ip, + ]); - if ($request->getUri()->getPath() === '/login') { - $attempts = ($row['attempts'] ?? 0); - if ($response->getStatusCode() === 302) { - $this->db->prepare(" - INSERT INTO login_throttle (ip, attempts, last_attempt) - VALUES (:ip, 0, :last_attempt) - ")->execute(['ip' => $ip, 'last_attempt' => $now]); - } else { - $attempts++; - $this->db->prepare(" - INSERT INTO login_throttle (ip, attempts, last_attempt) - VALUES (:ip, :attempts, :last_attempt) - ")->execute([ + $row = null; + } elseif ($attempts >= self::MAX_ATTEMPTS) { + $retryAfter = ($lastAttempt + self::LOCK_TIME) - $now; + + $this->logger->warning('Login throttled', [ 'ip' => $ip, 'attempts' => $attempts, - 'last_attempt' => $now + 'retry_after' => $retryAfter, ]); + + return new Response( + 429, + ['Retry-After' => (string)max(1, $retryAfter)], + 'Too Many Requests' + ); } } - return $response; + + $this->registerAttempt($ip, $now, $row !== null); + + return $handler->handle($request); + } + + private function getLastAttempt(string $ip): ?array + { + $stmt = $this->db->prepare( + "SELECT id, attempts, last_attempt + FROM login_throttle + WHERE ip = :ip + ORDER BY id DESC + LIMIT 1" + ); + + $stmt->execute(['ip' => $ip]); + + return $stmt->fetch(PDO::FETCH_ASSOC) ?: null; + } + + private function registerAttempt(string $ip, int $now, bool $exists): void + { + if ($exists) { + $stmt = $this->db->prepare( + "UPDATE login_throttle + SET attempts = attempts + 1, + last_attempt = :time + WHERE id = ( + SELECT id FROM login_throttle + WHERE ip = :ip + ORDER BY id DESC + LIMIT 1 + )" + ); + + $this->logger->info('Throttle attempt incremented', [ + 'ip' => $ip, + ]); + } else { + $stmt = $this->db->prepare( + "INSERT INTO login_throttle (ip, attempts, last_attempt) + VALUES (:ip, 1, :time)" + ); + + $this->logger->info('Throttle first attempt registered', [ + 'ip' => $ip, + ]); + } + + $stmt->execute([ + 'ip' => $ip, + 'time' => $now, + ]); + } + + private function clearAttempts(string $ip): void + { + $stmt = $this->db->prepare( + "DELETE FROM login_throttle WHERE ip = :ip" + ); + + $stmt->execute(['ip' => $ip]); } }