feat: implement JWT-based authentication system with repository, service, and middleware layers

This commit is contained in:
2026-09-09 20:02:55 -03:00
parent f6d7d42f10
commit 14b85d28a1
11 changed files with 975 additions and 2 deletions
@@ -0,0 +1,74 @@
<?php
namespace WorkbloomERP\Module\Auth\Controller;
use Throwable;
use InvalidArgumentException;
use RuntimeException;
use KrothiumAPI\Utils\HttpUtil;
use WorkbloomERP\Module\Auth\DTO\LoginDTO;
use WorkbloomERP\Module\Auth\Service\AuthService;
use WorkbloomERP\Security\AuthContext;
class AuthController {
private AuthService $authService;
public function __construct(?AuthService $authService = null) {
$this->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);
}
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace WorkbloomERP\Module\Auth\DTO;
use InvalidArgumentException;
class LoginDTO {
public function __construct(
public readonly string $login,
public readonly string $senha
) {}
/**
* Instancia e valida a partir dos dados da requisição.
*
* @param array $data
* @return self
* @throws InvalidArgumentException
*/
public static function fromArray(array $data): self {
$login = trim((string)($data['login'] ?? $data['email'] ?? $data['nome_usuario'] ?? ''));
$senha = (string)($data['senha'] ?? $data['password'] ?? '');
$errors = [];
if ($login === '') {
$errors['login'] = 'O campo login (e-mail ou nome de usuário) é obrigatório.';
}
if ($senha === '') {
$errors['senha'] = 'O campo senha é obrigatório.';
}
if (!empty($errors)) {
throw new InvalidArgumentException(json_encode($errors, JSON_UNESCAPED_UNICODE));
}
return new self(
login: $login,
senha: $senha
);
}
}
@@ -0,0 +1,151 @@
<?php
namespace WorkbloomERP\Module\Auth\Repository;
use KrothiumAPI\Database\DBManager;
use KrothiumAPI\Database\Contracts\DriverInterface;
class AuthRepository {
private DriverInterface $db;
public function __construct(?DriverInterface $db = null) {
$this->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]);
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace WorkbloomERP\Module\Auth;
use KrothiumAPI\Http\Router;
use WorkbloomERP\Module\Auth\Controller\AuthController;
class Routes {
public static function register(): void {
Router::group('/api/v1/auth', function () {
Router::post('/login', [AuthController::class, 'login']);
Router::get('/me', [AuthController::class, 'me'], ['auth']);
});
}
}
+107
View File
@@ -0,0 +1,107 @@
<?php
namespace WorkbloomERP\Module\Auth\Service;
use RuntimeException;
use WorkbloomERP\Module\Auth\DTO\LoginDTO;
use WorkbloomERP\Module\Auth\Repository\AuthRepository;
use WorkbloomERP\Security\JwtService;
class AuthService {
private AuthRepository $repository;
private JwtService $jwtService;
public function __construct(
?AuthRepository $repository = null,
?JwtService $jwtService = null
) {
$this->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,
];
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace WorkbloomERP\Middleware;
use Throwable;
use KrothiumAPI\Utils\HttpUtil;
use WorkbloomERP\Security\JwtService;
use WorkbloomERP\Security\AuthContext;
class AuthMiddleware {
private JwtService $jwtService;
public function __construct(?JwtService $jwtService = null) {
$this->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.',
];
}
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace WorkbloomERP\Security;
class AuthContext {
private static ?array $user = null;
public static function setUser(array $user): void {
self::$user = $user;
}
public static function getUser(): ?array {
return self::$user;
}
public static function getUserId(): ?int {
return isset(self::$user['id']) ? (int)self::$user['id'] : null;
}
public static function getUserUuid(): ?string {
return self::$user['sub'] ?? self::$user['uuid'] ?? null;
}
public static function clear(): void {
self::$user = null;
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace WorkbloomERP\Security;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Throwable;
use RuntimeException;
class JwtService {
private string $secret;
private int $expiresIn;
public function __construct(?string $secret = null, ?int $expiresIn = null) {
$this->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);
}
}
}
+88
View File
@@ -0,0 +1,88 @@
<?php
require_once __DIR__ . '/../vendor/autoload.php';
use Dotenv\Dotenv;
use Ramsey\Uuid\Uuid;
use KrothiumAPI\Database\DBManager;
$dotenv = Dotenv::createImmutable(__DIR__ . '/..');
$dotenv->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";
+353
View File
@@ -0,0 +1,353 @@
<?php
/**
* Suíte de testes automatizados do fluxo de autenticação (Workbloom ERP).
* Executa todos os cenários requeridos com validação rigorosa de regras.
*/
require_once __DIR__ . '/../vendor/autoload.php';
use Dotenv\Dotenv;
use Ramsey\Uuid\Uuid;
use KrothiumAPI\Database\DBManager;
use WorkbloomERP\Security\JwtService;
use WorkbloomERP\Security\AuthContext;
use WorkbloomERP\Middleware\AuthMiddleware;
use WorkbloomERP\Module\Auth\DTO\LoginDTO;
use WorkbloomERP\Module\Auth\Repository\AuthRepository;
use WorkbloomERP\Module\Auth\Service\AuthService;
$dotenv = Dotenv::createImmutable(__DIR__ . '/..');
$dotenv->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);
}
+14 -2
View File
@@ -20,7 +20,7 @@ KrothiumAPI::init(config: [
'root' => __DIR__ . '/..', 'root' => __DIR__ . '/..',
'src' => __DIR__ . '/../app', 'src' => __DIR__ . '/../app',
'module' => __DIR__ . '/../app/Module', 'module' => __DIR__ . '/../app/Module',
'storage' => __DIR__ . '/var/www/workbloomerp-api/storage', 'storage' => __DIR__ . '/../storage',
'component' => __DIR__ . '/../app/Common/Component', 'component' => __DIR__ . '/../app/Common/Component',
], ],
'router' => [ '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 // Dispara o roteador