From 14b85d28a1ad268e3eaba75a72f2063e8a443d11 Mon Sep 17 00:00:00 2001 From: Claudecio Martins Date: Wed, 9 Sep 2026 20:02:55 -0300 Subject: [PATCH] feat: implement JWT-based authentication system with repository, service, and middleware layers --- app/Module/Auth/Controller/AuthController.php | 74 ++++ app/Module/Auth/DTO/LoginDTO.php | 42 +++ app/Module/Auth/Repository/AuthRepository.php | 151 ++++++++ app/Module/Auth/Routes.php | 14 + app/Module/Auth/Service/AuthService.php | 107 ++++++ app/Shared/Middleware/AuthMiddleware.php | 43 +++ app/Shared/Security/AuthContext.php | 26 ++ app/Shared/Security/JwtService.php | 63 ++++ bin/create-user.php | 88 +++++ bin/test-auth.php | 353 ++++++++++++++++++ public/index.php | 16 +- 11 files changed, 975 insertions(+), 2 deletions(-) create mode 100644 app/Module/Auth/Controller/AuthController.php create mode 100644 app/Module/Auth/DTO/LoginDTO.php create mode 100644 app/Module/Auth/Repository/AuthRepository.php create mode 100644 app/Module/Auth/Routes.php create mode 100644 app/Module/Auth/Service/AuthService.php create mode 100644 app/Shared/Middleware/AuthMiddleware.php create mode 100644 app/Shared/Security/AuthContext.php create mode 100644 app/Shared/Security/JwtService.php create mode 100644 bin/create-user.php create mode 100644 bin/test-auth.php diff --git a/app/Module/Auth/Controller/AuthController.php b/app/Module/Auth/Controller/AuthController.php new file mode 100644 index 0000000..4914865 --- /dev/null +++ b/app/Module/Auth/Controller/AuthController.php @@ -0,0 +1,74 @@ +authService = $authService ?? new AuthService(); + } + + /** + * Endpoint de login da API. + * POST /api/v1/auth/login + */ + public function login(): void { + $input = HttpUtil::getRequestBody('POST'); + + if (empty($input) || !is_array($input)) { + HttpUtil::error('Corpo da requisição inválido ou vazio.', 400); + return; + } + + try { + $dto = LoginDTO::fromArray($input); + } catch (InvalidArgumentException $e) { + $errors = json_decode($e->getMessage(), true) ?? ['login' => $e->getMessage()]; + HttpUtil::error('Dados inválidos para autenticação.', 422, ['validation_errors' => $errors]); + return; + } + + try { + $result = $this->authService->login($dto); + HttpUtil::success($result, 'Autenticação realizada com sucesso.'); + } catch (RuntimeException $e) { + $code = $e->getCode(); + $statusCode = ($code >= 400 && $code <= 499) ? $code : 401; + HttpUtil::error($e->getMessage(), $statusCode); + } catch (Throwable) { + HttpUtil::error('Erro ao processar autenticação.', 500); + } + } + + /** + * Endpoint de consulta do perfil do usuário autenticado. + * GET /api/v1/auth/me + */ + public function me(): void { + $userUuid = AuthContext::getUserUuid(); + + if ($userUuid === null) { + HttpUtil::error('Usuário não autenticado.', 401); + return; + } + + try { + $profile = $this->authService->getProfile($userUuid); + HttpUtil::success($profile, 'Perfil recuperado com sucesso.'); + } catch (RuntimeException $e) { + $code = $e->getCode(); + $statusCode = ($code >= 400 && $code <= 499) ? $code : 404; + HttpUtil::error($e->getMessage(), $statusCode); + } catch (Throwable) { + HttpUtil::error('Erro ao buscar perfil do usuário.', 500); + } + } +} diff --git a/app/Module/Auth/DTO/LoginDTO.php b/app/Module/Auth/DTO/LoginDTO.php new file mode 100644 index 0000000..fc7153e --- /dev/null +++ b/app/Module/Auth/DTO/LoginDTO.php @@ -0,0 +1,42 @@ +db = $db ?? DBManager::getConnection(); + } + + /** + * Localiza usuário ativo e não deletado por e-mail ou nome de usuário. + * Retorna apenas colunas estritamente necessárias. NUNCA SELECT *. + * + * @param string $login E-mail ou nome_usuario + * @return array|null + */ + public function findByLogin(string $login): ?array { + $sql = " + SELECT + u.id, + u.uuid, + u.nome_completo, + u.cpf, + u.email, + u.nome_usuario, + u.senha_hash, + u.ativo + FROM usuario u + WHERE (u.email = :login OR u.nome_usuario = :login) + AND u.deleted_at IS NULL + LIMIT 1 + "; + + return $this->db->fetchOne($sql, ['login' => $login]); + } + + /** + * Localiza dados cadastrais do usuário por UUID (sem expor id ou hash de senha). + * + * @param string $uuid + * @return array|null + */ + public function findByUuid(string $uuid): ?array { + $sql = " + SELECT + u.uuid, + u.nome_completo, + u.cpf, + u.email, + u.nome_usuario, + u.ativo, + u.created_at + FROM usuario u + WHERE u.uuid = :uuid + AND u.deleted_at IS NULL + LIMIT 1 + "; + + return $this->db->fetchOne($sql, ['uuid' => $uuid]); + } + + /** + * Localiza dados cadastrais do usuário por ID (compatibilidade interna). + * + * @param int $id + * @return array|null + */ + public function findById(int $id): ?array { + $sql = " + SELECT + u.uuid, + u.nome_completo, + u.cpf, + u.email, + u.nome_usuario, + u.ativo, + u.created_at + FROM usuario u + WHERE u.id = :id + AND u.deleted_at IS NULL + LIMIT 1 + "; + + return $this->db->fetchOne($sql, ['id' => $id]); + } + + /** + * Lista empresas vinculadas ao usuário por UUID (retornando apenas e.uuid como identificador). + * NUNCA SELECT *. + * + * @param string $usuarioUuid + * @return array + */ + public function findEmpresasByUsuarioUuid(string $usuarioUuid): array { + $sql = " + SELECT + e.uuid, + e.nome_empresarial, + e.nome_fantasia, + e.cnpj, + e.tipo_estabelecimento, + e.status, + ue.perfil, + ue.acesso_todas_filiais + FROM usuario_empresa ue + INNER JOIN empresa e + ON e.id = ue.empresa_id + INNER JOIN usuario u + ON u.id = ue.usuario_id + WHERE u.uuid = :usuario_uuid + AND e.deleted_at IS NULL + AND u.deleted_at IS NULL + ORDER BY e.tipo_estabelecimento ASC, e.nome_empresarial ASC + "; + + return $this->db->fetchAll($sql, ['usuario_uuid' => $usuarioUuid]); + } + + /** + * Lista empresas vinculadas ao usuário por ID interno. + * Retorna exclusivamente e.uuid como identificador da empresa. + * + * @param int $usuarioId + * @return array + */ + public function findEmpresasByUsuarioId(int $usuarioId): array { + $sql = " + SELECT + e.uuid, + e.nome_empresarial, + e.nome_fantasia, + e.cnpj, + e.tipo_estabelecimento, + e.status, + ue.perfil, + ue.acesso_todas_filiais + FROM usuario_empresa ue + INNER JOIN empresa e + ON e.id = ue.empresa_id + WHERE ue.usuario_id = :usuario_id + AND e.deleted_at IS NULL + ORDER BY e.tipo_estabelecimento ASC, e.nome_empresarial ASC + "; + + return $this->db->fetchAll($sql, ['usuario_id' => $usuarioId]); + } +} diff --git a/app/Module/Auth/Routes.php b/app/Module/Auth/Routes.php new file mode 100644 index 0000000..b2164ac --- /dev/null +++ b/app/Module/Auth/Routes.php @@ -0,0 +1,14 @@ +repository = $repository ?? new AuthRepository(); + $this->jwtService = $jwtService ?? new JwtService(); + } + + /** + * Realiza a autenticação do usuário com validação de credenciais e status. + * + * @param LoginDTO $dto + * @return array + * @throws RuntimeException + */ + public function login(LoginDTO $dto): array { + $user = $this->repository->findByLogin($dto->login); + + // Prevenção contra enumeração de usuários: mensagem genérica para usuário inexistente + if ($user === null) { + throw new RuntimeException('Credenciais inválidas.', 401); + } + + // Validação de conta ativa + if (empty($user['ativo'])) { + throw new RuntimeException('Usuário inativo no sistema.', 403); + } + + // Validação segura de senha com hash + if (!password_verify($dto->senha, $user['senha_hash'])) { + throw new RuntimeException('Credenciais inválidas.', 401); + } + + $userUuid = (string)$user['uuid']; + $empresas = $this->repository->findEmpresasByUsuarioUuid($userUuid); + + $tokenPayload = [ + 'sub' => $userUuid, + 'email' => $user['email'], + 'nome_usuario' => $user['nome_usuario'], + 'nome_completo' => $user['nome_completo'], + ]; + + $tokenData = $this->jwtService->generateToken($tokenPayload); + + return [ + 'token' => $tokenData['token'], + 'token_type' => 'Bearer', + 'expires_in' => $tokenData['expires_in'], + 'expires_at' => $tokenData['expires_at'], + 'usuario' => [ + 'uuid' => $user['uuid'], + 'nome_completo' => $user['nome_completo'], + 'cpf' => $user['cpf'], + 'email' => $user['email'], + 'nome_usuario' => $user['nome_usuario'], + ], + 'empresas' => $empresas, + ]; + } + + /** + * Retorna os dados do usuário autenticado a partir do UUID (sem expor credenciais nem ID inteiro). + * + * @param string $userUuid + * @return array + * @throws RuntimeException + */ + public function getProfile(string $userUuid): array { + $user = $this->repository->findByUuid($userUuid); + + if ($user === null) { + throw new RuntimeException('Usuário não encontrado.', 404); + } + + if (empty($user['ativo'])) { + throw new RuntimeException('Usuário inativo no sistema.', 403); + } + + $empresas = $this->repository->findEmpresasByUsuarioUuid($userUuid); + + return [ + 'usuario' => [ + 'uuid' => $user['uuid'], + 'nome_completo' => $user['nome_completo'], + 'cpf' => $user['cpf'], + 'email' => $user['email'], + 'nome_usuario' => $user['nome_usuario'], + 'ativo' => (bool)$user['ativo'], + 'created_at' => $user['created_at'], + ], + 'empresas' => $empresas, + ]; + } +} diff --git a/app/Shared/Middleware/AuthMiddleware.php b/app/Shared/Middleware/AuthMiddleware.php new file mode 100644 index 0000000..0de645a --- /dev/null +++ b/app/Shared/Middleware/AuthMiddleware.php @@ -0,0 +1,43 @@ +jwtService = $jwtService ?? new JwtService(); + } + + /** + * Executa a validação de autenticação Bearer Token. + * Retorna true em caso de sucesso ou array estruturado de bloqueio (401). + */ + public function handle(): array|bool { + $token = HttpUtil::getBearerToken(); + + if (empty($token)) { + return [ + 'block' => true, + 'response_code' => 401, + 'message' => 'Token de autenticação não fornecido.', + ]; + } + + try { + $payload = $this->jwtService->validateToken($token); + AuthContext::setUser($payload); + return true; + } catch (Throwable) { + return [ + 'block' => true, + 'response_code' => 401, + 'message' => 'Token inválido ou expirado.', + ]; + } + } +} diff --git a/app/Shared/Security/AuthContext.php b/app/Shared/Security/AuthContext.php new file mode 100644 index 0000000..0fa7a93 --- /dev/null +++ b/app/Shared/Security/AuthContext.php @@ -0,0 +1,26 @@ +secret = $secret ?? $_ENV['JWT_SECRET'] ?? getenv('JWT_SECRET') ?: ''; + if (empty($this->secret)) { + throw new RuntimeException('JWT secret is not configured.'); + } + + $this->expiresIn = $expiresIn ?? (int)($_ENV['JWT_EXPIRES_IN'] ?? getenv('JWT_EXPIRES_IN') ?: 86400); + } + + /** + * Gera um novo token JWT para o payload fornecido. + * + * @param array $userData Dados do usuário e contexto + * @return array{token: string, expires_in: int, expires_at: int} + */ + public function generateToken(array $userData): array { + $now = time(); + $expiresAt = $now + $this->expiresIn; + + $payload = array_merge($userData, [ + 'iat' => $now, + 'nbf' => $now, + 'exp' => $expiresAt, + ]); + + $token = JWT::encode($payload, $this->secret, 'HS256'); + + return [ + 'token' => $token, + 'expires_in' => $this->expiresIn, + 'expires_at' => $expiresAt, + ]; + } + + /** + * Decodifica e valida o token JWT. + * Retorna o payload em array ou lança uma exceção caso inválido ou expirado. + * + * @param string $token + * @return array + * @throws RuntimeException + */ + public function validateToken(string $token): array { + try { + $decoded = JWT::decode($token, new Key($this->secret, 'HS256')); + return (array)$decoded; + } catch (Throwable $e) { + throw new RuntimeException('Token inválido ou expirado: ' . $e->getMessage(), 401, $e); + } + } +} diff --git a/bin/create-user.php b/bin/create-user.php new file mode 100644 index 0000000..6fbc81d --- /dev/null +++ b/bin/create-user.php @@ -0,0 +1,88 @@ +load(); + +$db = DBManager::getConnection(); + +$nome = $argv[1] ?? 'Administrador Workbloom'; +$email = $argv[2] ?? 'admin@workbloom.com.br'; +$usuario = $argv[3] ?? 'admin'; +$senha = $argv[4] ?? 'Admin@123456'; + +echo "=== Criação de Usuário Administrador ===\n"; +echo "Nome: {$nome}\n"; +echo "Email: {$email}\n"; +echo "Usuário: {$usuario}\n"; + +// Verifica se já existe por e-mail ou nome_usuario +$existente = $db->fetchOne( + "SELECT id, uuid, email, nome_usuario FROM usuario WHERE email = :email OR nome_usuario = :usuario LIMIT 1", + ['email' => $email, 'usuario' => $usuario] +); + +if ($existente !== null) { + echo "Erro: Usuário já cadastrado com este e-mail ou nome de usuário (ID: {$existente['id']}).\n"; + exit(1); +} + +$userUuid = Uuid::uuid7()->toString(); +$senhaHash = password_hash($senha, PASSWORD_ARGON2ID); + +// Cria o usuário +$db->insert('usuario', [ + 'uuid' => $userUuid, + 'nome_completo' => $nome, + 'email' => $email, + 'nome_usuario' => $usuario, + 'senha_hash' => $senhaHash, + 'ativo' => 1, +]); + +$userId = (int)$db->fetchColumn( + "SELECT id FROM usuario WHERE uuid = :uuid LIMIT 1", + ['uuid' => $userUuid] +); + +echo "Usuário criado com sucesso! ID: {$userId}, UUID: {$userUuid}\n"; + +// Verifica se existe ao menos uma empresa +$empresaId = $db->fetchColumn("SELECT id FROM empresa WHERE deleted_at IS NULL LIMIT 1"); + +if (!$empresaId) { + echo "Nenhuma empresa cadastrada. Criando Empresa Matriz de teste...\n"; + $empresaUuid = Uuid::uuid7()->toString(); + $db->insert('empresa', [ + 'uuid' => $empresaUuid, + 'tipo_estabelecimento' => 'MATRIZ', + 'status' => 'ATIVO', + 'nome_empresarial' => 'WORKBLOOM SISTEMAS LTDA', + 'nome_fantasia' => 'Workbloom Matriz', + 'cnpj' => '12345678000195', + 'regime_tributario' => 1, + ]); + + $empresaId = (int)$db->fetchColumn( + "SELECT id FROM empresa WHERE uuid = :uuid LIMIT 1", + ['uuid' => $empresaUuid] + ); + echo "Empresa criada! ID: {$empresaId}, UUID: {$empresaUuid}\n"; +} else { + $empresaId = (int)$empresaId; +} + +// Vincula usuário à empresa como ADMIN +$db->insert('usuario_empresa', [ + 'usuario_id' => $userId, + 'empresa_id' => $empresaId, + 'perfil' => 'ADMIN', + 'acesso_todas_filiais' => 1, +]); + +echo "Vínculo criado na empresa ID: {$empresaId} como ADMIN!\n"; +echo "Concluído com sucesso!\n"; diff --git a/bin/test-auth.php b/bin/test-auth.php new file mode 100644 index 0000000..b750b88 --- /dev/null +++ b/bin/test-auth.php @@ -0,0 +1,353 @@ +load(); + +$db = DBManager::getConnection(); +$repo = new AuthRepository($db); +$jwt = new JwtService(); +$service = new AuthService($repo, $jwt); + +$passed = 0; +$failed = 0; + +function assertTest(string $title, bool $condition, ?string $detail = null): void { + global $passed, $failed; + if ($condition) { + echo " [PASS] {$title}\n"; + $passed++; + } else { + echo " [FAIL] {$title}" . ($detail ? " - {$detail}" : "") . "\n"; + $failed++; + } +} + +echo "\n=======================================================\n"; +echo " INICIANDO BATERIA DE TESTES DE AUTENTICAÇÃO\n"; +echo "=======================================================\n\n"; + +// --------------------------------------------------------------------------- +// 1. Login com e-mail válido +// --------------------------------------------------------------------------- +echo "Cenário 1: Login com e-mail válido\n"; +try { + $dto = LoginDTO::fromArray(['login' => 'admin@workbloom.com.br', 'senha' => 'Admin@123456']); + $res = $service->login($dto); + + assertTest("Geração do token JWT", !empty($res['token'])); + assertTest("Tipo do token é Bearer", $res['token_type'] === 'Bearer'); + assertTest("Tempo de expiração retornado", $res['expires_in'] > 0); + assertTest("Dados do usuário presentes", isset($res['usuario']['email']) && $res['usuario']['email'] === 'admin@workbloom.com.br'); + assertTest("UUID presente no usuário", !empty($res['usuario']['uuid'])); + assertTest("ID numérico NÃO exposto no usuário", !isset($res['usuario']['id'])); + assertTest("Senha hash NÃO exposta no retorno", !isset($res['usuario']['senha_hash'])); + assertTest("Empresas vinculadas presentes", is_array($res['empresas']) && count($res['empresas']) >= 1); + assertTest("UUID presente na empresa", !empty($res['empresas'][0]['uuid'])); + assertTest("ID numérico NÃO exposto na empresa", !isset($res['empresas'][0]['id'])); + assertTest("Perfil ADMIN identificado na empresa", $res['empresas'][0]['perfil'] === 'ADMIN'); + assertTest("Flag de acesso a todas as filiais presente", isset($res['empresas'][0]['acesso_todas_filiais'])); +} catch (Throwable $e) { + assertTest("Execução do login por e-mail", false, $e->getMessage()); +} + +// --------------------------------------------------------------------------- +// 2. Login com nome de usuário válido +// --------------------------------------------------------------------------- +echo "\nCenário 2: Login com nome de usuário válido\n"; +try { + $dto = LoginDTO::fromArray(['login' => 'admin', 'senha' => 'Admin@123456']); + $res = $service->login($dto); + assertTest("Login por username autenticado com sucesso", !empty($res['token'])); + assertTest("ID do usuário consistente", $res['usuario']['nome_usuario'] === 'admin'); +} catch (Throwable $e) { + assertTest("Execução do login por username", false, $e->getMessage()); +} + +// --------------------------------------------------------------------------- +// 3. Login com senha inválida +// --------------------------------------------------------------------------- +echo "\nCenário 3: Login com senha inválida\n"; +try { + $dto = LoginDTO::fromArray(['login' => 'admin', 'senha' => 'SenhaIncorreta@999']); + $service->login($dto); + assertTest("Bloqueio de senha incorreta", false, "Deveria ter lançado RuntimeException"); +} catch (RuntimeException $e) { + assertTest("Rejeitado com código 401", $e->getCode() === 401); + assertTest("Mensagem genérica de credenciais inválidas", $e->getMessage() === 'Credenciais inválidas.'); +} catch (Throwable $e) { + assertTest("Bloqueio de senha incorreta", false, $e->getMessage()); +} + +// --------------------------------------------------------------------------- +// 4. Login com usuário inexistente (prevenção contra enumeração) +// --------------------------------------------------------------------------- +echo "\nCenário 4: Tentativa com usuário inexistente\n"; +try { + $dto = LoginDTO::fromArray(['login' => 'usuario_fantasma_xyz@workbloom.com.br', 'senha' => 'Admin@123456']); + $service->login($dto); + assertTest("Bloqueio de usuário inexistente", false, "Deveria ter lançado RuntimeException"); +} catch (RuntimeException $e) { + assertTest("Rejeitado com código 401", $e->getCode() === 401); + assertTest("Mesma mensagem genérica do erro de senha (anti-enumeração)", $e->getMessage() === 'Credenciais inválidas.'); +} catch (Throwable $e) { + assertTest("Bloqueio de usuário inexistente", false, $e->getMessage()); +} + +// --------------------------------------------------------------------------- +// Criação temporária de usuários de teste para cenários avançados +// --------------------------------------------------------------------------- +$testUserUuidInativo = Uuid::uuid7()->toString(); +$testUserUuidDeletado = Uuid::uuid7()->toString(); +$testUserUuidSemEmpresa = Uuid::uuid7()->toString(); +$testUserUuidMultiEmpresa = Uuid::uuid7()->toString(); + +$senhaHashPadrao = password_hash('Teste@123456', PASSWORD_ARGON2ID); + +// 5. Usuário inativo +$db->insert('usuario', [ + 'uuid' => $testUserUuidInativo, + 'nome_completo' => 'Usuário Inativo Teste', + 'email' => 'inativo@workbloom.com.br', + 'nome_usuario' => 'user_inativo', + 'senha_hash' => $senhaHashPadrao, + 'ativo' => 0, +]); +$idInativo = (int)$db->fetchColumn("SELECT id FROM usuario WHERE uuid = :uuid LIMIT 1", ['uuid' => $testUserUuidInativo]); + +// 6. Usuário logicamente excluído (deleted_at) +$db->insert('usuario', [ + 'uuid' => $testUserUuidDeletado, + 'nome_completo' => 'Usuário Deletado Teste', + 'email' => 'deletado@workbloom.com.br', + 'nome_usuario' => 'user_deletado', + 'senha_hash' => $senhaHashPadrao, + 'ativo' => 1, +]); +$idDeletado = (int)$db->fetchColumn("SELECT id FROM usuario WHERE uuid = :uuid LIMIT 1", ['uuid' => $testUserUuidDeletado]); +$db->execute("UPDATE usuario SET deleted_at = CURRENT_TIMESTAMP WHERE id = :id", ['id' => $idDeletado]); + +// 7. Usuário sem vínculo com empresa +$db->insert('usuario', [ + 'uuid' => $testUserUuidSemEmpresa, + 'nome_completo' => 'Usuário Sem Empresa', + 'email' => 'semempresa@workbloom.com.br', + 'nome_usuario' => 'user_semempresa', + 'senha_hash' => $senhaHashPadrao, + 'ativo' => 1, +]); +$idSemEmpresa = (int)$db->fetchColumn("SELECT id FROM usuario WHERE uuid = :uuid LIMIT 1", ['uuid' => $testUserUuidSemEmpresa]); + +// 8. Usuário multiempresa com perfis distintos +$db->insert('usuario', [ + 'uuid' => $testUserUuidMultiEmpresa, + 'nome_completo' => 'Usuário Multiempresa', + 'email' => 'multiempresa@workbloom.com.br', + 'nome_usuario' => 'user_multiempresa', + 'senha_hash' => $senhaHashPadrao, + 'ativo' => 1, +]); +$idMultiEmpresa = (int)$db->fetchColumn("SELECT id FROM usuario WHERE uuid = :uuid LIMIT 1", ['uuid' => $testUserUuidMultiEmpresa]); + +// Cria 2 empresas adicionais de teste +$empresaUuid1 = Uuid::uuid7()->toString(); +$empresaUuid2 = Uuid::uuid7()->toString(); +$db->insert('empresa', [ + 'uuid' => $empresaUuid1, + 'tipo_estabelecimento' => 'MATRIZ', + 'status' => 'ATIVO', + 'nome_empresarial' => 'EMPRESA ALFA LTDA', + 'nome_fantasia' => 'Alfa Matriz', + 'cnpj' => '11111111000101', + 'regime_tributario' => 1, +]); +$idEmpresaAlfa = (int)$db->fetchColumn("SELECT id FROM empresa WHERE uuid = :uuid LIMIT 1", ['uuid' => $empresaUuid1]); + +$db->insert('empresa', [ + 'uuid' => $empresaUuid2, + 'tipo_estabelecimento' => 'FILIAL', + 'status' => 'ATIVO', + 'nome_empresarial' => 'EMPRESA BETA FILIAL LTDA', + 'nome_fantasia' => 'Beta Filial', + 'cnpj' => '11111111000202', + 'regime_tributario' => 1, +]); +$idEmpresaBeta = (int)$db->fetchColumn("SELECT id FROM empresa WHERE uuid = :uuid LIMIT 1", ['uuid' => $empresaUuid2]); + +// Vincula usuário multiempresa às 2 empresas com perfis diferentes +$db->insert('usuario_empresa', [ + 'usuario_id' => $idMultiEmpresa, + 'empresa_id' => $idEmpresaAlfa, + 'perfil' => 'ADMIN', + 'acesso_todas_filiais' => 1, +]); +$db->insert('usuario_empresa', [ + 'usuario_id' => $idMultiEmpresa, + 'empresa_id' => $idEmpresaBeta, + 'perfil' => 'EMISSOR', + 'acesso_todas_filiais' => 0, +]); + +try { + // --------------------------------------------------------------------------- + // Cenário 5: Tentativa com usuário inativo + // --------------------------------------------------------------------------- + echo "\nCenário 5: Tentativa com usuário inativo (ativo = false)\n"; + try { + $dto = LoginDTO::fromArray(['login' => 'inativo@workbloom.com.br', 'senha' => 'Teste@123456']); + $service->login($dto); + assertTest("Bloqueio de usuário inativo", false, "Deveria ter lançado RuntimeException 403"); + } catch (RuntimeException $e) { + assertTest("Rejeitado com código 403", $e->getCode() === 403); + assertTest("Mensagem indica usuário inativo", str_contains($e->getMessage(), 'inativo')); + } + + // --------------------------------------------------------------------------- + // Cenário 6: Tentativa com usuário com deleted_at preenchido + // --------------------------------------------------------------------------- + echo "\nCenário 6: Tentativa com usuário excluído (deleted_at preenchido)\n"; + try { + $dto = LoginDTO::fromArray(['login' => 'deletado@workbloom.com.br', 'senha' => 'Teste@123456']); + $service->login($dto); + assertTest("Bloqueio de usuário excluído", false, "Deveria ter lançado RuntimeException 401"); + } catch (RuntimeException $e) { + assertTest("Rejeitado como não encontrado (401)", $e->getCode() === 401); + assertTest("Mensagem genérica de credenciais inválidas", $e->getMessage() === 'Credenciais inválidas.'); + } + + // --------------------------------------------------------------------------- + // Cenário 7: Usuário sem vínculo com empresa + // --------------------------------------------------------------------------- + echo "\nCenário 7: Usuário sem vínculo com empresa\n"; + try { + $dto = LoginDTO::fromArray(['login' => 'semempresa@workbloom.com.br', 'senha' => 'Teste@123456']); + $res = $service->login($dto); + assertTest("Login efetuado mesmo sem empresa vinculada", !empty($res['token'])); + assertTest("Lista de empresas vazia", is_array($res['empresas']) && count($res['empresas']) === 0); + } catch (Throwable $e) { + assertTest("Login sem empresa", false, $e->getMessage()); + } + + // --------------------------------------------------------------------------- + // Cenário 8: Usuário multiempresa com múltiplos perfis + // --------------------------------------------------------------------------- + echo "\nCenário 8: Usuário com múltiplas empresas e perfis distintos\n"; + try { + $dto = LoginDTO::fromArray(['login' => 'multiempresa@workbloom.com.br', 'senha' => 'Teste@123456']); + $res = $service->login($dto); + assertTest("Autenticação com sucesso", !empty($res['token'])); + assertTest("Exatamente 2 empresas vinculadas", count($res['empresas']) === 2); + + $perfilMap = []; + $filiaisMap = []; + foreach ($res['empresas'] as $emp) { + $perfilMap[$emp['nome_fantasia']] = $emp['perfil']; + $filiaisMap[$emp['nome_fantasia']] = (bool)$emp['acesso_todas_filiais']; + } + + assertTest("Perfil ADMIN na Empresa Alfa", ($perfilMap['Alfa Matriz'] ?? null) === 'ADMIN'); + assertTest("Flag todas as filiais = true na Empresa Alfa", ($filiaisMap['Alfa Matriz'] ?? null) === true); + assertTest("Perfil EMISSOR na Empresa Beta", ($perfilMap['Beta Filial'] ?? null) === 'EMISSOR'); + assertTest("Flag todas as filiais = false na Empresa Beta", ($filiaisMap['Beta Filial'] ?? null) === false); + } catch (Throwable $e) { + assertTest("Multiempresa", false, $e->getMessage()); + } + + // --------------------------------------------------------------------------- + // Cenário 9: Validação do Token JWT e Middleware + // --------------------------------------------------------------------------- + echo "\nCenário 9: Validação do Token JWT e Middleware de Proteção\n"; + // Token forjado / adulterado + try { + $jwt->validateToken('token.invalido.falso'); + assertTest("Rejeição de token adulterado", false, "Deveria ter lançado RuntimeException"); + } catch (RuntimeException $e) { + assertTest("Token forjado rejeitado com código 401", $e->getCode() === 401); + } + + // Token expirado + try { + $expiredJwt = new JwtService(expiresIn: -10); + $expiredData = $expiredJwt->generateToken(['id' => 1, 'sub' => 'uuid-teste']); + $jwt->validateToken($expiredData['token']); + assertTest("Rejeição de token expirado", false, "Deveria ter lançado RuntimeException"); + } catch (RuntimeException $e) { + assertTest("Token expirado rejeitado com código 401", $e->getCode() === 401); + } + + // Middleware sem header + unset($_SERVER['HTTP_AUTHORIZATION']); + $middleware = new AuthMiddleware($jwt); + $midRes = $middleware->handle(); + assertTest("Middleware bloqueia requisição sem token", is_array($midRes) && ($midRes['block'] ?? false) === true); + assertTest("Código retornado pelo middleware é 401", ($midRes['response_code'] ?? null) === 401); + + // Middleware com token válido + $tokenValido = $res['token']; + $_SERVER['HTTP_AUTHORIZATION'] = "Bearer {$tokenValido}"; + AuthContext::clear(); + $midResValido = $middleware->handle(); + assertTest("Middleware aceita requisição com Bearer Token válido", $midResValido === true); + assertTest("AuthContext populado com o UUID do usuário", AuthContext::getUserUuid() === $testUserUuidMultiEmpresa); + + // --------------------------------------------------------------------------- + // Cenário 10: Consulta ao Perfil (endpoint me / getProfile) + // --------------------------------------------------------------------------- + echo "\nCenário 10: Consulta ao Perfil do usuário autenticado (GET /api/v1/auth/me)\n"; + try { + $profile = $service->getProfile(AuthContext::getUserUuid()); + assertTest("Perfil recuperado com sucesso via UUID", !empty($profile['usuario']['uuid'])); + assertTest("ID numérico NÃO exposto no perfil do usuário", !isset($profile['usuario']['id'])); + assertTest("Nome completo presente", $profile['usuario']['nome_completo'] === 'Usuário Multiempresa'); + assertTest("Senha hash NÃO exposta no perfil", !isset($profile['usuario']['senha_hash'])); + assertTest("Lista de empresas presente no perfil", count($profile['empresas']) === 2); + assertTest("ID numérico NÃO exposto nas empresas do perfil", !isset($profile['empresas'][0]['id'])); + assertTest("UUID presente nas empresas do perfil", !empty($profile['empresas'][0]['uuid'])); + } catch (Throwable $e) { + assertTest("Consulta de perfil", false, $e->getMessage()); + } + +} finally { + // Limpeza dos dados temporários criados nos testes + $db->execute("DELETE FROM usuario_empresa WHERE usuario_id IN (:u1, :u2, :u3, :u4)", [ + 'u1' => $idInativo, + 'u2' => $idDeletado, + 'u3' => $idSemEmpresa, + 'u4' => $idMultiEmpresa, + ]); + $db->execute("DELETE FROM usuario WHERE id IN (:u1, :u2, :u3, :u4)", [ + 'u1' => $idInativo, + 'u2' => $idDeletado, + 'u3' => $idSemEmpresa, + 'u4' => $idMultiEmpresa, + ]); + $db->execute("DELETE FROM empresa WHERE id IN (:e1, :e2)", [ + 'e1' => $idEmpresaAlfa, + 'e2' => $idEmpresaBeta, + ]); + unset($_SERVER['HTTP_AUTHORIZATION']); + AuthContext::clear(); +} + +echo "\n=======================================================\n"; +echo " RESULTADO DOS TESTES: {$passed} passaram, {$failed} falharam\n"; +echo "=======================================================\n\n"; + +if ($failed > 0) { + exit(1); +} diff --git a/public/index.php b/public/index.php index 5f1d8fe..9cf1342 100755 --- a/public/index.php +++ b/public/index.php @@ -20,7 +20,7 @@ KrothiumAPI::init(config: [ 'root' => __DIR__ . '/..', 'src' => __DIR__ . '/../app', 'module' => __DIR__ . '/../app/Module', - 'storage' => __DIR__ . '/var/www/workbloomerp-api/storage', + 'storage' => __DIR__ . '/../storage', 'component' => __DIR__ . '/../app/Common/Component', ], 'router' => [ @@ -43,7 +43,19 @@ KrothiumAPI::init(config: [ ] ]); -// Importa rotas da API v0 +// ====================================== +// Registra Middlewares +// ====================================== +use KrothiumAPI\Http\Router; +use WorkbloomERP\Middleware\AuthMiddleware; +use WorkbloomERP\Module\Auth\Routes as AuthRoutes; + +Router::defineMiddleware('auth', [AuthMiddleware::class, 'handle']); + +// ====================================== +// Importa rotas dos módulos +// ====================================== +AuthRoutes::register(); // ============================ // Dispara o roteador