소스 검색

fix the security problens

gdias 2 주 전
부모
커밋
ad70c2df7d
10개의 변경된 파일318개의 추가작업 그리고 28개의 파일을 삭제
  1. 1 0
      composer.json
  2. 34 0
      controllers/LoginController.php
  3. 14 1
      controllers/RegisterController.php
  4. 110 0
      libs/RateLimiter.php
  5. 29 0
      libs/Roles.php
  6. 6 2
      middlewares/JwtAuthMiddleware.php
  7. 48 0
      middlewares/RoleMiddleware.php
  8. 5 24
      public/index.php
  9. 70 0
      routes/Dispatcher.php
  10. 1 1
      test_endpoints.sh

+ 1 - 0
composer.json

@@ -5,6 +5,7 @@
             "Lucas\\Bartender\\": "src/",
             "Controllers\\": "controllers/",
             "Middlewares\\": "middlewares/",
+            "Routes\\": "routes/",
             "Models\\": "models/",
             "Services\\": "services/",
             "Utils\\": "utils/",

+ 34 - 0
controllers/LoginController.php

@@ -5,14 +5,32 @@ namespace Controllers;
 use Firebase\JWT\JWT;
 use Libs\Logger;
 use Libs\Payload;
+use Libs\RateLimiter;
 use Libs\Validator;
 use Models\UserModel;
 use Psr\Http\Message\ServerRequestInterface;
 
 class LoginController
 {
+    /** Máximo de tentativas falhas por IP antes do bloqueio temporário. */
+    private const MAX_ATTEMPTS = 5;
+
+    /** Duração da janela de bloqueio, em segundos (15 minutos). */
+    private const WINDOW_SECONDS = 900;
+
     public function __invoke(ServerRequestInterface $request)
     {
+        // Anti brute-force: chaveado por IP de origem. Conta apenas tentativas
+        // falhas; uma autenticação bem-sucedida zera o contador.
+        $rateKey = 'login:' . $this->clientIp($request);
+
+        $retryAfter = RateLimiter::retryAfter($rateKey, self::MAX_ATTEMPTS);
+        if ($retryAfter > 0) {
+            Logger::warning('Login rate limit exceeded', ['ip' => $this->clientIp($request)]);
+            return Payload::fail('Too many login attempts. Try again later.', [], 'E_RATE_LIMIT', 429)
+                ->withHeader('Retry-After', (string) $retryAfter);
+        }
+
         $body = json_decode((string) $request->getBody(), true) ?: [];
 
         $email = $body['email'] ?? $body['user_email'] ?? '';
@@ -36,9 +54,12 @@ class LoginController
         $user = $userModel->validateLogin($email, $password);
 
         if (!$user) {
+            RateLimiter::hit($rateKey, self::WINDOW_SECONDS);
             return Payload::fail('Invalid credentials', [], 'E_VALIDATE', 401);
         }
 
+        RateLimiter::clear($rateKey);
+
         $payload = [
             'sub' => $user['user_id'],
             'email' => $user['user_email'],
@@ -54,4 +75,17 @@ class LoginController
             'user' => $user,
         ]);
     }
+
+    /**
+     * Resolve o IP de origem da requisição. Usa o atributo "remote_addr"
+     * populado pelo framework-X e cai para REMOTE_ADDR dos server params.
+     * Não confiamos em X-Forwarded-For (falsificável) para evitar bypass.
+     */
+    private function clientIp(ServerRequestInterface $request): string
+    {
+        $ip = $request->getAttribute('remote_addr')
+            ?? ($request->getServerParams()['REMOTE_ADDR'] ?? '');
+
+        return $ip !== '' ? (string) $ip : 'unknown';
+    }
 }

+ 14 - 1
controllers/RegisterController.php

@@ -4,6 +4,7 @@ namespace Controllers;
 
 use Libs\Logger;
 use Libs\Payload;
+use Libs\Roles;
 use Libs\Validator;
 use Models\UserModel;
 use Psr\Http\Message\ServerRequestInterface;
@@ -44,13 +45,25 @@ class RegisterController
             ->maxLength('name', 120)
             ->required('phone')->phone('phone')
             ->required('email')->email('email')->maxLength('email', 255)
-            ->required('role')->maxLength('role', 50)
+            ->required('role')->maxLength('role', 10)
             ->required('password')->minLength('password', 8)->maxLength('password', 255);
 
         if ($validator->fails()) {
             return Payload::fail($validator->firstError(), [], 'E_VALIDATE', 400);
         }
 
+        // Só permitimos papéis do catálogo oficial (admin/manager/operator).
+        $normalizedRole = mb_strtolower(trim((string) $role));
+        if (!Roles::isValid($normalizedRole)) {
+            return Payload::fail(
+                'Invalid role. Allowed roles: ' . implode(', ', Roles::ALL),
+                [],
+                'E_VALIDATE',
+                400
+            );
+        }
+        $role = $normalizedRole;
+
         try {
             $companyId = $this->userModel->getCompanyIdByUserId($userId);
             if ($companyId === null) {

+ 110 - 0
libs/RateLimiter.php

@@ -0,0 +1,110 @@
+<?php
+
+namespace Libs;
+
+/**
+ * Rate limiter de janela fixa com armazenamento em arquivo.
+ *
+ * Em produção a aplicação roda sob `php -S` (servidor embutido do PHP), que
+ * atende requisições de forma serial e NÃO mantém estado estático entre elas.
+ * Por isso um contador em memória não funcionaria; persistimos em arquivo.
+ *
+ * Cada chave vira um arquivo JSON: { "count": <int>, "reset_at": <timestamp> }.
+ * Ao expirar a janela (now >= reset_at) o contador zera automaticamente.
+ */
+final class RateLimiter
+{
+    /**
+     * Diretório de armazenamento. Configurável por RATELIMIT_DIR; por padrão
+     * usa o diretório temporário do sistema.
+     */
+    private static function dir(): string
+    {
+        $dir = $_ENV['RATELIMIT_DIR'] ?? (sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'nettown_ratelimit');
+
+        if (!is_dir($dir) && !@mkdir($dir, 0700, true) && !is_dir($dir)) {
+            // Sem diretório utilizável o limiter vira no-op silencioso (fail-open).
+            Logger::warning('RateLimiter storage dir unavailable', ['dir' => $dir]);
+        }
+
+        return $dir;
+    }
+
+    private static function path(string $key): string
+    {
+        return self::dir() . DIRECTORY_SEPARATOR . hash('sha256', $key) . '.json';
+    }
+
+    private static function read(string $key): array
+    {
+        $file = self::path($key);
+        if (!is_file($file)) {
+            return ['count' => 0, 'reset_at' => 0];
+        }
+
+        $raw = @file_get_contents($file);
+        $data = $raw !== false ? json_decode($raw, true) : null;
+
+        if (!is_array($data) || !isset($data['count'], $data['reset_at'])) {
+            return ['count' => 0, 'reset_at' => 0];
+        }
+
+        return ['count' => (int) $data['count'], 'reset_at' => (int) $data['reset_at']];
+    }
+
+    private static function write(string $key, array $data): void
+    {
+        $encoded = json_encode($data);
+        if ($encoded === false) {
+            return;
+        }
+        @file_put_contents(self::path($key), $encoded, LOCK_EX);
+    }
+
+    /**
+     * Segundos restantes de bloqueio, ou 0 se ainda há tentativas disponíveis.
+     */
+    public static function retryAfter(string $key, int $maxAttempts): int
+    {
+        $data = self::read($key);
+        $now = time();
+
+        if ($now >= $data['reset_at']) {
+            return 0;
+        }
+
+        if ($data['count'] < $maxAttempts) {
+            return 0;
+        }
+
+        return $data['reset_at'] - $now;
+    }
+
+    /**
+     * Registra uma tentativa dentro da janela. Inicia uma nova janela quando
+     * a anterior expirou.
+     */
+    public static function hit(string $key, int $windowSeconds): void
+    {
+        $data = self::read($key);
+        $now = time();
+
+        if ($now >= $data['reset_at']) {
+            $data = ['count' => 0, 'reset_at' => $now + $windowSeconds];
+        }
+
+        $data['count']++;
+        self::write($key, $data);
+    }
+
+    /**
+     * Zera o contador (ex.: após autenticação bem-sucedida).
+     */
+    public static function clear(string $key): void
+    {
+        $file = self::path($key);
+        if (is_file($file)) {
+            @unlink($file);
+        }
+    }
+}

+ 29 - 0
libs/Roles.php

@@ -0,0 +1,29 @@
+<?php
+
+namespace Libs;
+
+/**
+ * Catálogo central de papéis (roles) válidos do sistema.
+ *
+ * O papel é persistido em "user.user_role" (VARCHAR(10)) e embarcado no JWT.
+ * Manter a lista num único lugar evita divergência entre validação de
+ * cadastro (RegisterController) e checagem de acesso (RoleMiddleware).
+ */
+final class Roles
+{
+    public const ADMIN = 'admin';
+    public const MANAGER = 'manager';
+    public const OPERATOR = 'operator';
+
+    /** @var string[] Todos os papéis aceitos pelo sistema. */
+    public const ALL = [
+        self::ADMIN,
+        self::MANAGER,
+        self::OPERATOR,
+    ];
+
+    public static function isValid(string $role): bool
+    {
+        return in_array($role, self::ALL, true);
+    }
+}

+ 6 - 2
middlewares/JwtAuthMiddleware.php

@@ -45,7 +45,10 @@ class JwtAuthMiddleware
 
             $pdo = Database::pdo();
 
-            $stmt = $pdo->prepare("SELECT user_id, user_email FROM \"user\" WHERE user_id = :user_id AND user_email = :user_email AND user_deleted_at = 'infinity'");
+            // O papel (role) é lido do banco — fonte autoritativa — e não do JWT.
+            // Assim, alterar/revogar o papel de um usuário tem efeito imediato,
+            // mesmo que ele ainda possua um token antigo válido.
+            $stmt = $pdo->prepare("SELECT user_id, user_email, user_role FROM \"user\" WHERE user_id = :user_id AND user_email = :user_email AND user_deleted_at = 'infinity'");
             $stmt->execute(['user_id' => $userId, 'user_email' => mb_strtolower(trim($userEmail))]);
             $user = $stmt->fetch(\PDO::FETCH_ASSOC);
 
@@ -57,7 +60,8 @@ class JwtAuthMiddleware
                 ->withAttribute('api_user', $user['user_email'])
                 ->withAttribute('api_user_id', $user['user_id'])
                 ->withAttribute('user_email', $user['user_email'])
-                ->withAttribute('user_id', $user['user_id']);
+                ->withAttribute('user_id', $user['user_id'])
+                ->withAttribute('user_role', $user['user_role']);
 
             return $next($request);
 

+ 48 - 0
middlewares/RoleMiddleware.php

@@ -0,0 +1,48 @@
+<?php
+
+namespace Middlewares;
+
+use Libs\Logger;
+use Libs\Payload;
+use Psr\Http\Message\ServerRequestInterface;
+
+/**
+ * Autorização baseada em papel (RBAC).
+ *
+ * Deve ser registrada SEMPRE depois do JwtAuthMiddleware, que popula o
+ * atributo "user_role" a partir do banco. Bloqueia (403) quando o papel do
+ * usuário autenticado não está na lista de papéis permitidos para a rota.
+ *
+ * Uso na rota:
+ *     $app->post('/v1/register', $authJwt, new RoleMiddleware('admin'), Controller::class);
+ */
+class RoleMiddleware
+{
+    /** @var string[] */
+    private array $allowedRoles;
+
+    public function __construct(string ...$allowedRoles)
+    {
+        // Normaliza para comparação case-insensitive e estável.
+        $this->allowedRoles = array_map(
+            static fn (string $role): string => mb_strtolower(trim($role)),
+            $allowedRoles
+        );
+    }
+
+    public function __invoke(ServerRequestInterface $request, callable $next)
+    {
+        $role = mb_strtolower(trim((string) $request->getAttribute('user_role', '')));
+
+        if ($role === '' || !in_array($role, $this->allowedRoles, true)) {
+            Logger::warning('Access denied by role policy', [
+                'user_id' => $request->getAttribute('user_id'),
+                'role' => $role,
+                'allowed' => $this->allowedRoles,
+            ]);
+            return Payload::fail('Forbidden: insufficient permissions', [], 'E_FORBIDDEN', 403);
+        }
+
+        return $next($request);
+    }
+}

+ 5 - 24
public/index.php

@@ -1,7 +1,7 @@
 <?php
 
 use FrameworkX\App;
-use Middlewares\JwtAuthMiddleware;
+use Routes\Dispatcher;
 
 require __DIR__ . '/../vendor/autoload.php';
 
@@ -22,28 +22,9 @@ if (class_exists(Dotenv\Dotenv::class) && file_exists(__DIR__ . '/../.env')) {
 error_reporting(E_ALL);
 
 $app = new App();
-$authJwt = new JwtAuthMiddleware();
-
-// Rotas versionadas sob /v1 para permitir evolução sem quebrar clientes existentes.
-$app->get('/v1/me', $authJwt, \Controllers\MeController::class);
-$app->post('/v1/me/change-password', $authJwt, \Controllers\MeChangePasswordController::class);
-$app->get('/v1/dashboard/overview', $authJwt, \Controllers\DashboardOverviewController::class);
-$app->get('/v1/interactions', $authJwt, \Controllers\InteractionsController::class);
-$app->get('/v1/interactions/details', $authJwt, \Controllers\InteractionDetailsController::class);
-$app->get('/v1/analytics/sentiment/dashboard', $authJwt, \Controllers\AnalyticsSentimentDashboardController::class);
-$app->get('/v1/personas/overview', $authJwt, \Controllers\PersonasOverviewController::class);
-$app->get('/v1/evolution/overview', $authJwt, \Controllers\EvolutionOverviewController::class);
-$app->get('/v1/executive/dashboard', $authJwt, \Controllers\ExecutiveDashboardController::class);
-$app->get('/v1/agents', $authJwt, \Controllers\AgentsController::class);
-$app->get('/v1/sla/configs', $authJwt, \Controllers\SlaConfigsController::class);
-$app->get('/v1/sla/live-status', $authJwt, \Controllers\SlaLiveStatusController::class);
-
-$app->post('/v1/login', \Controllers\LoginController::class);
-// Registro exige autenticação: o novo usuário herda o company_id do solicitante.
-$app->post('/v1/register', $authJwt, \Controllers\RegisterController::class);
-$app->post('/v1/sla/configs', $authJwt, \Controllers\SlaSaveConfigController::class);
-$app->post('/v1/agents', $authJwt, \Controllers\AgentSaveController::class);
-$app->post('/v1/agents/status', $authJwt, \Controllers\AgentStatusController::class);
-$app->post('/v1/agents/escalation', $authJwt, \Controllers\AgentEscalationController::class);
+
+// As rotas (com autenticação JWT e autorização por papel) ficam centralizadas
+// em routes/Dispatcher.php.
+Dispatcher::register($app);
 
 $app->run();

+ 70 - 0
routes/Dispatcher.php

@@ -0,0 +1,70 @@
+<?php
+
+namespace Routes;
+
+use FrameworkX\App;
+use Middlewares\JwtAuthMiddleware;
+use Middlewares\RoleMiddleware;
+use Libs\Roles;
+
+/**
+ * Registro central de rotas da API.
+ *
+ * Mantém a definição das rotas fora do bootstrap (public/index.php),
+ * concentrando aqui a tabela de endpoints, a autenticação (JWT) e a
+ * autorização por papel (RBAC).
+ *
+ * Convenção de middlewares por rota (a ordem importa):
+ *   1) JwtAuthMiddleware  -> autentica e popula "user_role" a partir do banco;
+ *   2) RoleMiddleware     -> autoriza conforme os papéis permitidos.
+ */
+final class Dispatcher
+{
+    public static function register(App $app): void
+    {
+        $auth = new JwtAuthMiddleware();
+
+        // ---- Público (sem autenticação) -------------------------------------
+        // Login é protegido contra brute-force via RateLimiter no controller.
+        $app->post('/v1/login', \Controllers\LoginController::class);
+
+        // ---- Somente admin --------------------------------------------------
+        // Cadastro de usuários: o novo usuário herda o company_id do solicitante.
+        $app->post('/v1/register', $auth, new RoleMiddleware(Roles::ADMIN), \Controllers\RegisterController::class);
+
+        // ---- Autenticado (qualquer papel válido) ----------------------------
+        self::registerAuthenticated($app, $auth);
+    }
+
+    /**
+     * Rotas que exigem apenas um usuário autenticado, sem restrição de papel.
+     * Para restringir uma destas no futuro, basta inserir um RoleMiddleware
+     * entre $auth e o controller (ex.: new RoleMiddleware(Roles::ADMIN, Roles::MANAGER)).
+     */
+    private static function registerAuthenticated(App $app, JwtAuthMiddleware $auth): void
+    {
+        // Perfil do usuário autenticado.
+        $app->get('/v1/me', $auth, \Controllers\MeController::class);
+        $app->post('/v1/me/change-password', $auth, \Controllers\MeChangePasswordController::class);
+
+        // Dashboards e analytics (leitura).
+        $app->get('/v1/dashboard/overview', $auth, \Controllers\DashboardOverviewController::class);
+        $app->get('/v1/interactions', $auth, \Controllers\InteractionsController::class);
+        $app->get('/v1/interactions/details', $auth, \Controllers\InteractionDetailsController::class);
+        $app->get('/v1/analytics/sentiment/dashboard', $auth, \Controllers\AnalyticsSentimentDashboardController::class);
+        $app->get('/v1/personas/overview', $auth, \Controllers\PersonasOverviewController::class);
+        $app->get('/v1/evolution/overview', $auth, \Controllers\EvolutionOverviewController::class);
+        $app->get('/v1/executive/dashboard', $auth, \Controllers\ExecutiveDashboardController::class);
+
+        // SLA.
+        $app->get('/v1/sla/configs', $auth, \Controllers\SlaConfigsController::class);
+        $app->get('/v1/sla/live-status', $auth, \Controllers\SlaLiveStatusController::class);
+        $app->post('/v1/sla/configs', $auth, \Controllers\SlaSaveConfigController::class);
+
+        // Agentes.
+        $app->get('/v1/agents', $auth, \Controllers\AgentsController::class);
+        $app->post('/v1/agents', $auth, \Controllers\AgentSaveController::class);
+        $app->post('/v1/agents/status', $auth, \Controllers\AgentStatusController::class);
+        $app->post('/v1/agents/escalation', $auth, \Controllers\AgentEscalationController::class);
+    }
+}

+ 1 - 1
test_endpoints.sh

@@ -200,7 +200,7 @@ else
     TS=$(date +%s)
 
     # 7.1 Register — cria usuário com e-mail único para não colidir.
-    REG_PAYLOAD=$(printf '{"name":"Teste %s","phone":"5511999990000","email":"teste+%s@example.com","role":"agent","password":"senha12345"}' "$TS" "$TS")
+    REG_PAYLOAD=$(printf '{"name":"Teste %s","phone":"5511999990000","email":"teste+%s@example.com","role":"operator","password":"senha12345"}' "$TS" "$TS")
     check "register" POST "/register" "$REG_PAYLOAD"
 
     # 7.2 Agents — cria um agente e captura o id para os toggles.