src/Controller/IntegrationController.php line 487

Open in your IDE?
  1. <?php
  2. namespace App\Controller;
  3. use App\Entity\Module;
  4. use App\Entity\Usuario;
  5. use App\Entity\ViewProfile;
  6. use App\Entity\Periodo;
  7. use Doctrine\ORM\EntityManagerInterface;
  8. use Doctrine\Persistence\ManagerRegistry;
  9. use Psr\Log\LoggerInterface;
  10. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
  11. use Symfony\Component\HttpFoundation\JsonResponse;
  12. use Symfony\Component\HttpFoundation\Request;
  13. use Symfony\Component\HttpFoundation\Response;
  14. use Symfony\Component\Routing\Annotation\Route;
  15. use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
  16. use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
  17. use Symfony\Component\HttpFoundation\Session\SessionInterface;
  18. use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
  19. use App\Service\JwtTokenService;
  20. use Firebase\JWT\ExpiredException;
  21. use Symfony\Component\Routing\Exception\RouteNotFoundException;
  22. #[Route('/api/integration')]
  23. class IntegrationController extends AbstractController
  24. {
  25.     private EntityManagerInterface $em;
  26.     private ManagerRegistry $doctrine;
  27.     private TokenStorageInterface $tokenStorage;
  28.     private SessionInterface $session;
  29.     private string $systemName;
  30.     private LoggerInterface $logger;
  31.     private JwtTokenService $jwtTokenService;
  32.     // Constante para mapeo de iconos
  33.     private const ICON_MAP = [
  34.         'fa-chart-line' => 'DotChartOutlined',
  35.         'fa-users-cog' => 'TeamOutlined',
  36.         'fa-book' => 'BookOutlined',
  37.         'fa-calendar-alt' => 'CalendarOutlined',
  38.         'fa-users' => 'UsergroupAddOutlined',
  39.         'fa-home' => 'HomeOutlined',
  40.         'fa-cogs' => 'SettingOutlined',
  41.         'fa-angle-double-up' => 'ArrowUpOutlined',
  42.         'fa-user' => 'UserOutlined',
  43.         'fa-file' => 'FileOutlined',
  44.         'fa-search' => 'SearchOutlined',
  45.         'fa-bell' => 'BellOutlined',
  46.         'fa-dashboard' => 'DashboardOutlined',
  47.         'fa-appstore' => 'AppstoreOutlined',
  48.         'fa-area-chart' => 'AreaChartOutlined',
  49.         'fa-bar-chart' => 'BarChartOutlined'
  50.     ];
  51.     public function __construct(
  52.         EntityManagerInterface $em,
  53.         ManagerRegistry $doctrine,
  54.         TokenStorageInterface $tokenStorage,
  55.         SessionInterface $session,
  56.         LoggerInterface $logger,
  57.         JwtTokenService $jwtTokenService
  58.     ) {
  59.         $this->em = $em;
  60.         $this->doctrine = $doctrine;
  61.         $this->tokenStorage = $tokenStorage;
  62.         $this->session = $session;
  63.         $this->logger = $logger;
  64.         $this->jwtTokenService = $jwtTokenService;
  65.         $this->systemName = $_ENV['SYSTEM_NAME'] ?? 'UNKNOWN_SYSTEM';
  66.     }
  67.     /**
  68.      * Endpoint principal para SSO Login
  69.      * Valida token JWT y establece sesión siguiendo la misma lógica del login normal
  70.      */
  71.     #[Route('/sso-login', name: 'integration_sso_login', methods: ['GET'])]
  72.     public function ssoLogin(Request $request): Response
  73.     {
  74.         $token = $request->query->get('token');
  75.         $redirectPath = $request->query->get('redirect');
  76.         if (!$token) {
  77.             $this->logger->warning('SSO Login: Token no proporcionado');
  78.             return $this->jwtTokenService->jsonError('Token SSO es requerido', '', 400);
  79.         }
  80.         try {
  81.             // 1. Decodificar y validar token JWT
  82.             $decoded = $this->jwtTokenService->decodeAndValidateJWT($token);
  83.             if (!isset($decoded->email)) {
  84.                 throw new \Exception('Token inválido: falta email');
  85.             }
  86.             // 2. Buscar usuario
  87.             $user = $this->findUserByEmail($decoded->email);
  88.             if (!$user) {
  89.                 $this->logger->info('SSO Login: Usuario no encontrado', [
  90.                     'email' => $decoded->email
  91.                 ]);
  92.                 return $this->render('restringido.html.twig', [], new Response('', 403));
  93.             }
  94.             // 3. VALIDACIONES DEL USUARIO (misma lógica que login normal)
  95.             $this->validateUserForLogin($user);
  96.             // 4. VALIDAR SI YA EXISTE SESIÓN ACTIVA VÁLIDA
  97.             if ($this->hasValidSession($user)) {
  98.                 $this->logger->info('SSO Login: Sesión existente válida, redirigiendo', [
  99.                     'user_id' => $user->getIdUsu(),
  100.                     'email' => $user->getCorreo()
  101.                 ]);
  102.                 
  103.                 $targetPath = $this->determineRedirectPath($redirectPath);
  104.                 return $this->redirect($targetPath . '?embedded=1&sso=1');
  105.             }
  106.             // 5. No hay sesión válida, crear una nueva
  107.             $this->logger->info('SSO Login: Creando nueva sesión', [
  108.                 'user_id' => $user->getIdUsu(),
  109.                 'email' => $user->getCorreo()
  110.             ]);
  111.             // ***  Invalidar cualquier sesión previa ***
  112.             $this->invalidatePreviousSession();
  113.             $this->authenticateUserInSession($user, $request);
  114.             
  115.             $targetPath = $this->determineRedirectPath($redirectPath);
  116.             return $this->redirect($targetPath . '?embedded=1&sso=1');
  117.         } catch (ExpiredException $e) {
  118.             $this->logger->warning('SSO Login: Token expirado', [
  119.                 'error' => $e->getMessage()
  120.             ]);
  121.             return $this->jwtTokenService->jsonError(
  122.                 'Token SSO expirado',
  123.                 'Por favor, vuelve a intentar desde el sistema principal',
  124.                 401
  125.             );
  126.         } catch (CustomUserMessageAuthenticationException $e) {
  127.             // Excepción de validación de usuario
  128.             $this->logger->warning('SSO Login: Validación de usuario falló', [
  129.                 'error' => $e->getMessage()
  130.             ]);
  131.             return $this->render('restringido.html.twig', [
  132.                 'mensaje' => $e->getMessage()
  133.             ], new Response('', 403));
  134.         } catch (\Exception $e) {
  135.             $this->logger->error('SSO Login: Error en autenticación', [
  136.                 'error' => $e->getMessage(),
  137.                 'trace' => $e->getTraceAsString()
  138.             ]);
  139.             
  140.             return $this->jwtTokenService->jsonError('Error en autenticación SSO', $e->getMessage(), 401);
  141.         }
  142.     }
  143.     /**
  144.      * Verifica si el usuario tiene una sesión activa y válida
  145.      */
  146.     private function hasValidSession(Usuario $user): bool
  147.     {
  148.         $currentToken = $this->tokenStorage->getToken();
  149.         
  150.         if (!$currentToken) {
  151.             return false;
  152.         }
  153.         $sessionUser = $currentToken->getUser();
  154.         
  155.         // Verificar que sea un Usuario válido y que coincida con el email
  156.         if (!$sessionUser instanceof Usuario) {
  157.             return false;
  158.         }
  159.         if ($sessionUser->getCorreo() !== $user->getCorreo()) {
  160.             return false;
  161.         }
  162.         // Verificar que los datos de sesión críticos existan
  163.         if (!$this->session->has('perfil') || !$this->session->has('menu')) {
  164.             $this->logger->warning('Sesión sin datos críticos, regenerando', [
  165.                 'user_id' => $user->getIdUsu()
  166.             ]);
  167.             return false;
  168.         }
  169.         return true;
  170.     }
  171.     /**
  172.      * Busca un usuario por email
  173.      */
  174.     private function findUserByEmail(string $email): ?Usuario
  175.     {
  176.         return $this->em->getRepository(Usuario::class)
  177.             ->findOneBy(['correo' => $email]);
  178.     }
  179.     /**
  180.      * Busca un usuario por email o ID
  181.      */
  182.     private function findUserByEmailOrId(?string $email, ?int $userId): ?Usuario
  183.     {
  184.         if ($userId) {
  185.             return $this->em->getRepository(Usuario::class)->find($userId);
  186.         }
  187.         
  188.         if ($email) {
  189.             return $this->findUserByEmail($email);
  190.         }
  191.         return null;
  192.     }
  193.     /**
  194.      * Valida que el usuario pueda iniciar sesión
  195.      * *** CORREGIDO: Misma lógica exacta que LoginAuthenticator ***
  196.      * 
  197.      * @throws CustomUserMessageAuthenticationException si el usuario no puede iniciar sesión
  198.      */
  199.     private function validateUserForLogin(Usuario $user): void
  200.     {
  201.         // Validación 1: Usuario Pendiente
  202.         if ($user->getEstatus() === 'Pendiente') {
  203.             $this->logger->warning('Intento de login SSO con usuario pendiente', [
  204.                 'user_id' => $user->getIdUsu(),
  205.                 'email' => $user->getCorreo()
  206.             ]);
  207.             throw new CustomUserMessageAuthenticationException('Access request pending evaluation.');
  208.         }
  209.         // Validación 2: Usuario Rechazado
  210.         if ($user->getEstatus() === 'Rechazado') {
  211.             $this->logger->warning('Intento de login SSO con usuario rechazado', [
  212.                 'user_id' => $user->getIdUsu(),
  213.                 'email' => $user->getCorreo()
  214.             ]);
  215.             throw new CustomUserMessageAuthenticationException('Access request has been rejected.');
  216.         }
  217.         // Validación 3: Usuario Suspendido
  218.         if ($user->getEstatus() === 'Suspendido') {
  219.             $this->logger->warning('Intento de login SSO con usuario suspendido', [
  220.                 'user_id' => $user->getIdUsu(),
  221.                 'email' => $user->getCorreo()
  222.             ]);
  223.             throw new CustomUserMessageAuthenticationException('Access credentials suspended.');
  224.         }
  225.         $this->logger->debug('Usuario validado correctamente para SSO login', [
  226.             'user_id' => $user->getIdUsu(),
  227.             'email' => $user->getCorreo(),
  228.             'estatus' => $user->getEstatus()
  229.         ]);
  230.     }
  231.     /**
  232.      * Establece la autenticación del usuario en la sesión
  233.      */
  234.     private function authenticateUserInSession(Usuario $user, Request $request): void
  235.     {
  236.         try {
  237.             // *** AGREGADO: Actualizar estatus de "Aceptado" a "Activo" en primer login ***
  238.             $estatusActual = $user->getEstatus();
  239.             if ($estatusActual === "Aceptado") {
  240.                 $user->setEstatus("Activo");
  241.                 $this->em->persist($user);
  242.                 $this->em->flush();
  243.                 
  244.                 $this->logger->info('Estatus actualizado de Aceptado a Activo', [
  245.                     'user_id' => $user->getIdUsu()
  246.                 ]);
  247.             }
  248.             // 1. Crear y establecer token de autenticación
  249.             $token = new UsernamePasswordToken($user, 'main', $user->getRoles());
  250.             $this->tokenStorage->setToken($token);
  251.             // 2. Obtener periodo actual
  252.             $periodo = $this->em->getRepository(Periodo::class)->findOneBy(['actual' => 1]);
  253.             $this->session->set('periodo', $periodo);
  254.         
  255.             // 3. Construir perfil y menú (MISMA LÓGICA QUE LoginAuthenticator)
  256.             $rol = $user->getRol()->getIdRol();
  257.             $acciones = $this->em->getRepository(ViewProfile::class)->findBy(['idRol' => $rol]);
  258.             
  259.             $perfil = [];
  260.             $menu = [];
  261.             
  262.             foreach ($acciones as $accion) {
  263.                 // *** CORREGIDO: Construcción de perfil con índice por idMod ***
  264.                 $perfil[$accion->getIdMod()] = [
  265.                     "idMod" => $accion->getIdMod(),
  266.                     "nombre" => $accion->getModulo(),
  267.                     "submenu" => $accion->getSubmenu(),
  268.                     "idBeh" => $accion->getIdBeh(),
  269.                     "nivel" => $accion->getNivel(),
  270.                     "comportamiento" => $accion->getComportamiento(),
  271.                     "descripcion" => $accion->getDescripcion(),
  272.                     "origen" => ($accion->getOrigen()) ? true : false
  273.                 ];
  274.                 
  275.                 // *** Construcción del menú lateral ***
  276.                 if ($accion->getNivel() > 0) {
  277.                     if (empty($accion->getSubmenu())) {
  278.                         $menu[$accion->getOrden()] = [
  279.                             "idMenu" => $accion->getIdMenu(),
  280.                             "menu" => $accion->getModulo(),
  281.                             "icono" => $accion->getIcono(),
  282.                             "ruta" => $accion->getRuta(),
  283.                             "submenu" => false,
  284.                             "origen" => ($accion->getOrigen()) ? true : false
  285.                         ];
  286.                     } else {
  287.                         if (!array_key_exists($accion->getOrden(), $menu)) {
  288.                             $menu[$accion->getOrden()] = [
  289.                                 "idMenu" => $accion->getIdMenu(),
  290.                                 "menu" => $accion->getModulo(),
  291.                                 "icono" => $accion->getIcono(),
  292.                                 "ruta" => "#",
  293.                                 "origen" => ($accion->getOrigen()) ? true : false
  294.                             ];
  295.                         }
  296.                         $menu[$accion->getOrden()]["submenu"][] = [
  297.                             "idMenu" => $accion->getIdSubmenu(),
  298.                             "menu" => $accion->getSubmenu(),
  299.                             "icono" => "fa-angle-double-up",
  300.                             "ruta" => $accion->getRuta(),
  301.                             "origen" => ($accion->getOrigen()) ? true : false
  302.                         ];
  303.                     }
  304.                 }
  305.             }
  306.             
  307.             ksort($menu);
  308.             
  309.             // 4. Establecer datos en sesión
  310.             $this->session->set('perfil', $perfil);
  311.             $this->session->set('menu', $menu);
  312.             $this->logger->info('Sesión establecida exitosamente con estructura original', [
  313.                 'user_id' => $user->getIdUsu(),
  314.                 'email' => $user->getCorreo(),
  315.                 'perfil_items' => count($perfil),
  316.                 'menu_items' => count($menu)
  317.             ]);
  318.         }
  319.         catch (\Exception $e) {
  320.             $this->logger->error('Error validando token de integración', [
  321.                 'error' => $e->getMessage()
  322.             ]); 
  323.         } 
  324.     }
  325.     /**
  326.      * Determina la ruta de redirección validando seguridad
  327.      */
  328.     private function determineRedirectPath(?string $redirectPath): string
  329.     {
  330.         if (empty($redirectPath)) {
  331.             return '/';
  332.         }
  333.         $redirectPath = trim($redirectPath);
  334.         // Prevenir redirecciones externas
  335.         if (preg_match('#^(https?:)?//#i', $redirectPath)) {
  336.             $this->logger->warning('Intento de redirección externa bloqueado', [
  337.                 'redirect' => $redirectPath
  338.             ]);
  339.             return '/';
  340.         }
  341.         $redirectPath = ltrim($redirectPath, '/');
  342.         
  343.         try {
  344.             return $this->generateUrl($redirectPath);
  345.         } catch (RouteNotFoundException $e) {
  346.             $this->logger->warning('Ruta Symfony no encontrada', [
  347.                 'redirect' => $redirectPath
  348.             ]);
  349.             
  350.             return '/';
  351.         }
  352.     }
  353.     /**
  354.      * Invalida completamente la sesión anterior
  355.     */
  356.     private function invalidatePreviousSession(): void
  357.     {
  358.         $currentToken = $this->tokenStorage->getToken();
  359.         
  360.         if ($currentToken && $currentToken->getUser() instanceof Usuario) {
  361.             $this->tokenStorage->setToken(null);
  362.             $this->session->invalidate();
  363.             $this->logger->debug('Sesión de otro usuario invalidada completamente');
  364.         } else {
  365.             $this->session->migrate(true);
  366.             $this->logger->debug('ID de sesión regenerado (sin sesión previa)');
  367.         }
  368.     }
  369.     // ==================== ENDPOINTS API ====================
  370.     #[Route('/test', name: 'integration_test', methods: ['GET'])]
  371.     public function test(): JsonResponse
  372.     {
  373.         return new JsonResponse([
  374.             'status' => 'OK',
  375.             'message' => 'Integration endpoint working correctly',
  376.             'system_name' => $this->systemName,
  377.             'timestamp' => date('Y-m-d H:i:s')
  378.         ]);
  379.     }
  380.     #[Route('/dev-token', name: 'integration_dev_token', methods: ['GET'])]
  381.     public function devToken(Request $request): JsonResponse
  382.     {
  383.         if (($_ENV['APP_ENV'] ?? 'prod') !== 'dev') {
  384.             return new JsonResponse(['error' => 'Not allowed'], 403);
  385.         }
  386.         $email = trim((string) $request->query->get('email', ''));
  387.         if ($email === '') {
  388.             return new JsonResponse([
  389.                 'error'   => 'El parámetro "email" es requerido para generar el token de prueba',
  390.                 'example' => '/api/integration/dev-token?email=usuario@conalepmex.edu.mx',
  391.             ], 400);
  392.         }
  393.         $token = $this->jwtTokenService->encodeDevToken($email);
  394.         return new JsonResponse([
  395.             'token' => $token,
  396.             'email' => $email,
  397.         ]);
  398.     }
  399.     #[Route('/check-user', name: 'integration_check_user', methods: ['POST'])]
  400.     public function checkUser(Request $request): JsonResponse
  401.     {
  402.         if (!$this->jwtTokenService->validateIntegrationToken($request)) {
  403.             return $this->jwtTokenService->jsonError('Invalid or missing integration token', '', 401);
  404.         }
  405.         $data = json_decode($request->getContent(), true);
  406.         $email = $data['email'] ?? null;
  407.         if (!$email) {
  408.             return $this->jwtTokenService->jsonError('Email is required', '', 400);
  409.         }
  410.         try {
  411.             $user = $this->findUserByEmail($email);
  412.             if (!$user) {
  413.                 return new JsonResponse([
  414.                     'has_access' => false,
  415.                     'system_name' => $this->systemName,
  416.                     'message' => 'User not found in this system'
  417.                 ]);
  418.             }
  419.             return new JsonResponse([
  420.                 'has_access' => true,
  421.                 'user_id' => $user->getIdUsu(),
  422.                 'email' => $user->getCorreo(),
  423.                 'name' => trim($user->getNombre() . ' ' . $user->getPapellido()),
  424.                 'roles' => [$user->getRol()->getNombre()],
  425.                 'system_name' => $this->systemName
  426.             ]);
  427.         } catch (\Exception $e) {
  428.             $this->logger->error('Error en check-user', [
  429.                 'email' => $email,
  430.                 'error' => $e->getMessage()
  431.             ]);
  432.             return $this->jwtTokenService->jsonError('Database error', $e->getMessage(), 500);
  433.         }
  434.     }
  435.     #[Route('/get-menu', name: 'integration_get_menu', methods: ['POST'])]
  436.     public function getMenu(Request $request): JsonResponse
  437.     {
  438.         if (!$this->jwtTokenService->validateIntegrationToken($request)) {
  439.             return $this->jwtTokenService->jsonError('Invalid or missing integration token', '', 401);
  440.         }
  441.         $data = json_decode($request->getContent(), true);
  442.         $email = $data['email'] ?? null;
  443.         $userId = $data['user_id'] ?? null;
  444.         if (!$email && !$userId) {
  445.             return $this->jwtTokenService->jsonError('Email or user_id is required', '', 400);
  446.         }
  447.         try {
  448.             $user = $this->findUserByEmailOrId($email, $userId);
  449.             if (!$user) {
  450.                 return new JsonResponse([
  451.                     'menu' => [],
  452.                     'message' => 'User not found'
  453.                 ]);
  454.             }
  455.             $menu = $this->buildUserMenuFromSession($user);
  456.             $frontendMenu = $this->transformMenuToFrontend($menu);
  457.             return new JsonResponse([
  458.                 'user_name' => trim($user->getNombre() . ' ' . $user->getPapellido()),
  459.                 'menu_tree' => $frontendMenu,
  460.                 'user_id' => $user->getIdUsu(),
  461.                 'system_name' => $this->systemName
  462.             ]);
  463.         } catch (\Exception $e) {
  464.             $this->logger->error('Error en get-menu', [
  465.                 'email' => $email,
  466.                 'user_id' => $userId,
  467.                 'error' => $e->getMessage()
  468.             ]);
  469.             return $this->jwtTokenService->jsonError('Error building menu', $e->getMessage(), 500);
  470.         }
  471.     }
  472.     /**
  473.      * Construye el menú desde la sesión (para API endpoints)
  474.      */
  475.     private function buildUserMenuFromSession(Usuario $user): array
  476.     {
  477.         $rol = $user->getRol()->getIdRol();
  478.         $acciones = $this->em->getRepository(ViewProfile::class)->findBy(['idRol' => $rol]);
  479.         
  480.         $menu = [];
  481.         
  482.         foreach ($acciones as $accion) {
  483.             if ($accion->getNivel() > 0) {
  484.                 if (empty($accion->getSubmenu())) {
  485.                     $menu[$accion->getOrden()] = [
  486.                         "idMenu" => $accion->getIdMenu(),
  487.                         "menu" => $accion->getModulo(),
  488.                         "icono" => $accion->getIcono(),
  489.                         "ruta" => $accion->getRuta(),
  490.                         "submenu" => false,
  491.                         "origen" => ($accion->getOrigen()) ? true : false
  492.                     ];
  493.                 } else {
  494.                     if (!array_key_exists($accion->getOrden(), $menu)) {
  495.                         $menu[$accion->getOrden()] = [
  496.                             "idMenu" => $accion->getIdMenu(),
  497.                             "menu" => $accion->getModulo(),
  498.                             "icono" => $accion->getIcono(),
  499.                             "ruta" => "#",
  500.                             "origen" => ($accion->getOrigen()) ? true : false
  501.                         ];
  502.                     }
  503.                     $menu[$accion->getOrden()]["submenu"][] = [
  504.                         "idMenu" => $accion->getIdSubmenu(),
  505.                         "menu" => $accion->getSubmenu(),
  506.                         "icono" => "fa-angle-double-up",
  507.                         "ruta" => $accion->getRuta(),
  508.                         "origen" => ($accion->getOrigen()) ? true : false
  509.                     ];
  510.                 }
  511.             }
  512.         }
  513.         
  514.         ksort($menu);
  515.         return array_values($menu);
  516.     }
  517.     /**
  518.      * Transforma el menú al formato del frontend
  519.      */
  520.     private function transformMenuToFrontend(array $menu, int $startKey = 2): array
  521.     {
  522.         $keyCounter = $startKey;
  523.         
  524.         $transform = function ($item) use (&$transform, &$keyCounter) {
  525.             $children = [];
  526.             if (!empty($item['submenu']) && is_array($item['submenu'])) {
  527.                 foreach ($item['submenu'] as $subItem) {
  528.                     $children[] = $transform($subItem);
  529.                 }
  530.             }
  531.             $path = ($item['ruta'] === '#' || $item['ruta'] === null)
  532.                 ? null
  533.                 : '/' . ltrim($item['ruta'], '/');
  534.             return [
  535.                 'key' => $keyCounter++,
  536.                 'label' => $item['menu'] ?? '',
  537.                 'icon' => $this->mapIcon($item['icono'] ?? ''),
  538.                 'path' => $path,
  539.                 'external' => ($item['origen']) ? true : false,
  540.                 'children' => $children
  541.             ];
  542.         };
  543.         $result = array_map($transform, $menu);
  544.         return [[
  545.             'key' => 1,
  546.             'label' => 'Sigmec',
  547.             'icon' => 'UsergroupAddOutlined',
  548.             'path' => null,
  549.             'external' => true,
  550.             'children' => $result
  551.         ]];
  552.     }
  553.     private function mapIcon(string $faIcon): ?string
  554.     {
  555.         return self::ICON_MAP[$faIcon] ?? 'RightOutlined';
  556.     }
  557. }