<?php
namespace App\Controller;
use App\Entity\Usuario;
use App\Entity\ViewProfile;
use App\Entity\Periodo;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Firebase\JWT\ExpiredException;
use Symfony\Component\Routing\Exception\RouteNotFoundException;
#[Route('/api/integration')]
class IntegrationController extends AbstractController
{
private EntityManagerInterface $em;
private ManagerRegistry $doctrine;
private string $secretKey;
private TokenStorageInterface $tokenStorage;
private SessionInterface $session;
private string $systemName;
private LoggerInterface $logger;
// Constante para mapeo de iconos
private const ICON_MAP = [
'fa-chart-line' => 'DotChartOutlined',
'fa-users-cog' => 'TeamOutlined',
'fa-book' => 'BookOutlined',
'fa-calendar-alt' => 'CalendarOutlined',
'fa-users' => 'UsergroupAddOutlined',
'fa-home' => 'HomeOutlined',
'fa-cogs' => 'SettingOutlined',
'fa-angle-double-up' => 'ArrowUpOutlined',
'fa-user' => 'UserOutlined',
'fa-file' => 'FileOutlined',
'fa-search' => 'SearchOutlined',
'fa-bell' => 'BellOutlined',
'fa-dashboard' => 'DashboardOutlined',
'fa-appstore' => 'AppstoreOutlined',
'fa-area-chart' => 'AreaChartOutlined',
'fa-bar-chart' => 'BarChartOutlined'
];
public function __construct(
EntityManagerInterface $em,
ManagerRegistry $doctrine,
TokenStorageInterface $tokenStorage,
SessionInterface $session,
LoggerInterface $logger
) {
$this->em = $em;
$this->doctrine = $doctrine;
$this->tokenStorage = $tokenStorage;
$this->session = $session;
$this->logger = $logger;
$this->secretKey = $_ENV['SERVICIOS_ADMINISTRATIVOS_SECRET_KEY'] ?? '';
$this->systemName = $_ENV['SYSTEM_NAME'] ?? 'UNKNOWN_SYSTEM';
if (empty($this->secretKey)) {
throw new \RuntimeException('SERVICIOS_ADMINISTRATIVOS_SECRET_KEY no configurada');
}
}
/**
* Endpoint principal para SSO Login
* Valida token JWT y establece sesión siguiendo la misma lógica del login normal
*/
#[Route('/sso-login', name: 'integration_sso_login', methods: ['GET'])]
public function ssoLogin(Request $request): Response
{
$token = $request->query->get('token');
$redirectPath = $request->query->get('redirect');
if (!$token) {
$this->logger->warning('SSO Login: Token no proporcionado');
return $this->jsonError('Token SSO es requerido', '', 400);
}
try {
// 1. Decodificar y validar token JWT
$decoded = $this->decodeAndValidateJWT($token);
if (!isset($decoded->email)) {
throw new \Exception('Token inválido: falta email');
}
// 2. Buscar usuario
$user = $this->findUserByEmail($decoded->email);
if (!$user) {
$this->logger->info('SSO Login: Usuario no encontrado', [
'email' => $decoded->email
]);
return $this->render('restringido.html.twig', [], new Response('', 403));
}
// 3. VALIDACIONES DEL USUARIO (misma lógica que login normal)
$this->validateUserForLogin($user);
// 4. VALIDAR SI YA EXISTE SESIÓN ACTIVA VÁLIDA
if ($this->hasValidSession($user)) {
$this->logger->info('SSO Login: Sesión existente válida, redirigiendo', [
'user_id' => $user->getIdUsu(),
'email' => $user->getCorreo()
]);
$targetPath = $this->determineRedirectPath($redirectPath);
return $this->redirect($targetPath . '?embedded=1&sso=1');
}
// 5. No hay sesión válida, crear una nueva
$this->logger->info('SSO Login: Creando nueva sesión', [
'user_id' => $user->getIdUsu(),
'email' => $user->getCorreo()
]);
// *** Invalidar cualquier sesión previa ***
$this->invalidatePreviousSession();
$this->authenticateUserInSession($user, $request);
$targetPath = $this->determineRedirectPath($redirectPath);
return $this->redirect($targetPath . '?embedded=1&sso=1');
} catch (ExpiredException $e) {
$this->logger->warning('SSO Login: Token expirado', [
'error' => $e->getMessage()
]);
return $this->jsonError(
'Token SSO expirado',
'Por favor, vuelve a intentar desde el sistema principal',
401
);
} catch (CustomUserMessageAuthenticationException $e) {
// Excepción de validación de usuario
$this->logger->warning('SSO Login: Validación de usuario falló', [
'error' => $e->getMessage()
]);
return $this->render('restringido.html.twig', [
'mensaje' => $e->getMessage()
], new Response('', 403));
} catch (\Exception $e) {
$this->logger->error('SSO Login: Error en autenticación', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
return $this->jsonError('Error en autenticación SSO', $e->getMessage(), 401);
}
}
/**
* Verifica si el usuario tiene una sesión activa y válida
*/
private function hasValidSession(Usuario $user): bool
{
$currentToken = $this->tokenStorage->getToken();
if (!$currentToken) {
return false;
}
$sessionUser = $currentToken->getUser();
// Verificar que sea un Usuario válido y que coincida con el email
if (!$sessionUser instanceof Usuario) {
return false;
}
if ($sessionUser->getCorreo() !== $user->getCorreo()) {
return false;
}
// Verificar que los datos de sesión críticos existan
if (!$this->session->has('perfil') || !$this->session->has('menu')) {
$this->logger->warning('Sesión sin datos críticos, regenerando', [
'user_id' => $user->getIdUsu()
]);
return false;
}
return true;
}
/**
* Decodifica y valida el token JWT
*/
private function decodeAndValidateJWT(string $token): object
{
try {
$decoded = JWT::decode($token, new Key($this->secretKey, 'HS256'));
if (!isset($decoded->email) || empty($decoded->email)) {
throw new \Exception('Token inválido: falta email');
}
if (isset($decoded->exp) && $decoded->exp < time()) {
throw new ExpiredException('Token expirado');
}
if (isset($decoded->iss)) {
$expectedIssuer = $_ENV['JWT_ISSUER'] ?? 'integration-service';
if ($decoded->iss !== $expectedIssuer) {
$this->logger->warning('Emisor del token inválido', [
'expected' => $expectedIssuer,
'received' => $decoded->iss
]);
throw new \Exception('Emisor del token inválido');
}
}
if (isset($decoded->system)) {
if ($decoded->system !== $this->systemName) {
$this->logger->warning('Token no destinado a este sistema', [
'expected_system' => $this->systemName,
'token_system' => $decoded->system
]);
throw new \Exception('Token no destinado a este sistema');
}
}
$this->logger->debug('Token JWT validado exitosamente', [
'email' => $decoded->email,
'system' => $decoded->system ?? 'N/A'
]);
return $decoded;
} catch (ExpiredException $e) {
throw $e;
} catch (\Exception $e) {
$this->logger->error('Error decodificando JWT', [
'error' => $e->getMessage()
]);
throw new \Exception('Token inválido: ' . $e->getMessage());
}
}
/**
* Busca un usuario por email
*/
private function findUserByEmail(string $email): ?Usuario
{
return $this->em->getRepository(Usuario::class)
->findOneBy(['correo' => $email]);
}
/**
* Busca un usuario por email o ID
*/
private function findUserByEmailOrId(?string $email, ?int $userId): ?Usuario
{
if ($userId) {
return $this->em->getRepository(Usuario::class)->find($userId);
}
if ($email) {
return $this->findUserByEmail($email);
}
return null;
}
/**
* Valida que el usuario pueda iniciar sesión
* *** CORREGIDO: Misma lógica exacta que LoginAuthenticator ***
*
* @throws CustomUserMessageAuthenticationException si el usuario no puede iniciar sesión
*/
private function validateUserForLogin(Usuario $user): void
{
// Validación 1: Usuario Pendiente
if ($user->getEstatus() === 'Pendiente') {
$this->logger->warning('Intento de login SSO con usuario pendiente', [
'user_id' => $user->getIdUsu(),
'email' => $user->getCorreo()
]);
throw new CustomUserMessageAuthenticationException('Access request pending evaluation.');
}
// Validación 2: Usuario Rechazado
if ($user->getEstatus() === 'Rechazado') {
$this->logger->warning('Intento de login SSO con usuario rechazado', [
'user_id' => $user->getIdUsu(),
'email' => $user->getCorreo()
]);
throw new CustomUserMessageAuthenticationException('Access request has been rejected.');
}
// Validación 3: Usuario Suspendido
if ($user->getEstatus() === 'Suspendido') {
$this->logger->warning('Intento de login SSO con usuario suspendido', [
'user_id' => $user->getIdUsu(),
'email' => $user->getCorreo()
]);
throw new CustomUserMessageAuthenticationException('Access credentials suspended.');
}
$this->logger->debug('Usuario validado correctamente para SSO login', [
'user_id' => $user->getIdUsu(),
'email' => $user->getCorreo(),
'estatus' => $user->getEstatus()
]);
}
/**
* Establece la autenticación del usuario en la sesión
*/
private function authenticateUserInSession(Usuario $user, Request $request): void
{
try {
// *** AGREGADO: Actualizar estatus de "Aceptado" a "Activo" en primer login ***
$estatusActual = $user->getEstatus();
if ($estatusActual === "Aceptado") {
$user->setEstatus("Activo");
$this->em->persist($user);
$this->em->flush();
$this->logger->info('Estatus actualizado de Aceptado a Activo', [
'user_id' => $user->getIdUsu()
]);
}
// 1. Crear y establecer token de autenticación
$token = new UsernamePasswordToken($user, 'main', $user->getRoles());
$this->tokenStorage->setToken($token);
// 2. Obtener periodo actual
$periodo = $this->em->getRepository(Periodo::class)->findOneBy(['actual' => 1]);
$this->session->set('periodo', $periodo);
// 3. Construir perfil y menú (MISMA LÓGICA QUE LoginAuthenticator)
$rol = $user->getRol()->getIdRol();
$acciones = $this->em->getRepository(ViewProfile::class)->findBy(['idRol' => $rol]);
$perfil = [];
$menu = [];
foreach ($acciones as $accion) {
// *** CORREGIDO: Construcción de perfil con índice por idMod ***
$perfil[$accion->getIdMod()] = [
"idMod" => $accion->getIdMod(),
"nombre" => $accion->getModulo(),
"submenu" => $accion->getSubmenu(),
"idBeh" => $accion->getIdBeh(),
"nivel" => $accion->getNivel(),
"comportamiento" => $accion->getComportamiento(),
"descripcion" => $accion->getDescripcion()
];
// *** Construcción del menú lateral ***
if ($accion->getNivel() > 0) {
if (empty($accion->getSubmenu())) {
$menu[$accion->getOrden()] = [
"idMenu" => $accion->getIdMenu(),
"menu" => $accion->getModulo(),
"icono" => $accion->getIcono(),
"ruta" => $accion->getRuta(),
"submenu" => false
];
} else {
if (!array_key_exists($accion->getOrden(), $menu)) {
$menu[$accion->getOrden()] = [
"idMenu" => $accion->getIdMenu(),
"menu" => $accion->getModulo(),
"icono" => $accion->getIcono(),
"ruta" => "#"
];
}
$menu[$accion->getOrden()]["submenu"][] = [
"idMenu" => $accion->getIdSubmenu(),
"menu" => $accion->getSubmenu(),
"icono" => "fa-angle-double-up",
"ruta" => $accion->getRuta()
];
}
}
}
ksort($menu);
// 4. Establecer datos en sesión
$this->session->set('perfil', $perfil);
$this->session->set('menu', $menu);
$this->logger->info('Sesión establecida exitosamente con estructura original', [
'user_id' => $user->getIdUsu(),
'email' => $user->getCorreo(),
'perfil_items' => count($perfil),
'menu_items' => count($menu)
]);
}
catch (\Exception $e) {
$this->logger->error('Error validando token de integración', [
'error' => $e->getMessage()
]);
}
}
/**
* Determina la ruta de redirección validando seguridad
*/
private function determineRedirectPath(?string $redirectPath): string
{
if (empty($redirectPath)) {
return '/';
}
$redirectPath = trim($redirectPath);
// Prevenir redirecciones externas
if (preg_match('#^(https?:)?//#i', $redirectPath)) {
$this->logger->warning('Intento de redirección externa bloqueado', [
'redirect' => $redirectPath
]);
return '/';
}
$redirectPath = ltrim($redirectPath, '/');
try {
return $this->generateUrl($redirectPath);
} catch (RouteNotFoundException $e) {
$this->logger->warning('Ruta Symfony no encontrada', [
'redirect' => $redirectPath
]);
return '/';
}
}
/**
* Valida el token de integración en requests API
*/
private function validateIntegrationToken(Request $request): ?object
{
$authHeader = $request->headers->get('Authorization');
if (!$authHeader || !preg_match('/Bearer\s+(.*)$/i', $authHeader, $matches)) {
$this->logger->warning('Token de integración ausente o malformado');
return null;
}
try {
return $this->decodeAndValidateJWT($matches[1]);
} catch (\Exception $e) {
$this->logger->error('Error validando token de integración', [
'error' => $e->getMessage()
]);
return null;
}
}
/**
* Invalida completamente la sesión anterior
*/
private function invalidatePreviousSession(): void
{
$currentToken = $this->tokenStorage->getToken();
if ($currentToken && $currentToken->getUser() instanceof Usuario) {
$this->tokenStorage->setToken(null);
$this->session->invalidate();
$this->logger->debug('Sesión de otro usuario invalidada completamente');
} else {
$this->session->migrate(true);
$this->logger->debug('ID de sesión regenerado (sin sesión previa)');
}
}
// ==================== ENDPOINTS API ====================
#[Route('/test', name: 'integration_test', methods: ['GET'])]
public function test(): JsonResponse
{
return new JsonResponse([
'status' => 'OK',
'message' => 'Integration endpoint working correctly',
'system_name' => $this->systemName,
'timestamp' => date('Y-m-d H:i:s')
]);
}
#[Route('/check-user', name: 'integration_check_user', methods: ['POST'])]
public function checkUser(Request $request): JsonResponse
{
if (!$this->validateIntegrationToken($request)) {
return $this->jsonError('Invalid or missing integration token', '', 401);
}
$data = json_decode($request->getContent(), true);
$email = $data['email'] ?? null;
if (!$email) {
return $this->jsonError('Email is required', '', 400);
}
try {
$user = $this->findUserByEmail($email);
if (!$user) {
return new JsonResponse([
'has_access' => false,
'system_name' => $this->systemName,
'message' => 'User not found in this system'
]);
}
return new JsonResponse([
'has_access' => true,
'user_id' => $user->getIdUsu(),
'email' => $user->getCorreo(),
'name' => trim($user->getNombre() . ' ' . $user->getPapellido()),
'roles' => [$user->getRol()->getNombre()],
'system_name' => $this->systemName
]);
} catch (\Exception $e) {
$this->logger->error('Error en check-user', [
'email' => $email,
'error' => $e->getMessage()
]);
return $this->jsonError('Database error', $e->getMessage(), 500);
}
}
#[Route('/get-menu', name: 'integration_get_menu', methods: ['POST'])]
public function getMenu(Request $request): JsonResponse
{
if (!$this->validateIntegrationToken($request)) {
return $this->jsonError('Invalid or missing integration token', '', 401);
}
$data = json_decode($request->getContent(), true);
$email = $data['email'] ?? null;
$userId = $data['user_id'] ?? null;
if (!$email && !$userId) {
return $this->jsonError('Email or user_id is required', '', 400);
}
try {
$user = $this->findUserByEmailOrId($email, $userId);
if (!$user) {
return new JsonResponse([
'menu' => [],
'message' => 'User not found'
]);
}
$menu = $this->buildUserMenuFromSession($user);
$frontendMenu = $this->transformMenuToFrontend($menu);
return new JsonResponse([
'user_name' => trim($user->getNombre() . ' ' . $user->getPapellido()),
'menu_tree' => $frontendMenu,
'user_id' => $user->getIdUsu(),
'system_name' => $this->systemName
]);
} catch (\Exception $e) {
$this->logger->error('Error en get-menu', [
'email' => $email,
'user_id' => $userId,
'error' => $e->getMessage()
]);
return $this->jsonError('Error building menu', $e->getMessage(), 500);
}
}
/**
* Construye el menú desde la sesión (para API endpoints)
*/
private function buildUserMenuFromSession(Usuario $user): array
{
$rol = $user->getRol()->getIdRol();
$acciones = $this->em->getRepository(ViewProfile::class)->findBy(['idRol' => $rol]);
$menu = [];
foreach ($acciones as $accion) {
if ($accion->getNivel() > 0) {
if (empty($accion->getSubmenu())) {
$menu[$accion->getOrden()] = [
"idMenu" => $accion->getIdMenu(),
"menu" => $accion->getModulo(),
"icono" => $accion->getIcono(),
"ruta" => $accion->getRuta(),
"submenu" => false
];
} else {
if (!array_key_exists($accion->getOrden(), $menu)) {
$menu[$accion->getOrden()] = [
"idMenu" => $accion->getIdMenu(),
"menu" => $accion->getModulo(),
"icono" => $accion->getIcono(),
"ruta" => "#"
];
}
$menu[$accion->getOrden()]["submenu"][] = [
"idMenu" => $accion->getIdSubmenu(),
"menu" => $accion->getSubmenu(),
"icono" => "fa-angle-double-up",
"ruta" => $accion->getRuta()
];
}
}
}
ksort($menu);
return array_values($menu);
}
/**
* Transforma el menú al formato del frontend
*/
private function transformMenuToFrontend(array $menu, int $startKey = 2): array
{
$keyCounter = $startKey;
$transform = function ($item) use (&$transform, &$keyCounter) {
$children = [];
if (!empty($item['submenu']) && is_array($item['submenu'])) {
foreach ($item['submenu'] as $subItem) {
$children[] = $transform($subItem);
}
}
$path = ($item['ruta'] === '#' || $item['ruta'] === null)
? null
: '/' . ltrim($item['ruta'], '/');
return [
'key' => $keyCounter++,
'label' => $item['menu'] ?? '',
'icon' => $this->mapIcon($item['icono'] ?? ''),
'path' => $path,
'external' => true,
'children' => $children
];
};
$result = array_map($transform, $menu);
return [[
'key' => 1,
'label' => 'Sigmec',
'icon' => 'UsergroupAddOutlined',
'path' => null,
'external' => true,
'children' => $result
]];
}
private function mapIcon(string $faIcon): ?string
{
return self::ICON_MAP[$faIcon] ?? 'RightOutlined';
}
/**
* Método helper para respuestas de error consistentes
*/
private function jsonError(string $error, string $message = '', int $status = 400): JsonResponse
{
$response = [
'error' => $error,
'system_name' => $this->systemName
];
if (!empty($message)) {
$response['message'] = $message;
}
return new JsonResponse($response, $status);
}
}