first commit

This commit is contained in:
Claudecio Martins
2026-06-16 10:04:10 -03:00
commit a951944997
4463 changed files with 419677 additions and 0 deletions
+289
View File
@@ -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;
}
}
}