feat: implement JWT-based authentication system with repository, service, and middleware layers
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user