first commit
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
namespace WorkbloomERP\Module\v0\Auth\Controllers;
|
||||
|
||||
use KrothiumAPI\Utils\HttpUtil;
|
||||
use WorkbloomERP\Utils\SanitizeUtil;
|
||||
use WorkbloomERP\Exceptions\AppException;
|
||||
use WorkbloomERP\Module\v0\Auth\Factories\ServiceFactory;
|
||||
|
||||
class AuthController {
|
||||
/**
|
||||
* Endpoint de API para autenticação de usuário (**Login**).
|
||||
*
|
||||
* Este método gerencia a interface HTTP (POST) para validar as credenciais do usuário.
|
||||
* Ele atua como uma camada fina (*Thin Controller*), responsável por capturar o
|
||||
* payload da requisição, sanitizar os dados de entrada para prevenir injeções
|
||||
* e delegar a verificação de identidade e gestão de sessão/tokens para o `AuthService`.
|
||||
*
|
||||
* ---
|
||||
* ## Fluxo de Execução
|
||||
* 1. **Captura e Validação:** Extrai o corpo da requisição. Se o payload estiver vazio, interrompe com erro 400 (Bad Request).
|
||||
* 2. **Sanitização:** Aplica `SanitizeUtil::string()` nos campos de login e senha para garantir a limpeza dos dados antes do processamento de negócio.
|
||||
* 3. **Processamento de Negócio:** Invoca `AuthService::login()`, que realiza a consulta de credenciais.
|
||||
* 4. **Normalização de Resposta:**
|
||||
* - **Sucesso (200 OK):** Retorna os dados de sessão (ex: token de acesso).
|
||||
* - **Tratamento de Erros:** Captura `AppException` (credenciais inválidas, conta bloqueada, etc.), formatando a resposta para que o frontend receba um objeto de erro estruturado.
|
||||
*
|
||||
* ---
|
||||
* ## Observações Técnicas
|
||||
* - A responsabilidade de verificar a hash da senha e gerar o token de acesso reside exclusivamente no `AuthService`, isolando a lógica de segurança do controlador.
|
||||
* - Utiliza `HttpUtil` para garantir consistência no formato da resposta JSON, independentemente do sucesso ou falha da autenticação.
|
||||
*
|
||||
* @return void O método encerra o processamento enviando uma resposta HTTP JSON ao cliente.
|
||||
* @see AuthService::login() Para os detalhes sobre a regra de autenticação e geração de tokens.
|
||||
*/
|
||||
public function login(): void {
|
||||
try {
|
||||
$form = HttpUtil::getRequestBody(form_type: 'POST');
|
||||
if (empty($form)) {
|
||||
throw new AppException(message: 'Requisição inválida.', code: 400);
|
||||
}
|
||||
|
||||
$authService = ServiceFactory::makeAuthService();
|
||||
$response = $authService->login(login: SanitizeUtil::string(value: $form['login']), senha: SanitizeUtil::string(value: $form['senha']));
|
||||
|
||||
HttpUtil::jsonResponse(
|
||||
response_code: $response['response_code'] ?? 200,
|
||||
message: $response['message'] ?? 'Login realizado com sucesso.',
|
||||
output: $response['output'] ?? null
|
||||
);
|
||||
} catch(AppException $e) {
|
||||
HttpUtil::jsonResponse(
|
||||
response_code: $e->getCode() ?? 500,
|
||||
message: $e->getMessage(),
|
||||
output: $e->getDetails() ? ['errors' => $e->getDetails()] : null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpoint de API para a seleção de contexto de empresa ativa.
|
||||
*
|
||||
* Este método gerencia a interface HTTP (POST) para permitir que um usuário
|
||||
* autenticado alterne sua empresa ativa no sistema multi-empresa. O controlador
|
||||
* realiza a captura do payload, sanitiza o UUID da empresa e delega a
|
||||
* complexa lógica de troca de sessão e regeneração de tokens ao `AuthService`.
|
||||
*
|
||||
* ---
|
||||
* ## Fluxo de Execução
|
||||
* 1. **Captura e Validação:** Extrai o corpo da requisição. Se o payload estiver vazio ou malformado, retorna erro 400 (Bad Request).
|
||||
* 2. **Sanitização:** Aplica `SanitizeUtil::string()` no `empresa_uuid` para garantir a integridade dos dados recebidos.
|
||||
* 3. **Processamento de Negócio:** Invoca `AuthService::selectCompany()`, que valida o acesso à empresa, gera um novo JWT e registra a sessão.
|
||||
* 4. **Normalização de Resposta:**
|
||||
* - **Sucesso (200 OK):** Retorna o novo token de acesso contendo o contexto da empresa selecionada.
|
||||
* - **Tratamento de Erros:** Captura `AppException` (ex: empresa inexistente, falta de permissão), retornando uma resposta JSON estruturada com os detalhes do erro para o cliente.
|
||||
*
|
||||
* ---
|
||||
* ## Arquitetura de Seleção de Contexto
|
||||
*
|
||||
*
|
||||
* ---
|
||||
* ## Observações Técnicas
|
||||
* - Segue o padrão "Thin Controller", onde a orquestração de infraestrutura (cache, banco de dados, JWT) é encapsulada na camada de serviço.
|
||||
* - Este endpoint é fundamental para aplicações multi-tenant ou multi-empresa, permitindo que o usuário altere seu "escopo" de atuação sem precisar realizar um re-login completo.
|
||||
*
|
||||
* @return void O método encerra o processamento enviando uma resposta HTTP JSON ao cliente.
|
||||
* @see AuthService::selectCompany() Para os detalhes sobre a regra de negócio aplicada na troca de contexto.
|
||||
*/
|
||||
public function selectCompany(): void {
|
||||
try {
|
||||
$form = HttpUtil::getRequestBody(form_type: 'POST');
|
||||
if (empty($form)) {
|
||||
throw new AppException(message: 'Requisição inválida.', code: 400);
|
||||
}
|
||||
|
||||
$authService = ServiceFactory::makeAuthService();
|
||||
$response = $authService->selectCompany(empresa_uuid: SanitizeUtil::string(value: $form['empresa_uuid']));
|
||||
|
||||
HttpUtil::jsonResponse(
|
||||
response_code: $response['response_code'] ?? 200,
|
||||
message: $response['message'] ?? 'Empresa selecionada com sucesso.',
|
||||
output: $response['output'] ?? null
|
||||
);
|
||||
} catch(AppException $e) {
|
||||
HttpUtil::jsonResponse(
|
||||
response_code: $e->getCode() ?? 500,
|
||||
message: $e->getMessage(),
|
||||
output: $e->getDetails() ? ['errors' => $e->getDetails()] : null
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
namespace WorkbloomERP\Module\v0\Auth\Factories;
|
||||
|
||||
use WorkbloomERP\Services\DBService;
|
||||
use WorkbloomERP\Module\v0\Auth\Services\AuthService;
|
||||
use WorkbloomERP\Module\v0\Empresa\Repos\EmpresaRepo;
|
||||
use WorkbloomERP\Module\v0\Usuario\Repos\UsuarioRepo;
|
||||
use WorkbloomERP\Module\v0\Usuario\Repos\UsuarioSessionRepo;
|
||||
use WorkbloomERP\Module\v0\Usuario\Repos\UsuarioEmpresaRepo;
|
||||
|
||||
class ServiceFactory {
|
||||
public static function makeAuthService() {
|
||||
$db = new DBService();
|
||||
return new AuthService(
|
||||
db: $db,
|
||||
usuarioRepo: new UsuarioRepo(db: $db),
|
||||
empresaRepo: new EmpresaRepo(db: $db),
|
||||
usuarioSessionRepo: new UsuarioSessionRepo(db: $db),
|
||||
usuarioEmpresaRepo: new UsuarioEmpresaRepo(db: $db),
|
||||
);
|
||||
}
|
||||
|
||||
public static function makeUsuarioSessionRepo() {
|
||||
$db = new DBService();
|
||||
return new UsuarioSessionRepo(
|
||||
db: $db
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
<?php
|
||||
namespace WorkbloomERP\Module\v0\Auth\Middlewares;
|
||||
|
||||
use WorkbloomERP\Utils\JwtUtil;
|
||||
use KrothiumAPI\Utils\HttpUtil;
|
||||
use WorkbloomERP\Utils\CacheUtil;
|
||||
use WorkbloomERP\Utils\CypherUtil;
|
||||
use WorkbloomERP\Exceptions\AppException;
|
||||
use WorkbloomERP\Module\v0\Auth\Factories\ServiceFactory;
|
||||
use WorkbloomERP\Module\v0\Usuario\Models\UsuarioSessionModel;
|
||||
|
||||
class AuthMiddleware {
|
||||
/**
|
||||
* Valida se o token JWT da requisição atual corresponde a um estado de "Login Inicial".
|
||||
*
|
||||
* Este método atua como um guardião de segurança (Guard) para rotas que exigem
|
||||
* ações obrigatórias do usuário logo após o primeiro acesso ao sistema, como a
|
||||
* troca obrigatória de senha ou a configuração de MFA (Autenticação de Dois Fatores).
|
||||
*
|
||||
* ---
|
||||
* ## Fluxo de Execução
|
||||
* 1. **Extração:** Obtém o token JWT do cabeçalho `Authorization: Bearer`.
|
||||
* 2. **Validação:** Verifica a integridade e assinatura do token através do `JwtUtil`.
|
||||
* 3. **Verificação de Claim:** Decodifica o token e confirma se a claim `initial_login` está presente e definida como `true`.
|
||||
* 4. **Retorno:**
|
||||
* - Retorna `true` se o token for válido e corresponder ao estado de login inicial.
|
||||
* - Retorna um `array` estruturado contendo detalhes do erro caso a validação falhe.
|
||||
*
|
||||
* ---
|
||||
* ## Observações Técnicas
|
||||
* - O método utiliza variáveis de ambiente (`$_ENV`) para configurar o `JwtUtil`, garantindo que as configurações de `secretKey`, `algorithm` e `ttl` estejam sincronizadas com a política de segurança da aplicação.
|
||||
* - Este método captura internamente instâncias de `AppException`, tratando o fluxo de erro de forma silenciosa para o chamador, retornando um status de erro estruturado em vez de propagar a exceção.
|
||||
*
|
||||
* @return true|array{status: string, response_code: int, message: string}
|
||||
* Retorna `true` em caso de sucesso. Retorna um array com o erro caso o token seja inválido, ausente ou não possua a claim necessária.
|
||||
*/
|
||||
public static function isInitialLogin(): true|array {
|
||||
try {
|
||||
// Valida o token JWT presente no header Authorization (Bearer Token)
|
||||
|
||||
$bearerToken = HttpUtil::getBearerToken();
|
||||
if (!$bearerToken) {
|
||||
throw new AppException(message: 'Token de acesso inválido ou ausente.', code: 401);
|
||||
}
|
||||
|
||||
$jwtUtil = new JwtUtil(
|
||||
secretKey: $_ENV['JWT_SECRET_KEY'], algorithm: $_ENV['JWT_ALGORITHM'],
|
||||
ttl: $_ENV['JWT_EXPIRATION_TIME'], issuer: $_ENV['JWT_ISSUER']
|
||||
);
|
||||
$decodedToken = $jwtUtil->validate(token: $bearerToken);
|
||||
if (!$decodedToken) {
|
||||
throw new AppException(message: 'Token de acesso inválido ou ausente.', code: 401);
|
||||
}
|
||||
|
||||
$decodedToken = json_decode(json: json_encode($decodedToken, flags: JSON_THROW_ON_ERROR), associative: true, flags: JSON_THROW_ON_ERROR);
|
||||
$userData = $decodedToken['user_data'] ?? null;
|
||||
if (!$userData) {
|
||||
throw new AppException(message: 'Token de acesso inválido ou ausente.', code: 401);
|
||||
}
|
||||
|
||||
if (!isset($userData['initial_login']) || $userData['initial_login'] !== true) {
|
||||
throw new AppException(message: 'Token de acesso não corresponde a um login inicial.', code: 403);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch(AppException $e) {
|
||||
return [
|
||||
'status' => 'error',
|
||||
'response_code' => $e->getCode() ?? 500,
|
||||
'message' => $e->getMessage()
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware de autenticação para validar sessões e tokens JWT em requisições protegidas.
|
||||
*
|
||||
* Este método atua como um "Gatekeeper" (Guard). Ele implementa uma estratégia híbrida
|
||||
* de validação (Cache-First, seguida de DB e JWT) para equilibrar performance com
|
||||
* segurança. Ele verifica a existência do token, a validade da assinatura JWT,
|
||||
* a revogação da sessão no banco de dados e a integridade do dispositivo (IP/User-Agent).
|
||||
*
|
||||
* ---
|
||||
* ## Fluxo de Execução
|
||||
* 1. **Extração:** Obtém o token do header `Authorization`. Retorna erro 401 se ausente.
|
||||
* 2. **Cache-First Check:** Verifica no Redis (`login:main:{token}`). Se encontrado, autoriza imediatamente (retorna `null`), evitando carga no banco de dados.
|
||||
* 3. **Validação de Persistência:** Consulta o `UsuarioSessionRepo` (via hash do token). Valida revogação (`revoked_at`) e integridade.
|
||||
* 4. **Validação JWT:** Decodifica e verifica a assinatura/expiração (`exp`) do JWT.
|
||||
* 5. **Validação de Integridade (Guard):** Invoca `validateSessionData` para garantir que o IP e User-Agent atuais coincidem com a sessão original.
|
||||
* 6. **Caching:** Se tudo estiver correto, armazena o payload no cache para acelerar futuras requisições e retorna `null` (acesso concedido).
|
||||
*
|
||||
* ---
|
||||
* ## Arquitetura de Validação
|
||||
*
|
||||
*
|
||||
* ---
|
||||
* ## Observações Técnicas
|
||||
* - **Interface de Retorno:**
|
||||
* - `null`: A autenticação foi bem-sucedida; o fluxo de execução pode continuar.
|
||||
* - `array`: A autenticação falhou; o array contém a resposta de erro estruturada para o cliente.
|
||||
* - **Segurança:** Utiliza `CypherUtil` para garantir que tokens sensíveis não sejam comparados em texto puro contra o banco de dados.
|
||||
*
|
||||
* @return array|null Retorna `null` se o usuário estiver autenticado e autorizado. Retorna um `array` com `response_code`, `message` e `output` em caso de erro.
|
||||
* @see JwtUtil::validate() Para detalhes sobre a validação da assinatura criptográfica.
|
||||
* @see self::validateSessionData() Para a verificação de segurança de IP e User-Agent.
|
||||
*/
|
||||
public static function handle(): ?array {
|
||||
try {
|
||||
// Valida o token JWT presente no header Authorization (Bearer Token)
|
||||
$bearerToken = HttpUtil::getBearerToken();
|
||||
if (!$bearerToken) {
|
||||
throw new AppException(message: 'Token de acesso inválido ou ausente.', code: 401);
|
||||
}
|
||||
|
||||
$jwtUtil = new JwtUtil(
|
||||
secretKey: $_ENV['JWT_SECRET_KEY'], algorithm: $_ENV['JWT_ALGORITHM'],
|
||||
ttl: $_ENV['JWT_EXPIRATION_TIME'], issuer: $_ENV['JWT_ISSUER']
|
||||
);
|
||||
|
||||
// Valida o token JWT e decodifica seu payload para extrair os dados do usuário e as informações de sessão.
|
||||
$decodedToken = $jwtUtil->validate(token: $bearerToken);
|
||||
if (!$decodedToken) {
|
||||
throw new AppException(message: 'Token de acesso inválido ou ausente.', code: 401);
|
||||
}
|
||||
|
||||
// Verifica se o token corresponde a um login inicial, bloqueando o acesso a rotas protegidas caso seja verdadeiro.
|
||||
$decodedToken = json_decode(json: json_encode($decodedToken, flags: JSON_THROW_ON_ERROR), associative: true, flags: JSON_THROW_ON_ERROR);
|
||||
if ($decodedToken['user_data']['initial_login'] == true) {
|
||||
throw new AppException(message: 'Acesso negado. O token corresponde a um login inicial e não pode ser usado para acessar esta rota.', code: 403);
|
||||
}
|
||||
|
||||
// Verifica se a sessão principal já está em cache para evitar consultas desnecessárias ao banco de dados e validações repetitivas.
|
||||
// Se a sessão estiver em cache, considera o token como válido e retorna null para permitir o acesso à rota protegida.
|
||||
if (CacheUtil::get(key: "login:main:{$bearerToken}")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$usuarioSessionRepo = ServiceFactory::makeUsuarioSessionRepo();
|
||||
|
||||
// Busca a sessão do usuário no banco de dados utilizando o hash do token para garantir que o token é válido e não foi revogado.
|
||||
$usuarioSessionModel = $usuarioSessionRepo->findByIdentifier(identifier: 'token_hash', value: CypherUtil::hash(data: $bearerToken));
|
||||
if (!$usuarioSessionModel || !CypherUtil::verify(data: $bearerToken, hash: $usuarioSessionModel->getTokenHash()) || $usuarioSessionModel->getRevokedAt()) {
|
||||
throw new AppException(message: 'Sessão inválida ou expirada. Faça login novamente.', code: 401);
|
||||
}
|
||||
|
||||
// Valida o token JWT e decodifica seu payload para extrair os dados do usuário e as informações de sessão.
|
||||
// Se o token for inválido, expirado ou tiver sua assinatura comprometida, uma exceção é lançada.
|
||||
$decodedToken = $jwtUtil->validate(token: $bearerToken);
|
||||
if (!$decodedToken) {
|
||||
throw new AppException(message: 'Token de acesso inválido ou ausente.', code: 401);
|
||||
}
|
||||
|
||||
$decodedToken = json_decode(json: json_encode($decodedToken, JSON_THROW_ON_ERROR), associative: true, flags: JSON_THROW_ON_ERROR);
|
||||
$remainingTime = $decodedToken['exp'] - time();
|
||||
if ($remainingTime <= 0) {
|
||||
throw new AppException(message: 'Sessão expirada. Faça login novamente.', code: 401);
|
||||
}
|
||||
|
||||
self::validateSessionData(sessionModel: $usuarioSessionModel, tokenData: $decodedToken);
|
||||
CacheUtil::set(key: "login:main:{$bearerToken}", ttl: $remainingTime, value: $decodedToken);
|
||||
return null;
|
||||
} catch (AppException $e) {
|
||||
return [
|
||||
'status' => 'error',
|
||||
'response_code' => $e->getCode() ?: 500,
|
||||
'message' => $e->getMessage(),
|
||||
'output' => $e->getDetails() ? ['errors' => $e->getDetails()] : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Valida a integridade e autenticidade da sessão do usuário comparando metadados.
|
||||
*
|
||||
* Este método atua como uma barreira de segurança (Guard) que garante que a sessão
|
||||
* atual está sendo utilizada pelo mesmo dispositivo e rede que a iniciou. Ao comparar
|
||||
* o IP e o User-Agent da requisição atual com os dados persistidos no banco de dados
|
||||
* (`UsuarioSessionModel`) e os metadados contidos no token JWT (`$tokenData`),
|
||||
* protegemos o sistema contra ataques de sequestro de sessão (session hijacking).
|
||||
*
|
||||
* ---
|
||||
* ## Fluxo de Execução
|
||||
* 1. **Captura:** Extrai o IP (`REMOTE_ADDR`) e o User-Agent (`HTTP_USER_AGENT`) da variável superglobal `$_SERVER`.
|
||||
* 2. **Comparação (Match):** Utiliza uma expressão `match` para validar quatro pontos de convergência:
|
||||
* - IP da Sessão (DB) vs. IP Atual.
|
||||
* - User-Agent da Sessão (DB) vs. User-Agent Atual.
|
||||
* - IP registrado no Payload do Token vs. IP Atual.
|
||||
* - User-Agent registrado no Payload do Token vs. User-Agent Atual.
|
||||
* 3. **Bloqueio:** Caso qualquer divergência seja detectada, a sessão é considerada inválida e uma `AppException` (401 Unauthorized) é disparada.
|
||||
*
|
||||
* ---
|
||||
* ## Observações Técnicas
|
||||
* - O uso de `match` (PHP 8+) torna o código altamente legível e performático para a verificação booleana múltipla.
|
||||
* - Este método é fundamental em cenários onde a segurança de acesso exige que o usuário não troque de rede ou navegador enquanto a sessão estiver ativa.
|
||||
* - Caso a requisição venha através de um proxy, certifique-se de que a configuração de captura de IP (`REMOTE_ADDR`) esteja ajustada corretamente no ambiente.
|
||||
*
|
||||
* @param UsuarioSessionModel $sessionModel O modelo contendo os dados da sessão persistidos no banco.
|
||||
* @param array $tokenData O payload decodificado do token JWT contendo os metadados da sessão.
|
||||
* @return void
|
||||
* @throws AppException Caso haja qualquer incompatibilidade nos dados (IP ou User-Agent), invalidando a sessão.
|
||||
*/
|
||||
private static function validateSessionData(UsuarioSessionModel $sessionModel, array $tokenData): void {
|
||||
$ipAddress = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
|
||||
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? 'unknown';
|
||||
|
||||
$isInvalidSession = match(true) {
|
||||
$sessionModel->getIpAddress() !== $ipAddress,
|
||||
$sessionModel->getUserAgent() !== $userAgent,
|
||||
$tokenData['session_data']['ip_address'] !== $ipAddress,
|
||||
$tokenData['session_data']['user_agent'] !== $userAgent => true,
|
||||
default => false
|
||||
};
|
||||
if ($isInvalidSession) {
|
||||
throw new AppException(message: 'Sessão inválida ou expirada. Faça login novamente.', code: 401);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
use KrothiumAPI\Http\Router;
|
||||
use WorkbloomERP\Module\v0\Auth\Middlewares\AuthMiddleware;
|
||||
use WorkbloomERP\Module\v0\Auth\Controllers\AuthController;
|
||||
|
||||
Router::group(
|
||||
prefix: '/auth',
|
||||
callback: function() {
|
||||
// Rota de autenticação
|
||||
Router::post(uri: '/login', handler: [AuthController::class, 'login']);
|
||||
|
||||
// Rota para seleção de empresa após login inicial
|
||||
Router::post(uri: '/select-company', handler: [AuthController::class, 'selectCompany'], middlewares: [[AuthMiddleware::class, 'isInitialLogin']]);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,289 @@
|
||||
<?php
|
||||
namespace WorkbloomERP\Module\v0\Auth\Services;
|
||||
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use DateTimeImmutable;
|
||||
use KrothiumAPI\Utils\HttpUtil;
|
||||
use WorkbloomERP\Utils\JwtUtil;
|
||||
use WorkbloomERP\Utils\CacheUtil;
|
||||
use WorkbloomERP\Utils\CypherUtil;
|
||||
use WorkbloomERP\Services\DBService;
|
||||
use WorkbloomERP\Exceptions\AppException;
|
||||
use WorkbloomERP\Module\v0\Auth\Utils\AuthUtil;
|
||||
use WorkbloomERP\Module\v0\Empresa\Repos\EmpresaRepo;
|
||||
use WorkbloomERP\Module\v0\Usuario\Repos\UsuarioRepo;
|
||||
use WorkbloomERP\Module\v0\Usuario\Repos\UsuarioSessionRepo;
|
||||
use WorkbloomERP\Module\v0\Usuario\Repos\UsuarioEmpresaRepo;
|
||||
use WorkbloomERP\Module\v0\Empresa\Models\EmpresaModel;
|
||||
use WorkbloomERP\Module\v0\Usuario\Models\UsuarioModel;
|
||||
use WorkbloomERP\Module\v0\Usuario\Models\UsuarioSessionModel;
|
||||
use WorkbloomERP\Module\v0\Usuario\Models\UsuarioEmpresaModel;
|
||||
|
||||
class AuthService {
|
||||
public function __construct(
|
||||
private DBService $db,
|
||||
private UsuarioRepo $usuarioRepo,
|
||||
private EmpresaRepo $empresaRepo,
|
||||
private UsuarioSessionRepo $usuarioSessionRepo,
|
||||
private UsuarioEmpresaRepo $usuarioEmpresaRepo,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Realiza a autenticação de usuário e inicia uma sessão no sistema.
|
||||
*
|
||||
* Este método é o coração do processo de autenticação. Ele valida as credenciais
|
||||
* fornecidas, gera um token JWT (JSON Web Token) para comunicação segura, persiste
|
||||
* a sessão no banco de dados para fins de auditoria/rastreabilidade e armazena
|
||||
* o token no cache (Redis) para validação rápida de requests subsequentes.
|
||||
*
|
||||
* ---
|
||||
* ## Fluxo de Execução
|
||||
* 1. **Validação de Entrada:** Verifica se `login` e `senha` foram fornecidos.
|
||||
* 2. **Autenticação:** Busca o usuário pelo login e utiliza `password_verify` para validar a hash da senha.
|
||||
* 3. **Geração de Token:** Instancia o `JwtUtil` com configurações de ambiente e gera um novo token JWT.
|
||||
* 4. **Persistência de Sessão (Atomicidade):**
|
||||
* - Insere o registro de sessão na tabela `usuario_session` (contendo dados como IP e User-Agent).
|
||||
* - O hash do token é armazenado para permitir revogação futura.
|
||||
* 5. **Caching:** Armazena o token no Redis com a mesma TTL da expiração do token para otimizar a validação de acesso.
|
||||
*
|
||||
* ---
|
||||
* ## Observações Técnicas
|
||||
* - **Transacionalidade:** Toda a criação de sessão e persistência ocorre dentro de uma transação de banco de dados, garantindo que não existam sessões órfãs caso o cache ou a criação do token falhem.
|
||||
* - **Segurança:** O token JWT gerado é hasheado (`CypherUtil::hash`) antes de ser salvo no banco para evitar a exposição do token real em caso de vazamento de dados.
|
||||
* - **Infraestrutura:** Requer acesso funcional a: `JWT_SECRET_KEY`, `JWT_ALGORITHM`, `JWT_EXPIRATION_TIME`, `JWT_ISSUER`.
|
||||
*
|
||||
* @param string $login O nome de usuário ou e-mail.
|
||||
* @param string $senha A senha em texto puro fornecida pelo usuário.
|
||||
* @return array{response_code: int, message: string, output: array{token: string}} Resposta estruturada contendo o código de sucesso e o token JWT.
|
||||
* @throws AppException Caso as credenciais sejam inválidas (401), o payload esteja vazio (400) ou falhas críticas ocorram na geração de token/sessão (500).
|
||||
*/
|
||||
public function login(string $login, string $senha): array {
|
||||
try {
|
||||
// Validação básica de entrada
|
||||
if (empty($login) || empty($senha)) {
|
||||
throw new AppException(message: 'Login e senha são obrigatórios.', code: 400);
|
||||
}
|
||||
|
||||
// Inicia uma transação para garantir a atomicidade das operações de autenticação
|
||||
return $this->db->transaction(
|
||||
callback: function() use ($login, $senha) {
|
||||
$usuarioModel = $this->usuarioRepo->findByLogin(login: $login);
|
||||
if (!$usuarioModel || !password_verify(password: $senha, hash: $usuarioModel->getSenhaHash())) {
|
||||
throw new AppException(message: 'Login ou senha inválidos.', code: 401);
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'user_data' => [
|
||||
'initial_login' => true,
|
||||
'uuid' => $usuarioModel->getUuid(),
|
||||
'nome_usuario' => $usuarioModel->getNomeCompleto(),
|
||||
'email' => $usuarioModel->getEmail(),
|
||||
],
|
||||
'exp' => (new DateTimeImmutable())->modify("+5 minutes")->getTimestamp(),
|
||||
];
|
||||
|
||||
$jwtUtil = new JwtUtil(
|
||||
secretKey: $_ENV['JWT_SECRET_KEY'], algorithm: $_ENV['JWT_ALGORITHM'],
|
||||
ttl: $_ENV['JWT_EXPIRATION_TIME'], issuer: $_ENV['JWT_ISSUER']
|
||||
);
|
||||
$jwtToken = $jwtUtil->generate(payload: $payload);
|
||||
if (!$jwtToken) {
|
||||
throw new AppException(message: 'Erro ao gerar token de acesso.', code: 500);
|
||||
}
|
||||
|
||||
// Armazena o token JWT no cache Redis com uma TTL de 10 minutos (600 segundos)
|
||||
CacheUtil::set(key: "login:init:{$jwtToken}", ttl: 600, value: $payload);
|
||||
|
||||
return [
|
||||
'response_code' => 200,
|
||||
'message' => 'Login realizado com sucesso.',
|
||||
'output' => [
|
||||
'tmp_token' => $jwtToken,
|
||||
'empresas' => $this->getEmpresasByUsuarioId($usuarioModel->getId())
|
||||
]
|
||||
];
|
||||
}
|
||||
);
|
||||
} catch(AppException $e) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recupera e organiza a lista de empresas vinculadas a um determinado usuário.
|
||||
*
|
||||
* Este método realiza o mapeamento entre o ID do usuário e suas respectivas
|
||||
* empresas. Ele filtra as associações através do repositório `usuarioEmpresaRepo`,
|
||||
* hidrata os objetos `EmpresaModel` e organiza os dados em uma estrutura
|
||||
* associativa agrupada pelo nome empresarial.
|
||||
*
|
||||
* ---
|
||||
* ## Fluxo de Execução
|
||||
* 1. **Busca:** Recupera todos os registros de associação `usuario_id` -> `empresa_id`.
|
||||
* 2. **Hidratação:** Itera sobre as associações, buscando o `EmpresaModel` completo para cada ID encontrado.
|
||||
* 3. **Estruturação:** Monta um array multidimensional onde as chaves são os nomes das empresas e os valores são arrays contendo metadados (UUID, nome, CNPJ, tipo formatado).
|
||||
* 4. **Tratamento de Dados:** Normaliza o campo `tipo` (MATRIZ/FILIAL) para o formato "Capitalizado" (ex: Matriz).
|
||||
*
|
||||
* ---
|
||||
* ## Observações Técnicas
|
||||
* - Caso o usuário não possua empresas vinculadas ou o repositório retorne vazio, o método retorna um array vazio.
|
||||
* - Ignora silenciosamente qualquer `empresa_id` que não resulte em um `EmpresaModel` válido (falha de integridade referencial).
|
||||
* - O agrupamento pelo nome empresarial facilita o consumo da estrutura pelo frontend.
|
||||
*
|
||||
* @param int $usuario_id O identificador numérico interno do usuário.
|
||||
* @return array<string, array<int, array{uuid: string, nome_empresarial: string, nome_fantasia: string, cnpj: string, tipo: string}>> Array estruturado de empresas.
|
||||
* @throws AppException Caso ocorra falha de acesso aos repositórios.
|
||||
*/
|
||||
private function getEmpresasByUsuarioId(int $usuario_id): array {
|
||||
try {
|
||||
$empresas = $this->usuarioEmpresaRepo->findAllByUsuarioId(usuario_id: $usuario_id);
|
||||
if (empty($empresas)) {
|
||||
return ['Nenhuma empresa vinculada ao usuário.'];
|
||||
}
|
||||
|
||||
$preparedEmpresas = [];
|
||||
foreach($empresas as $i => $empresa) {
|
||||
$empresaModel = $this->empresaRepo->findByIdentifier(identifier: 'id', value: $empresa['empresa_id']);
|
||||
if (!$empresaModel) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$preparedEmpresas[$empresaModel->getNomeEmpresarial()][] = [
|
||||
'uuid' => $empresaModel->getUuid(),
|
||||
'nome_empresarial' => $empresaModel->getNomeEmpresarial(),
|
||||
'nome_fantasia' => $empresaModel->getNomeFantasia(),
|
||||
'cnpj' => $empresaModel->getDocumentCnpj(),
|
||||
'tipo' => ucfirst(string: strtolower(string: $empresaModel->getTipo()))
|
||||
];
|
||||
}
|
||||
|
||||
return $preparedEmpresas;
|
||||
} catch(AppException $e) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seleciona e define a empresa ativa para o contexto da sessão do usuário.
|
||||
*
|
||||
* Este método é utilizado em sistemas multi-empresa onde o usuário precisa alternar
|
||||
* entre diferentes entidades (empresas) sem realizar um novo login completo.
|
||||
* O método valida se o usuário possui vínculo com a empresa solicitada, gera
|
||||
* um novo token JWT com o contexto da empresa atualizado e registra essa nova
|
||||
* sessão no banco de dados e no cache (Redis).
|
||||
*
|
||||
* ---
|
||||
* ## Fluxo de Execução
|
||||
* 1. **Verificação de Sessão:** Garante que o usuário já possui um login ativo (leitura via `AuthUtil`).
|
||||
* 2. **Validação de Acesso:** Verifica se o usuário solicitado possui associação ativa com a empresa (`usuarioEmpresaRepo`).
|
||||
* 3. **Contextualização:** Monta um novo payload contendo os dados do usuário e os dados da empresa selecionada.
|
||||
* 4. **Token Rotation:** Gera um novo token JWT com os novos dados de contexto e define um tempo de expiração (60 minutos).
|
||||
* 5. **Persistência:** Registra a nova sessão no banco de dados (`usuario_session`) e atualiza o estado no cache (`CacheUtil`).
|
||||
*
|
||||
* ---
|
||||
* ## Observações Técnicas
|
||||
* - **Token Rotation:** O método invalida implicitamente o contexto anterior ao gerar um novo token baseado na nova seleção.
|
||||
* - **Atomicidade:** A operação ocorre dentro de uma transação, garantindo que se o token não for gerado ou a sessão não for criada, nada é alterado.
|
||||
* - **Segurança:** O hash do token gerado é armazenado para permitir rastreabilidade e segurança.
|
||||
*
|
||||
* @param string $empresa_uuid O Identificador Único Universal da empresa que o usuário deseja selecionar.
|
||||
* @return array{response_code: int, message: string, output: array{token: string}} Resposta estruturada contendo o novo token de acesso com o contexto atualizado.
|
||||
* @throws AppException Caso a sessão seja inválida, a empresa não exista, o usuário não tenha permissão de acesso à empresa ou falhas de persistência ocorram.
|
||||
*/
|
||||
public function selectCompany(string $empresa_uuid) {
|
||||
try {
|
||||
return $this->db->transaction(
|
||||
callback: function() use ($empresa_uuid) {
|
||||
if (empty($empresa_uuid)) {
|
||||
throw new AppException(message: 'UUID da empresa é obrigatório.', code: 400);
|
||||
}
|
||||
|
||||
// Lê os dados da sessão atual para validar a existência de um login prévio e obter o UUID do usuário
|
||||
$sessionData = AuthUtil::readInitSession();
|
||||
if (!$sessionData || !isset($sessionData['user_data']['uuid'])) {
|
||||
throw new AppException(message: 'Sessão de usuário não encontrada. Faça login novamente.', code: 401);
|
||||
}
|
||||
|
||||
// Valida se o usuário tem acesso à empresa selecionada
|
||||
$usuarioModel = $this->usuarioRepo->findByIdentifier(identifier: 'uuid', value: $sessionData['user_data']['uuid']);
|
||||
if (!$usuarioModel) {
|
||||
throw new AppException(message: 'Ocorreu um erro ao selecionar a empresa. Tente novamente mais tarde.', code: 404);
|
||||
}
|
||||
|
||||
// Valida se a empresa existe e se o usuário tem associação com ela
|
||||
$empresaModel = $this->empresaRepo->findByIdentifier(identifier: 'uuid', value: $empresa_uuid);
|
||||
if (!$empresaModel || !$this->usuarioEmpresaRepo->checkAssociationByUsuarioIdAndEmpresaId(usuario_id: $usuarioModel->getId(), empresa_id: $empresaModel->getId())) {
|
||||
throw new AppException(message: 'Empresa não encontrada.', code: 404);
|
||||
}
|
||||
|
||||
// Monta o payload da sessão principal, incluindo os dados do usuário e da empresa selecionada
|
||||
$payload = [
|
||||
'user_data' => [
|
||||
'initial_login' => false,
|
||||
'uuid' => $usuarioModel->getUuid(),
|
||||
'nome_completo' => $usuarioModel->getNomeCompleto(),
|
||||
'nome_usuario' => $usuarioModel->getNomeUsuario(),
|
||||
'email' => $usuarioModel->getEmail(),
|
||||
'is_root' => $usuarioModel->getIsRoot() ? true : false
|
||||
],
|
||||
'empresa_data' => [
|
||||
'uuid' => $empresaModel->getUuid(),
|
||||
'nome_empresarial' => $empresaModel->getNomeEmpresarial(),
|
||||
'nome_fantasia' => $empresaModel->getNomeFantasia(),
|
||||
'cnpj' => $empresaModel->getDocumentCnpj(),
|
||||
'tipo' => ucfirst(string: strtolower(string: $empresaModel->getTipo()))
|
||||
],
|
||||
'session_data' => [
|
||||
'ip_address' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
|
||||
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown'
|
||||
],
|
||||
'iat' => (new DateTimeImmutable())->getTimestamp(),
|
||||
'exp' => (new DateTimeImmutable())->modify("+{$_ENV['JWT_EXPIRATION_TIME']} seconds")->getTimestamp(),
|
||||
];
|
||||
|
||||
$jwtUtil = new JwtUtil(
|
||||
secretKey: $_ENV['JWT_SECRET_KEY'], algorithm: $_ENV['JWT_ALGORITHM'],
|
||||
ttl: $_ENV['JWT_EXPIRATION_TIME'], issuer: $_ENV['JWT_ISSUER']
|
||||
);
|
||||
$jwtToken = $jwtUtil->generate(payload: $payload);
|
||||
if (!$jwtToken) {
|
||||
throw new AppException(message: 'Erro ao gerar token de acesso.', code: 500);
|
||||
}
|
||||
|
||||
$usuarioSessionModel = $this->usuarioSessionRepo->insert(
|
||||
new UsuarioSessionModel(
|
||||
uuid: Uuid::uuid7()->toString(),
|
||||
usuario_id: $usuarioModel->getId(),
|
||||
token_hash: CypherUtil::hash(data: $jwtToken),
|
||||
ip_address: $payload['session_data']['ip_address'],
|
||||
user_agent: $payload['session_data']['user_agent'],
|
||||
created_at: new DateTimeImmutable()
|
||||
)
|
||||
);
|
||||
if (!$usuarioSessionModel) {
|
||||
throw new AppException(message: 'Erro ao criar sessão de usuário.', code: 500);
|
||||
}
|
||||
|
||||
if (!CacheUtil::set(key: "login:main:{$jwtToken}", ttl: $_ENV['JWT_EXPIRATION_TIME'], value: $payload)) {
|
||||
throw new AppException(message: 'Erro ao armazenar sessão no cache.', code: 500);
|
||||
}
|
||||
|
||||
$beforeToken = HttpUtil::getBearerToken();
|
||||
if (!CacheUtil::delete(keys: ["login:init:{$beforeToken}"])) {
|
||||
throw new AppException(message: 'Erro ao limpar sessão temporária do cache.', code: 500);
|
||||
}
|
||||
|
||||
return [
|
||||
'response_code' => 200,
|
||||
'message' => 'Empresa selecionada com sucesso.',
|
||||
'output' => [
|
||||
'token' => $jwtToken
|
||||
]
|
||||
];
|
||||
}
|
||||
);
|
||||
} catch(AppException $e) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
namespace WorkbloomERP\Module\v0\Auth\Utils;
|
||||
|
||||
use KrothiumAPI\Utils\HttpUtil;
|
||||
use WorkbloomERP\Utils\JwtUtil;
|
||||
use WorkbloomERP\Utils\CacheUtil;
|
||||
use WorkbloomERP\Utils\CryptoUtil;
|
||||
use WorkbloomERP\Module\v0\Usuario\Models\UsuarioModel;
|
||||
use WorkbloomERP\Module\v0\Auth\Factories\ServiceFactory;
|
||||
use WorkbloomERP\Module\v0\Usuario\Models\UsuarioSessionModel;
|
||||
|
||||
class AuthUtil {
|
||||
/**
|
||||
* Recupera os dados de uma sessão de "Login Inicial" a partir do cache.
|
||||
*
|
||||
* Este método é utilizado para verificar se existe uma sessão ativa vinculada ao
|
||||
* token de acesso atual no fluxo de "Primeiro Acesso" (Initial Login). Ele permite
|
||||
* identificar rapidamente se o usuário está em um estado que exige ações
|
||||
* obrigatórias, como a alteração de senha inicial ou configuração de MFA,
|
||||
* sem a necessidade de consultar o banco de dados.
|
||||
*
|
||||
* ---
|
||||
* ## Fluxo de Execução
|
||||
* 1. **Identificação:** Extrai o token Bearer da requisição atual (`HttpUtil::getBearerToken()`).
|
||||
* 2. **Busca em Cache:** Consulta o serviço de cache (Redis) utilizando a chave prefixada `login:init:{token}`.
|
||||
* 3. **Retorno:** Devolve os dados da sessão (array) caso o registro exista e esteja ativo; retorna `null` caso a sessão não exista ou tenha expirado.
|
||||
*
|
||||
* ---
|
||||
* ## Observações Técnicas
|
||||
* - **Performance:** Este método é altamente performático por consultar exclusivamente a camada de cache, sendo ideal para middlewares de validação de fluxo de login.
|
||||
* - **Segurança:** A chave de busca utiliza o próprio token Bearer, garantindo que o acesso aos dados da sessão inicial seja restrito ao detentor do token correspondente.
|
||||
* - **Integração:** Este método deve ser utilizado em conjunto com o fluxo de autenticação que persiste os dados do login inicial no cache.
|
||||
*
|
||||
* @return array|null Retorna o payload da sessão de login inicial se encontrada; retorna `null` se a sessão não existir no cache.
|
||||
*/
|
||||
public static function readInitSession(): ?array {
|
||||
// Verifica se existe um login inicial
|
||||
$bearerToken = HttpUtil::getBearerToken();
|
||||
$initSession = CacheUtil::get(key: "login:init:{$bearerToken}");
|
||||
if ($initSession) {
|
||||
return $initSession;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recupera a sessão principal do usuário ou uma chave específica do payload armazenado em cache.
|
||||
*
|
||||
* Este método centraliza o acesso aos dados de sessão ativos. Ele utiliza o
|
||||
* token Bearer da requisição atual para localizar o payload no cache (Redis) e,
|
||||
* caso uma chave específica seja fornecida, retorna apenas o subconjunto de dados
|
||||
* solicitado, otimizando o acesso às informações de contexto.
|
||||
*
|
||||
* ---
|
||||
* ## Fluxo de Execução
|
||||
* 1. **Identificação:** Obtém o token de acesso (Bearer Token) da requisição atual via `HttpUtil`.
|
||||
* 2. **Busca:** Consulta o cache usando a chave padronizada `login:main:{token}`.
|
||||
* 3. **Filtragem:**
|
||||
* - Se uma `$key` for fornecida e existir no payload, retorna apenas o valor correspondente (ex: `user_data`).
|
||||
* - Se nenhuma chave for fornecida ou a chave não existir, retorna o payload completo.
|
||||
* 4. **Retorno:** Devolve `null` caso a sessão não esteja em cache (sessão inexistente ou expirada).
|
||||
*
|
||||
* ---
|
||||
* ## Observações Técnicas
|
||||
* - **Performance:** A consulta ao cache é direta e rápida. O método evita redundância ao permitir filtrar os dados necessários logo na leitura.
|
||||
* - **Flexibilidade:** A inclusão do parâmetro `$key` permite que outros serviços solicitem apenas o bloco de dados que precisam (ex: dados da empresa ou do usuário), reduzindo o processamento desnecessário de arrays grandes.
|
||||
*
|
||||
* @param string $key Chave específica a ser extraída do payload da sessão (opcional; se vazio, retorna o payload completo).
|
||||
* @return array|null Retorna o payload da sessão ou um subconjunto dele; retorna `null` se a sessão não existir no cache.
|
||||
*/
|
||||
public static function readSession(string $key): ?array {
|
||||
$bearerToken = HttpUtil::getBearerToken();
|
||||
$result = CacheUtil::get(key: "login:main:{$bearerToken}");
|
||||
|
||||
if (!$result) {return null;}
|
||||
|
||||
if ($key && isset($result[$key])) {
|
||||
return $result[$key];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user