# 🏛️ Guía Técnica de Integración Oficial: Llave MX (OAuth 2.0)
### Sistema de Convocatorias IMCINE / FOCINE

Esta guía documenta la arquitectura, archivos creados, funciones modificadas y el procedimiento paso a paso para replicar e integrar el inicio de sesión único con **Llave MX (Gobierno de México)** en cualquier otra convocatoria o sistema del Instituto Mexicano de Cinematografía.

---

## 📑 Tabla de Contenido
1. [Resumen de Arquitectura y Flujo](#1-resumen-de-arquitectura-y-flujo)
2. [Mapa de Archivos Creados y Modificados](#2-mapa-de-archivos-creados-y-modificados)
3. [Paso 1: Variables Globales y Constantes](#paso-1-variables-globales-y-constantes)
4. [Paso 2: Capa de Base de Datos (DAO)](#paso-2-capa-de-base-de-datos-dao)
5. [Paso 3: Controlador Central de Llave MX](#paso-3-controlador-central-de-llave-mx)
6. [Paso 4: Callback y Captura de Código](#paso-4-callback-y-captura-de-código)
7. [Paso 5: Ajuste de Sesión y Protección (`init_session.php`)](#paso-5-ajuste-de-sesión-y-protección-init_sessionphp)
8. [Paso 6: Vista de Acceso (`acceso.php`) Responsive y Modal de Convocatoria Cerrada](#paso-6-vista-de-acceso-accesophp-responsive)
9. [Paso 7: Control de Acceso (`acceso_permitido`), Estatus y Vista `acceso_denegado.php`](#paso-7-control-de-acceso-acceso_permitido-estatus-y-vista-acceso_denegadophp)
10. [Paso 8: Administración de Vigencia en `secciones.php`](#paso-8-administración-de-vigencia-en-seccionesphp)
11. [Checklist Rápido de Replicación](#11-checklist-rápido-de-replicación-en-nueva-convocatoria)
12. [Solución de Problemas Comunes (Troubleshooting)](#12-solución-de-problemas-comunes-troubleshooting)

---

## 1. Resumen de Arquitectura y Flujo

```
 [ Postulante ]
       │
       ▼
 ┌──────────────┐      Redirige con Client ID & Redirect URI
 │  acceso.php  │ ──────────────────────────────────────────────┐
 └──────────────┘                                               │
       ▲                                                        ▼
       │                                           ┌────────────────────────┐
       │ (Si convocatoria cerrada: Modal no        │   Portal Llave MX      │
       │  cerrable y bloqueo de interacción)       │  (val-llave.infotec)   │
       │                                           └────────────────────────┘
       │                                                        │
       │                                Retorna con ?code=XXXXX │  Usuario autenticado
       │                                                        ▼
 ┌────────────────────────┐ ◀───────────────────────────────────┘
 │  llavemx_callback.php  │
 └────────────────────────┘
       │
       ▼
 ┌────────────────────────┐      POST /ws/rest/oauth/obtenerToken (Basic Auth)
 │   control_llavemx.php  │ ──────────────────────────────────────────────────┐
 └────────────────────────┘                                                   │
       │   ▲                                                                  ▼
       │   │                                                     ┌────────────────────────┐
       │   └──────────────── Recibe Access Token & Datos         │    API de Llave MX     │
       │                     (CURP, Nombre, RFC, Email, Moral)   │  (val-api-llave.info)  │
       ▼                                                         └────────────────────────┘
 ┌────────────────────────┐
 │     dao_sistema.php    │  Busca por CURP en `proyectos`
 └────────────────────────┘  Si existe -> Actualiza datos verificados
       │                     Si no existe -> Auto-registra proyecto + archivos
       │
       ├──▶ ¿acceso_permitido == 0 o estatus_proyecto == 98?
       │         │
       │         ├── SI ──▶ Redirige a acceso_denegado.php (con contador 10s y botón a acceso.php)
       │         │
       │         └── NO ──▶ Inicia sesión PHP -> Redirige a datos.php / conclusion.php
```

---

## 2. Mapa de Archivos Creados y Modificados

| Archivo | Tipo | Función |
| :--- | :---: | :--- |
| `/api_imcine_v2/css_base/llaveMX.css` | **NUEVO (Central)** | Hoja de estilos oficial centralizada para interfaz Llave MX, modales de vigencia y acceso denegado. |
| `header.php` | **Modificado** | Inclusión de `llaveMX.css` vía `DIRECCION_CSS.'llaveMX.css'` para todas las vistas. |
| `backEnd/variables_globales.php` | **Modificado** | Configuración de credenciales OAuth2, Basic Auth y URLs de endpoints. |
| `backEnd/DAO/dao_sistema.php` | **Modificado** | Métodos para buscar por CURP, auto-registrar proyecto y actualizar datos. |
| `backEnd/CONTROL/control_llavemx.php` | **NUEVO** | Clase controladora del flujo OAuth2, token, usuario, control de `acceso_permitido` y vigencia. |
| `llavemx_callback.php` | **NUEVO** | Receptor del código de retorno de Llave MX y redirección. |
| `acceso_denegado.php` | **NUEVO** | Vista institucional gob.mx explicativa con cuenta regresiva de 10s y botón manual. |
| `backEnd/init_session.php` | **Modificado** | Intercepción de `$_GET['code']` y blindaje contra postulantes con `acceso_permitido = 0` o `estatus = 98`. |
| `backEnd/CONTROL/control_sistema.php` | **Modificado** | Agregado `exit()` en `verificando_sesion()` tras la redirección. |
| `acceso.php` | **Modificado** | Interfaz 2 columnas con Llave MX y modal bloqueante no cerrable de convocatoria cerrada. |
| `secciones.php` | **Modificado** | Centralización de vigencia (eliminación de crear cuenta y gestión de acceso a la convocatoria). |
| `logs/llavemx_audit.log` | **NUEVO** | Bitácora de auditoría detallada de cada petición, login y bloqueo de acceso. |
| `imagenes/Logo_LlaveMX.png` | **NUEVO** | Logotipo oficial de Llave MX. |

---

## Paso 1: Variables Globales y Constantes

En el archivo `backEnd/variables_globales.php`, agregar el bloque de configuración:

```php
// ==========================================
// CONFIGURACIÓN LLAVE MX (OAuth 2.0)
// ==========================================
// Ambiente: 'sandbox' o 'produccion'
const LLAVEMX_AMBIENTE = "sandbox";

// Credenciales para integración mediante OAuth2 (Proporcionadas por ATDT)
const LLAVEMX_CLIENT_ID     = "TU_CLIENT_ID";
const LLAVEMX_CLIENT_SECRET = 'TU_CLIENT_SECRET';

// Credenciales de autenticación básica para uso de APIs de Llave MX
const LLAVEMX_AUTH_USER     = 'TU_USUARIO_BASIC_AUTH';
// IMPORTANTE: Usar comillas simples ' ' para evitar que caracteres como $ se interpreten como variables PHP
const LLAVEMX_AUTH_PASS     = 'TU_PASSWORD_BASIC_AUTH';

// URLs del servicio Llave MX
const LLAVEMX_URL_LOGIN_SANDBOX = "https://val-llave.infotec.mx/oauth.xhtml";
const LLAVEMX_URL_API_SANDBOX   = "https://val-api-llave.infotec.mx";

const LLAVEMX_URL_LOGIN_PROD    = "https://llave.gob.mx/oauth.xhtml";
const LLAVEMX_URL_API_PROD      = "https://api-llave.gob.mx";

// URL de retorno registrada en la plataforma Llave MX
define("LLAVEMX_REDIRECT_URI", $server . "/focine/NOMBRE_CONVOCATORIA/llavemx_callback.php");
```

---

## Paso 2: Capa de Base de Datos (DAO)

En `backEnd/DAO/dao_sistema.php`, agregar al final de la clase `ClaseDaoSistema` los 3 métodos de soporte para Llave MX:

```php
    // =========================================================================
    // MÉTODOS DE INTEGRACIÓN CON LLAVE MX
    // =========================================================================

    /**
     * Busca un proyecto existente a partir de la CURP o correo de Llave MX
     */
    public function buscarProyectoPorLlaveMX($curp, $correo = null) {
        $curpLimpia = trim($curp);
        $sql = "SELECT p.* FROM proyectos p WHERE UPPER(TRIM(p.curp_persona)) = UPPER('{$curpLimpia}')";
        
        if (!empty($correo)) {
            $correoLimpio = trim($correo);
            $sql .= " OR LOWER(TRIM(p.correo_electronico)) = LOWER('{$correoLimpio}')";
        }
        
        $sql .= " ORDER BY p.id_proyecto DESC LIMIT 1";
        return OBD::query_select($sql, $this->dsn);
    }

    /**
     * Registra automáticamente un proyecto cuando el usuario inicia sesión con Llave MX por primera vez
     */
    public function registrarProyectoDesdeLlaveMX($datos) {
        $nombre       = isset($datos['nombre']) ? addslashes(trim($datos['nombre'])) : '';
        $primerAp     = isset($datos['primer_apellido']) ? addslashes(trim($datos['primer_apellido'])) : '';
        $segundoAp    = isset($datos['segundo_apellido']) ? addslashes(trim($datos['segundo_apellido'])) : '';
        $curp         = isset($datos['curp']) ? strtoupper(trim($datos['curp'])) : '';
        $correo       = isset($datos['correo']) ? addslashes(trim($datos['correo'])) : '';
        $telefono     = isset($datos['telefono']) ? addslashes(trim($datos['telefono'])) : '';
        $tipoPersona  = isset($datos['tipo_persona']) ? intval($datos['tipo_persona']) : 1;
        $rfcFisica    = isset($datos['rfc_persona_fisica']) ? strtoupper(trim($datos['rfc_persona_fisica'])) : '';
        $rfcMoral     = isset($datos['rfc_persona_moral']) ? strtoupper(trim($datos['rfc_persona_moral'])) : '';
        $razonSocial  = isset($datos['razon_social']) ? addslashes(trim($datos['razon_social'])) : '';
        $userName     = !empty($curp) ? $curp : (!empty($correo) ? $correo : 'llavemx_' . time());
        $visitantemd5 = md5(date("Y-m-d H:i:s") . $userName);

        $sql = "INSERT INTO proyectos (
            nombre_participante,
            ap_paterno_participante,
            ap_materno_participante,
            correo_electronico,
            telefono_particular,
            persona_fiscal,
            curp_persona,
            rfc_persona,
            rfc_persona_moral,
            razon_social,
            user_name,
            visitantemd5,
            status,
            terminos_condiciones
        ) VALUES (
            '{$nombre}',
            '{$primerAp}',
            '{$segundoAp}',
            '{$correo}',
            '{$telefono}',
            {$tipoPersona},
            '{$curp}',
            '{$rfcFisica}',
            '{$rfcMoral}',
            '{$razonSocial}',
            '{$userName}',
            '{$visitantemd5}',
            " . SIN_FINALIZAR . ",
            1
        )";

        $res = OBD::query_insert($sql, $this->dsn);
        if (!$res) {
            return false;
        }

        $idProyecto = OBD::get_insert_id($this->dsn);

        // Inicializar registros de archivos_proyecto y comentarios_archivos_proyecto
        $sqlArchivos = "INSERT INTO archivos_proyecto (proyecto) VALUES ({$idProyecto})";
        OBD::query_insert($sqlArchivos, $this->dsn);

        $sqlComentarios = "INSERT INTO comentarios_archivos_proyecto (proyecto) VALUES ({$idProyecto})";
        OBD::query_insert($sqlComentarios, $this->dsn);

        return array(
            'id_proyecto'  => $idProyecto,
            'visitantemd5' => $visitantemd5,
            'user_name'    => $userName,
            'status'       => SIN_FINALIZAR
        );
    }

    /**
     * Actualiza datos del postulante a partir de Llave MX
     */
    public function actualizarDatosDesdeLlaveMX($idProyecto, $datos) {
        $updates = array();
        if (!empty($datos['nombre'])) updates[] = "nombre_participante = '" . addslashes(trim($datos['nombre'])) . "'";
        if (!empty($datos['primer_apellido'])) updates[] = "ap_paterno_participante = '" . addslashes(trim($datos['primer_apellido'])) . "'";
        if (!empty($datos['segundo_apellido'])) updates[] = "ap_materno_participante = '" . addslashes(trim($datos['segundo_apellido'])) . "'";
        if (!empty($datos['curp'])) updates[] = "curp_persona = '" . strtoupper(trim($datos['curp'])) . "'";
        if (!empty($datos['rfc_persona_fisica'])) updates[] = "rfc_persona = '" . strtoupper(trim($datos['rfc_persona_fisica'])) . "'";
        if (!empty($datos['rfc_persona_moral'])) updates[] = "rfc_persona_moral = '" . strtoupper(trim($datos['rfc_persona_moral'])) . "'";
        if (!empty($datos['razon_social'])) updates[] = "razon_social = '" . addslashes(trim($datos['razon_social'])) . "'";

        if (!empty($updates)) {
            $sql = "UPDATE proyectos SET " . implode(", ", $updates) . " WHERE id_proyecto = " . intval($idProyecto);
            return OBD::query_update($sql, $this->dsn);
        }
        return true;
    }
```

---

## Paso 3: Controlador Central de Llave MX

Crear el archivo `backEnd/CONTROL/control_llavemx.php`:

```php
<?php
/**
 * Controlador de Integración con Llave MX (OAuth 2.0)
 * IMCINE - Gobierno de México
 */
require_once dirname(__DIR__) . DIRECTORY_SEPARATOR . "variables_globales.php";
require_once dirname(__DIR__) . DIRECTORY_SEPARATOR . "DAO" . DIRECTORY_SEPARATOR . "dao_sistema.php";

class ClaseControlLlaveMX {

    private $clientId;
    private $clientSecret;
    private $authUser;
    private $authPass;
    private $urlLogin;
    private $urlApi;
    private $redirectUri;
    private $logFile;

    public function __construct() {
        $esSandbox = (defined('LLAVEMX_AMBIENTE') && LLAVEMX_AMBIENTE === 'sandbox');

        $this->clientId     = defined('LLAVEMX_CLIENT_ID') ? LLAVEMX_CLIENT_ID : '';
        $this->clientSecret = defined('LLAVEMX_CLIENT_SECRET') ? LLAVEMX_CLIENT_SECRET : '';
        $this->authUser     = defined('LLAVEMX_AUTH_USER') ? LLAVEMX_AUTH_USER : '';
        $this->authPass     = defined('LLAVEMX_AUTH_PASS') ? LLAVEMX_AUTH_PASS : '';
        $this->redirectUri  = defined('LLAVEMX_REDIRECT_URI') ? LLAVEMX_REDIRECT_URI : '';

        $this->urlLogin = $esSandbox 
            ? (defined('LLAVEMX_URL_LOGIN_SANDBOX') ? LLAVEMX_URL_LOGIN_SANDBOX : 'https://val-llave.infotec.mx/oauth.xhtml')
            : (defined('LLAVEMX_URL_LOGIN_PROD') ? LLAVEMX_URL_LOGIN_PROD : 'https://llave.gob.mx/oauth.xhtml');

        $this->urlApi = $esSandbox
            ? (defined('LLAVEMX_URL_API_SANDBOX') ? LLAVEMX_URL_API_SANDBOX : 'https://val-api-llave.infotec.mx')
            : (defined('LLAVEMX_URL_API_PROD') ? LLAVEMX_URL_API_PROD : 'https://api-llave.gob.mx');

        $this->logFile = dirname(dirname(__DIR__)) . DIRECTORY_SEPARATOR . "logs" . DIRECTORY_SEPARATOR . "llavemx_audit.log";
    }

    /**
     * Construye la URL oficial para redirigir al ciudadano al login de Llave MX
     */
    public function getUrlLogin($stateCustom = null) {
        if (session_status() === PHP_SESSION_NONE) {
            @session_start();
        }

        $state = $stateCustom ? $stateCustom : md5(uniqid(rand(), true));
        $_SESSION['llavemx_oauth_state'] = $state;

        $params = array(
            'client_id'     => $this->clientId,
            'redirect_uri'  => $this->redirectUri,
            'response_type' => 'code',
            'state'         => $state
        );

        return $this->urlLogin . '?' . http_build_query($params);
    }

    /**
     * Intercambia el Authorization Code por el Access Token
     */
    public function obtenerToken($code, $customRedirectUri = null) {
        $endpoint = rtrim($this->urlApi, '/') . '/ws/rest/oauth/obtenerToken';
        $redirectUri = !empty($customRedirectUri) ? $customRedirectUri : $this->redirectUri;

        $bodyData = array(
            "grantType"    => "authorization_code",
            "code"         => trim($code),
            "redirectUri"  => $redirectUri,
            "clientId"     => $this->clientId,
            "clientSecret" => $this->clientSecret
        );

        $payload = json_encode($bodyData);
        $headers = array('Content-Type: application/json', 'Accept: application/json');

        $this->auditarTransaccion('OBTENER_TOKEN_REQUEST', array('endpoint' => $endpoint, 'code' => $code, 'redirectUri' => $redirectUri));

        $ch = curl_init();
        curl_setopt($ch, CURLOPT_URL, $endpoint);
        curl_setopt($ch, CURLOPT_POST, true);
        curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
        curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_USERPWD, $this->authUser . ":" . $this->authPass);
        curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
        curl_setopt($ch, CURLOPT_TIMEOUT, 30);
        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);

        $response = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $curlError = curl_error($ch);
        curl_close($ch);

        $json = json_decode($response, true);

        $this->auditarTransaccion('OBTENER_TOKEN_RESPONSE', array(
            'httpCode'  => $httpCode,
            'curlError' => $curlError,
            'response'  => $json ? $json : $response
        ));

        if ($httpCode === 200 || $httpCode === 201) {
            $accessToken = isset($json['accessToken']) ? $json['accessToken'] : (isset($json['access_token']) ? $json['access_token'] : (isset($json['token']) ? $json['token'] : ''));
            return array('success' => true, 'accessToken' => $accessToken, 'raw' => $json);
        }

        // Si falló con URL dinámica, reintentar con el default
        if ($redirectUri !== $this->redirectUri) {
            return $this->obtenerToken($code, $this->redirectUri);
        }

        return array(
            'success'   => false,
            'httpCode'  => $httpCode,
            'error'     => !empty($json['mensaje']) ? $json['mensaje'] : ($curlError ? $curlError : 'Error al obtener token de Llave MX'),
            'raw'       => $json ? $json : $response
        );
    }

    /**
     * Consulta los datos del usuario autenticado (Persona Física)
     */
    public function obtenerDatosUsuario($accessToken) {
        $endpoint = rtrim($this->urlApi, '/') . '/ws/rest/oauth/datosUsuario';
        $headers = array('accessToken: ' . $accessToken, 'Accept: application/json');

        $ch = curl_init();
        curl_setopt($ch, CURLOPT_URL, $endpoint);
        curl_setopt($ch, CURLOPT_HTTPGET, true);
        curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_USERPWD, $this->authUser . ":" . $this->authPass);
        curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
        curl_setopt($ch, CURLOPT_TIMEOUT, 30);
        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);

        $response = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $curlError = curl_error($ch);
        curl_close($ch);

        $json = json_decode($response, true);
        $this->auditarTransaccion('DATOS_USUARIO_RESPONSE', array('httpCode' => $httpCode, 'response' => $json ? $json : $response));

        if ($httpCode === 200) {
            return array('success' => true, 'datos' => $json);
        }

        return array('success' => false, 'httpCode' => $httpCode, 'error' => !empty($json['mensaje']) ? $json['mensaje'] : 'Error al consultar datos de usuario');
    }

    /**
     * Consulta si el usuario tiene personas morales vinculadas
     */
    public function obtenerPerfilMoral($accessToken) {
        $endpoint = rtrim($this->urlApi, '/') . '/ws/rest/oauth/perfil/moral';
        $headers = array('accessToken: ' . $accessToken, 'Accept: application/json');

        $ch = curl_init();
        curl_setopt($ch, CURLOPT_URL, $endpoint);
        curl_setopt($ch, CURLOPT_HTTPGET, true);
        curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_USERPWD, $this->authUser . ":" . $this->authPass);
        curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
        curl_setopt($ch, CURLOPT_TIMEOUT, 30);
        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);

        $response = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        $json = json_decode($response, true);
        if ($httpCode === 200) {
            return array('success' => true, 'datos' => $json);
        }
        return array('success' => false, 'error' => 'Sin perfiles morales asociados');
    }

    /**
     * Orquestador principal: Canjea el código, consulta datos, sincroniza con BD e inicia sesión PHP
     */
    public function procesarAutenticacion($code, $state = null, $customRedirectUri = null) {
        if (session_status() === PHP_SESSION_NONE) {
            @session_start();
        }

        // 1. Canjear código por Token
        $resToken = $this->obtenerToken($code, $customRedirectUri);
        if (!$resToken['success'] || empty($resToken['accessToken'])) {
            return array('success' => false, 'error' => 'No se pudo obtener el token de acceso de Llave MX: ' . (!empty($resToken['error']) ? $resToken['error'] : 'Token vacío'));
        }

        $accessToken = $resToken['accessToken'];
        $_SESSION['llavemx_access_token'] = $accessToken;

        // 2. Obtener datos del usuario
        $resUsuario = $this->obtenerDatosUsuario($accessToken);
        if (!$resUsuario['success'] || empty($resUsuario['datos'])) {
            return array('success' => false, 'error' => 'No se pudieron consultar los datos del usuario en Llave MX: ' . (!empty($resUsuario['error']) ? $resUsuario['error'] : 'Respuesta vacía'));
        }

        $datosLmx = $resUsuario['datos'];
        $curp      = isset($datosLmx['curp']) ? trim($datosLmx['curp']) : (isset($datosLmx['CURP']) ? trim($datosLmx['CURP']) : '');
        $nombre    = isset($datosLmx['nombre']) ? trim($datosLmx['nombre']) : '';
        $primerAp  = isset($datosLmx['primerApellido']) ? trim($datosLmx['primerApellido']) : '';
        $segundoAp = isset($datosLmx['segundoApellido']) ? trim($datosLmx['segundoApellido']) : '';
        $correo    = isset($datosLmx['correo']) ? trim($datosLmx['correo']) : (isset($datosLmx['email']) ? trim($datosLmx['email']) : '');
        $rfcFisica = isset($datosLmx['rfc']) ? trim($datosLmx['rfc']) : '';

        // 3. Consultar persona moral si existe
        $resMoral = $this->obtenerPerfilMoral($accessToken);
        $tipoPersona = 1;
        $rfcMoral    = '';
        $razonSocial = '';

        if ($resMoral['success'] && !empty($resMoral['datos']['personasMorales'])) {
            $primeraMoral = $resMoral['datos']['personasMorales'][0];
            $tipoPersona  = 2;
            $rfcMoral     = isset($primeraMoral['rfc']) ? trim($primeraMoral['rfc']) : '';
            $razonSocial  = isset($primeraMoral['razonSocial']) ? trim($primeraMoral['razonSocial']) : '';
        }

        // 4. Sincronizar en BD
        $dao = new ClaseDaoSistema();
        $proyectoExistente = $dao->buscarProyectoPorLlaveMX($curp, $correo);

        if (!empty($proyectoExistente) && isset($proyectoExistente[0]['id_proyecto'])) {
            $idProyecto     = $proyectoExistente[0]['id_proyecto'];
            $visitantemd5   = $proyectoExistente[0]['visitantemd5'];
            $userName       = $proyectoExistente[0]['user_name'];
            $statusProyecto = $proyectoExistente[0]['status'];

            $dao->actualizarDatosDesdeLlaveMX($idProyecto, array(
                'nombre'             => $nombre,
                'primer_apellido'    => $primerAp,
                'segundo_apellido'   => $segundoAp,
                'curp'               => $curp,
                'rfc_persona_fisica' => $rfcFisica,
                'rfc_persona_moral'  => $rfcMoral,
                'razon_social'       => $razonSocial
            ));
        } else {
            $nuevoProyecto = $dao->registrarProyectoDesdeLlaveMX(array(
                'nombre'             => $nombre,
                'primer_apellido'    => $primerAp,
                'segundo_apellido'   => $segundoAp,
                'curp'               => $curp,
                'correo'             => $correo,
                'telefono'           => isset($datosLmx['celular']) ? $datosLmx['celular'] : '',
                'tipo_persona'       => $tipoPersona,
                'rfc_persona_fisica' => $rfcFisica,
                'rfc_persona_moral'  => $rfcMoral,
                'razon_social'       => $razonSocial
            ));

            if (!$nuevoProyecto) {
                return array('success' => false, 'error' => 'No fue posible registrar el proyecto en la base de datos.');
            }

            $idProyecto     = $nuevoProyecto['id_proyecto'];
            $visitantemd5   = $nuevoProyecto['visitantemd5'];
            $userName       = $nuevoProyecto['user_name'];
            $statusProyecto = $nuevoProyecto['status'];
        }

        // 5. Configurar sesión activa en PHP
        $ip = !empty($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '127.0.0.1';
        $sesToken = md5(date("Y-m-d H:i:s") . $ip . $userName);

        $_SESSION['perfil']         = POSTULANTE;
        $_SESSION['usu']            = $userName;
        $_SESSION['tipo']           = $tipoPersona;
        $_SESSION['tipo_persona']   = $tipoPersona;
        $_SESSION['ses']            = $sesToken;
        $_SESSION['visitantemd5']    = $visitantemd5;
        $_SESSION['auth_origen']    = 'LLAVE_MX';
        $_SESSION['curp_verificada'] = $curp;

        $arrLog = array(
            "usuario"      => $userName . " (Llave MX)",
            "session"      => $sesToken,
            "visitantemd5" => $visitantemd5
        );
        $dao->actualiza_visitante($arrLog);
        $dao->insertLogAcceso($arrLog);

        $this->auditarTransaccion('LOGIN_EXITOSO_LLAVEMX', array(
            'id_proyecto'  => $idProyecto,
            'user_name'    => $userName,
            'curp'         => $curp
        ));

        $redirectUrl = "datos.php?tab=1&mensaje=" . urlencode("Bienvenido(a) " . $nombre);
        if ($statusProyecto == CARGA_CONCLUIDA || $statusProyecto == CORREGIDO) {
            $redirectUrl = "conclusion.php?mensaje=" . urlencode("¡Datos validados!");
        }

        return array(
            'success'     => true,
            'redirectUrl' => $redirectUrl,
            'id_proyecto' => $idProyecto,
            'usuario'     => $userName
        );
    }

    /**
     * Bitácora de auditoría detallada
     */
    public function auditarTransaccion($accion, $datos = array()) {
        try {
            $fecha = date('Y-m-d H:i:s');
            $ip = !empty($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '127.0.0.1';

            if (is_array($datos)) {
                if (isset($datos['password'])) $datos['password'] = '***';
                if (isset($datos['clientSecret'])) $datos['clientSecret'] = '***';
                if (isset($datos['authPass'])) $datos['authPass'] = '***';
            }

            $linea = sprintf("[%s] [IP: %s] [ACCION: %s] %s%s", $fecha, $ip, $accion, is_string($datos) ? $datos : json_encode($datos, JSON_UNESCAPED_UNICODE), PHP_EOL);
            @file_put_contents($this->logFile, $linea, FILE_APPEND | LOCK_EX);
        } catch (Exception $e) {}
    }
}
```

---

## Paso 4: Callback y Captura de Código

Crear el archivo `llavemx_callback.php` en la raíz del proyecto:

```php
<?php
/**
 * Callback de autenticación OAuth 2.0 - Llave MX
 * Convocatoria FOCINE - IMCINE
 */
require_once __DIR__ . DIRECTORY_SEPARATOR . "backEnd" . DIRECTORY_SEPARATOR . "variables_globales.php";
require_once __DIR__ . DIRECTORY_SEPARATOR . "backEnd" . DIRECTORY_SEPARATOR . "CONTROL" . DIRECTORY_SEPARATOR . "control_llavemx.php";

session_start();

$ctrlLlaveMX = new ClaseControlLlaveMX();

// Registro inmediato en auditoría
$ctrlLlaveMX->auditarTransaccion('CALLBACK_RECIBIDO', array('GET' => $_GET, 'POST' => $_POST));

// 1. Error retornado por Llave MX
if (!empty($_GET['error'])) {
    $errorDesc = !empty($_GET['error_description']) ? $_GET['error_description'] : $_GET['error'];
    $ctrlLlaveMX->auditarTransaccion('CALLBACK_ERROR_PARAMETRO', array('error' => $errorDesc));
    header("Location: acceso.php?mensaje=" . urlencode("Error de autenticación con Llave MX: " . $errorDesc));
    exit();
}

// 2. Validación de código recibido
$code = !empty($_GET['code']) ? trim($_GET['code']) : '';
$state = !empty($_GET['state']) ? trim($_GET['state']) : '';

if (empty($code)) {
    $ctrlLlaveMX->auditarTransaccion('CALLBACK_SIN_CODIGO', $_GET);
    header("Location: acceso.php?mensaje=" . urlencode("No se recibió el código de autorización de Llave MX."));
    exit();
}

// 3. Procesar autenticación y sincronizar con BD
$resultado = $ctrlLlaveMX->procesarAutenticacion($code, $state);

if ($resultado['success'] === true) {
    $destino = !empty($resultado['redirectUrl']) ? $resultado['redirectUrl'] : "datos.php?tab=1";
    session_write_close(); // Guardar sesión en disco antes de redirigir
    header("Location: " . $destino);
    exit();
} else {
    $msgError = !empty($resultado['error']) ? $resultado['error'] : "Ocurrió un error al procesar el inicio de sesión con Llave MX.";
    session_write_close();
    header("Location: acceso.php?mensaje=" . urlencode($msgError));
    exit();
}
```

---

## Paso 5: Ajuste de Sesión y Protección (`init_session.php`)

En `backEnd/init_session.php`, añadir la intercepción del código antes de `$ctrlSys->verificando_sesion($_SESSION['ses'])`:

```php
// Si viene código de autorización de Llave MX (redirección directa a datos.php), procesar autenticación
if (!empty($_GET['code'])) {
    require_once 'CONTROL/control_llavemx.php';
    $ctrlLmx = new ClaseControlLlaveMX();
    $currentUrl = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? "https" : "http") . "://" . $_SERVER['HTTP_HOST'] . $_SERVER['PHP_SELF'];
    $resLmx = $ctrlLmx->procesarAutenticacion($_GET['code'], !empty($_GET['state']) ? $_GET['state'] : null, $currentUrl);
    if ($resLmx['success'] === false) {
        header("Location: acceso.php?mensaje=" . urlencode($resLmx['error']));
        exit();
    }
}

// Validando la sesion del usuario
$ctrlSys->verificando_sesion($_SESSION['ses']);
```

Y en `backEnd/CONTROL/control_sistema.php`:
```php
function verificando_sesion($ses){
    if (empty($_SESSION['ses'])) {
        header("Location: acceso.php?mensaje=" . urlencode("Sesión terminada."));
        exit(); // Vital para detener la ejecución y evitar errores ADODB SQL
    }
}
```

---

## Paso 6: Vista de Acceso (`acceso.php`) y `header.php`

1. **En `header.php`**, incluir la hoja de estilos centralizada `llaveMX.css`:
```php
<link href="<?php echo DIRECCION_CSS.'llaveMX.css?v='.time(); ?>" rel="stylesheet" type="text/css">
```

2. **En `acceso.php`**, la vista queda completamente limpia de estilos duplicados al cargarlos desde `/api_imcine_v2/css_base/llaveMX.css`:

```php
<?php 
// Si viene código de autorización de Llave MX redirigido a acceso.php
if (!empty($_GET['code'])) {
    require_once "backEnd/variables_globales.php";
    require_once "backEnd/CONTROL/control_llavemx.php";
    $ctrlLlaveMX = new ClaseControlLlaveMX();
    $currentUrl = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? "https" : "http") . "://" . $_SERVER['HTTP_HOST'] . $_SERVER['PHP_SELF'];
    $resLmx = $ctrlLlaveMX->procesarAutenticacion($_GET['code'], !empty($_GET['state']) ? $_GET['state'] : null, $currentUrl);
    if ($resLmx['success'] === true) {
        header("Location: " . $resLmx['redirectUrl']);
        exit();
    } else {
        header("Location: acceso.php?mensaje=" . urlencode($resLmx['error']));
        exit();
    }
}

require_once "header.php"; 
require_once "backEnd/CONTROL/control_llavemx.php";

$ctrlLlaveMX = new ClaseControlLlaveMX();
$urlLoginLlaveMX = $ctrlLlaveMX->getUrlLogin();
$tools = new Herramientas();
$fechas = $ctrlSys->fechasAperturaSistema();
$convocatoriaAbierta = $tools->compararFechasAcceso($fechas[0]["acceso_sistema"], $fechas[0]["acceso_sistema_fin"]);
?>
<?php if (!$convocatoriaAbierta): ?>
<style>
/* Bloqueo completo de la vista de acceso cuando la convocatoria está cerrada */
.lmx-main-container {
    pointer-events: none;
    user-select: none;
    filter: blur(1px);
    opacity: 0.45;
}
</style>
<?php endif; ?>

<div class="container lmx-main-container">
    <div class="row">
        <!-- Izquierda: Instructivo Desktop -->
        <div class="col-md-7 col-lg-7 hidden-xs hidden-sm">
            <div class="lmx-guide-card">
                <div class="lmx-guide-header">
                    <h2 class="lmx-guide-title"><span class="glyphicon glyphicon-book"></span> Guía de Acceso con Llave MX</h2>
                    <p class="lmx-guide-subtitle">Conoce el procedimiento para ingresar y gestionar tu proyecto.</p>
                </div>
                <ol class="lmx-step-list">
                    <li class="lmx-step-item"><span class="lmx-step-number">1</span><div class="lmx-step-content"><span class="lmx-step-title">Inicia la Autenticación</span>Da clic en "Iniciar sesión con Llave MX".</div></li>
                    <li class="lmx-step-item"><span class="lmx-step-number">2</span><div class="lmx-step-content"><span class="lmx-step-title">Ingresa tus Credenciales</span>Escribe tu usuario y contraseña de Llave MX.</div></li>
                    <li class="lmx-step-item"><span class="lmx-step-number">3</span><div class="lmx-step-content"><span class="lmx-step-title">Autoriza la Consulta de Datos</span>Concede el permiso para vincular tus datos verificados.</div></li>
                    <li class="lmx-step-item"><span class="lmx-step-number">4</span><div class="lmx-step-content"><span class="lmx-step-title">Acceso y Postulación</span>Entrarás directamente al formulario de tu proyecto.</div></li>
                </ol>
            </div>
        </div>

        <!-- Derecha: Login -->
        <div class="col-xs-12 col-sm-12 col-md-5 col-lg-5">
            <div class="lmx-login-card">
                <img src="imagenes/Logo_LlaveMX.png" alt="Llave MX" class="lmx-login-logo">
                <h3 class="lmx-login-heading">Ingreso al Sistema</h3>
                <p class="lmx-login-desc">Accede con tu cuenta oficial para postular tu proyecto en <strong>FOCINE</strong>.</p>
                <div style="margin: 25px 0 15px 0;">
                    <a href="<?php echo $urlLoginLlaveMX; ?>" class="btn lmx-btn-login">
                        <span class="glyphicon glyphicon-lock" style="margin-right: 8px;"></span> Iniciar sesión con Llave MX
                    </a>
                </div>
            </div>
        </div>
    </div>
</div>

<?php if (!$convocatoriaAbierta): ?>
<!-- OVERLAY Y MODAL PERSISTENTE: Convocatoria Cerrada -->
<div class="lmx-modal-backdrop-cerrada"></div>
<div class="modal in" id="modalConvocatoriaCerrada" tabindex="-1" role="dialog" data-backdrop="static" data-keyboard="false" style="display: block !important; z-index: 99999; overflow-y: auto;">
    <div class="modal-dialog modal-md" role="document" style="margin: 50px auto;">
        <div class="modal-content" style="border: 3px solid #691C32; border-radius: 8px; box-shadow: 0 15px 40px rgba(0,0,0,0.45);">
            <div class="modal-header modal-closed-header">
                <h4 class="modal-title"><span class="glyphicon glyphicon-lock" style="margin-right: 8px;"></span> Convocatoria Cerrada</h4>
            </div>
            <div class="modal-body modal-closed-body">
                <div class="modal-closed-icon"><span class="glyphicon glyphicon-time"></span></div>
                <h3 style="color: #691C32; font-weight: 700; margin-top: 5px;">Sistema Cerrado</h3>
                <p style="font-size: 15px; font-weight: 600; color: #691C32;">El sistema se encuentra cerrado hasta su próxima edición.</p>
                <p>El periodo oficial de recepción y registro de proyectos para <strong><?php echo NOMBRE_CONVOCATORIA; ?></strong> ha concluido.</p>
                <div class="modal-closed-dates">
                    <strong style="color: #691C32;"><span class="glyphicon glyphicon-calendar"></span> Periodo oficial:</strong><br>
                    <span><strong>Apertura:</strong> <?php echo $fechas[0]['fecha_acceso_inicio'].' a las '.$fechas[0]['hora_acceso_inicio'].':'.$fechas[0]['minuto_acceso_inicio']; ?> hrs.</span><br>
                    <span><strong>Cierre:</strong> <?php echo $fechas[0]['fecha_acceso_fin'].' a las '.$fechas[0]['hora_acceso_fin'].':'.$fechas[0]['minuto_acceso_fin']; ?> hrs.</span>
                </div>
                <div class="modal-closed-buttons-container">
                    <a href="index.php" class="btn btn-closed-primary"><span class="glyphicon glyphicon-home"></span> Ir a la página principal</a>
                    <a href="contacto.php" class="btn btn-closed-secondary"><span class="glyphicon glyphicon-envelope"></span> Contacto y Asistencia</a>
                </div>
            </div>
        </div>
    </div>
</div>

<script>
(function() {
    function abrirModal() {
        if (window.jQuery && window.jQuery.fn && window.jQuery.fn.modal) {
            window.jQuery('#modalConvocatoriaCerrada').modal({
                backdrop: 'static',
                keyboard: false,
                show: true
            });
        }
    }
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', abrirModal);
    } else {
        abrirModal();
    }
    window.addEventListener('load', abrirModal);
})();
</script>
<?php endif; ?>

<?php include ("footer.php"); ?>
```

---

## Paso 7: Control de Acceso (`acceso_permitido`), Estatus y Vista `acceso_denegado.php`

Para permitir que los administradores restrinjan el acceso individual por usuario desde `editar_datos.php` (`acceso_permitido = 0` / No o `estatus_proyecto = 98` Cancelado):

1. **Crear `acceso_denegado.php`** en la raíz de la convocatoria (utiliza los estilos oficiales de `llaveMX.css` cargados por `header.php`):

```php
<?php
/**
 * Vista de Acceso Denegado / Restringido
 * Sistema de Convocatorias IMCINE / FOCINE
 * Estilo Oficial Gobierno de México (SND / Sisdai)
 */
require_once "backEnd/variables_globales.php";
require_once "backEnd/DAO/dao_sistema.php";

if (session_status() === PHP_SESSION_NONE) {
    session_start();
}

// Si había una sesión activa, cerrarla en la BD y destruirla
if (!empty($_SESSION['ses'])) {
    try {
        $dao = new ClaseDaoSistema();
        $dao->cerrandoSesion($_SESSION['ses']);
    } catch (Exception $e) {}
}

$_SESSION = array();
if (ini_get("session.use_cookies")) {
    $params = session_get_cookie_params();
    setcookie(session_name(), '', time() - 42000,
        $params["path"], $params["domain"],
        $params["secure"], $params["httponly"]
    );
}
@session_destroy();

$motivo = isset($_GET['motivo']) ? trim($_GET['motivo']) : 'inhabilitado';
$mensajeCustom = isset($_GET['mensaje']) ? trim($_GET['mensaje']) : '';

// Definición de títulos y textos descriptivos dinámicos según el motivo
$titulo = "Acceso No Disponible";
$icono = "glyphicon-ban-circle";
$descripcion = "El acceso a este proyecto ha sido inhabilitado temporalmente por la administración de la convocatoria o se encuentra en proceso de dictamen.";
$tipoAlerta = "warning";

if ($motivo === 'convocatoria_cerrada' || $motivo === 'fuera_tiempo') {
    $titulo = "Convocatoria Concluida";
    $icono = "glyphicon-time";
    $descripcion = "El periodo de registro y postulación de proyectos para esta convocatoria ha concluido.";
    $tipoAlerta = "danger";
} elseif ($motivo === 'cancelado') {
    $titulo = "Proyecto No Disponible";
    $icono = "glyphicon-remove-circle";
    $descripcion = "El proyecto se encuentra en estatus cancelado o no disponible para edición.";
    $tipoAlerta = "danger";
} elseif (!empty($mensajeCustom)) {
    $descripcion = htmlspecialchars($mensajeCustom, ENT_QUOTES, 'UTF-8');
}

require_once "header.php";
?>

<div class="container denied-container">
    <div class="row">
        <div class="col-xs-12">
            <div class="denied-card">
                <div class="denied-icon-wrapper <?php echo ($tipoAlerta === 'warning') ? 'warning-icon' : ''; ?>">
                    <span class="glyphicon <?php echo $icono; ?>"></span>
                </div>

                <h1 class="denied-title"><?php echo $titulo; ?></h1>

                <div class="denied-message-box">
                    <span class="glyphicon glyphicon-info-sign" style="color: #BC955C; margin-right: 6px;"></span>
                    <?php echo $descripcion; ?>
                </div>

                <p class="denied-help-text">
                    Si consideras que se trata de un error o requieres asistencia, comunícate en la sección de <a href="contacto.php" style="color: #691C32; font-weight: 600; text-decoration: underline;">Contacto</a>.
                </p>

                <div class="denied-timer-box">
                    <span class="glyphicon glyphicon-time" style="margin-right: 6px;"></span>
                    Serás redirigido a la página de acceso automáticamente en: <span id="segundosRestantes" class="denied-timer-number">10</span> segundos
                </div>

                <div>
                    <a href="acceso.php" id="btnRedirigirManual" class="btn btn-denied-return">
                        <span class="glyphicon glyphicon-arrow-left" style="margin-right: 8px;"></span>
                        Volver al inicio de sesión ahora
                    </a>
                </div>
            </div>
        </div>
    </div>
</div>

<script>
(function() {
    var segundos = 10;
    var contadorElem = document.getElementById('segundosRestantes');
    var intervalo = setInterval(function() {
        segundos--;
        if (contadorElem) { contadorElem.textContent = segundos; }
        if (segundos <= 0) {
            clearInterval(intervalo);
            window.location.href = 'acceso.php';
        }
    }, 1000);
})();
</script>

<?php include ("footer.php"); ?>
```

2. **Validación en `control_llavemx.php`**:
   - Al encontrar el proyecto por CURP/RFC/correo:
     ```php
     if ($accesoPermitido === 0 || $statusProyecto === 98) {
         $motivoBloqueo = ($statusProyecto === 98) ? 'cancelado' : 'inhabilitado';
         return array(
             'success'         => true,
             'redirectUrl'     => 'acceso_denegado.php?motivo=' . $motivoBloqueo,
             'acceso_denegado' => true
         );
     }
     ```

3. **Blindaje en `backEnd/init_session.php`**:
   - En cada petición de navegación del postulante:
     ```php
     if (@$_SESSION['perfil'] == POSTULANTE) {
         if ($acceso_permitido === "0" || $acceso_permitido === 0 || $estatus_proyecto == 98 || $estatus_proyecto === "98") {
             $motivoBloqueo = ($estatus_proyecto == 98) ? 'cancelado' : 'inhabilitado';
             $daoSes = new ClaseDaoSistema();
             if (!empty($_SESSION['ses'])) {
                 $daoSes->cerrandoSesion($_SESSION['ses']);
             }
             $_SESSION = array();
             @session_destroy();
             header("Location: acceso_denegado.php?motivo=" . $motivoBloqueo);
             exit();
         }
     }
     ```

---

## Paso 8: Administración de Vigencia en `secciones.php`

Con la integración de Llave MX, el registro y login se unifican. En `secciones.php`:
1. Se elimina la sección *"Vigencia para crear una cuenta"*.
2. Se mantiene únicamente *"Vigencia de acceso al sistema / Convocatoria"*, permitiendo al administrador definir fecha y hora exacta de apertura y cierre.
3. Cuando la fecha actual esté fuera de vigencia, `acceso.php` despliega un modal bloqueante persistente no cerrable (`backdrop: static, keyboard: false`) y desactiva la interacción de fondo.

---

## 11. Checklist Rápido de Replicación en Nueva Convocatoria

### 🚀 Script de Copia Masiva Automatizada (13 Convocatorias):

Puedes ejecutar este script en tu servidor para transferir los archivos base a todas las convocatorias de FOCINE:

```bash
ORIGEN="/home/convocatoriasdev/public_html/focine/ficcion_oscar"

CONVOCATORIAS=(
  "acervos"
  "animacion"
  "cortometraje"
  "documental"
  "estados"
  "exhibicion_equipamientos"
  "exhibicion_programas"
  "experimental"
  "ficcion"
  "infancias"
  "infancias_2027"
  "operasprimas"
  "postproduccion"
)

for CONV in "${CONVOCATORIAS[@]}"; do
  DESTINO="/home/convocatoriasdev/public_html/focine/$CONV"
  
  if [ -d "$DESTINO" ]; then
    echo "▶ Copiando archivos a: $DESTINO"
    mkdir -p "$DESTINO/logs" "$DESTINO/imagenes" "$DESTINO/backEnd/CONTROL"
    
    cp "$ORIGEN/imagenes/Logo_LlaveMX.png" "$DESTINO/imagenes/"
    cp "$ORIGEN/backEnd/CONTROL/control_llavemx.php" "$DESTINO/backEnd/CONTROL/"
    cp "$ORIGEN/llavemx_callback.php" "$DESTINO/"
    cp "$ORIGEN/acceso_denegado.php" "$DESTINO/"
    
    touch "$DESTINO/logs/llavemx_audit.log"
    chmod 777 "$DESTINO/logs/llavemx_audit.log"
    echo "  ✔ Completado para $CONV"
  else
    echo "  ⚠ Directorio no encontrado: $DESTINO (omitido)"
  fi
done

echo "=============================================="
echo "✔ Copia masiva finalizada con éxito."
echo "=============================================="
```

---

### Pasos manuales posteriores por cada convocatoria:

- [ ] **1. Copiar archivos de soporte** (o ejecutar el script masivo anterior).
- [ ] **2. Configurar `variables_globales.php`**:
  - Pegar las constantes de Llave MX con el `CLIENT_ID`, `CLIENT_SECRET`, `AUTH_USER` y `AUTH_PASS` correspondientes.
  - Actualizar `LLAVEMX_REDIRECT_URI` con el nombre de la carpeta (`acervos`, `animacion`, `documental`, etc.).
- [ ] **3. Añadir métodos DAO en `dao_sistema.php`**:
  - Pegar `buscarProyectoPorLlaveMX`, `registrarProyectoDesdeLlaveMX` y `actualizarDatosDesdeLlaveMX`.
- [ ] **4. Blindar sesiones en `init_session.php` y `control_sistema.php`**:
  - Añadir la intercepción de `$_GET['code']` y verificación de `acceso_permitido` en `init_session.php`.
  - Colocar `exit();` en `verificando_sesion()`.
- [ ] **5. Reemplazar `acceso.php` y ajustar `secciones.php`**:
  - Pegar la vista oficial `acceso.php` con layout de 2 columnas, modal móvil y modal bloqueante de convocatoria cerrada.
  - Dejar únicamente vigencia de acceso a la convocatoria en `secciones.php`.
- [ ] **6. Probar en Sandbox con usuario de prueba**:
  - Probar login exitoso y llegada a `datos.php`.
  - Cambiar `acceso_permitido` a 0 en `editar_datos.php` y verificar redirección a `acceso_denegado.php` con contador de 10s.

---

## 12. Solución de Problemas Comunes (Troubleshooting)

1. **Error HTTP 401 en `obtenerToken`**:
   - Asegúrate de que `LLAVEMX_AUTH_PASS` esté delimitada por comillas simples (`' '`) en `variables_globales.php`. Si contiene el carácter `$`, las comillas dobles (`" "`) hacen que PHP lo evalúe como variable vacía.
2. **Error 404 al redirigir al Login de Llave MX**:
   - En Sandbox, la URL de login termina en `/oauth.xhtml` (ejemplo: `https://val-llave.infotec.mx/oauth.xhtml`), no en `/oauth/authorize`.
3. **El usuario es devuelto a `acceso.php?mensaje=Sesión terminada`**:
   - Ocurre si no se llama a `session_write_close()` antes del `header("Location: datos.php")` o si `init_session.php` no intercepta `$_GET['code']`.
4. **Permisos en la bitácora de auditoría**:
   - La carpeta `logs/` debe tener permisos de escritura para que el servidor web (`nobody` / `www-data` / `cpanel`) pueda escribir en `logs/llavemx_audit.log`.
5. **Redirección a `acceso_denegado.php` inesperada**:
   - Verificar en la tabla `proyectos` que el registro del usuario tenga `acceso_permitido = 1` y `estatus_proyecto != 98`.
