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,
];
}
}