# 09 — Autenticación y Autorización

---

## 1. Resumen del modelo

Kuorum tiene **dos identidades de autenticación separadas** que comparten el mismo almacén de sesión, el mismo motor de permisos y, en algunos casos, el mismo controlador.

| | Backoffice | Portal del colaborador |
|---|---|---|
| Tabla de credenciales | `usuarios` | `colaboradores` |
| Formulario | `?c=login&a=admin` | `?c=public&a=index` |
| Acción de validación | `LoginController::validateAction()` | `PublicController::validateAction()` |
| Modelo | `UsuariosModel::validateUser($user, $pass)` | `ColaboradoresModel::validateUser($user, $pass, $periodo)` |
| Campos del formulario | `user`, `pass`, `_csrf_token` | `user`, `pass`, `periodo`, `_csrf_token` |
| Rol | `usuarios.Tipo` → `roles.Id` | **Forzado a 5 (Colaborador)** |
| Marca de sesión | *(ninguna; se elimina `ColaboradorPublic`)* | `$_SESSION[APP_ID]['ColaboradorPublic'] = true` |
| Layout | `metronic` | `metronic_public` |
| Menú | `Menu::$principal` | `Menu::$public` |
| Destino tras el login | `?c=colaboradores&a=list` | `?c=PerfilColaborador&a=panel` |
| Barrera adicional | — | Lista blanca `Controller::$PUBLIC_COLLABORATOR_ROUTES` |

Existe además una tercera vía: **inicio de sesión institucional con Microsoft Entra ID** (`?c=public&a=validated`), y un **modo demo** que crea una sesión suplantada.

---

## 2. Flujo de login del backoffice

```mermaid
sequenceDiagram
    participant U as Usuario
    participant LC as LoginController
    participant RL as RateLimiter
    participant UM as UsuariosModel
    participant PDO as MysqlPDO
    participant S as $_SESSION

    U->>LC: GET ?c=login&a=admin
    LC-->>U: login/admin.php (layout metronic_empty)
    U->>LC: POST ?c=login&a=validate {user, pass, _csrf_token}
    LC->>LC: ¿es POST y validateCsrfToken()?
    alt no
        LC-->>U: Flash "Solicitud de inicio de sesión inválida" + redirect
    end
    LC->>LC: ¿user y pass no vacíos?
    LC->>RL: check('login_admin:'+sha256(lower(user)), 10, 900)
    alt bloqueado
        LC-->>U: Flash "Demasiados intentos…" + redirect
    end
    LC->>UM: validateUser(user, pass)
    UM->>PDO: SELECT * FROM usuarios WHERE Usuario = '<user>'
    alt no existe
        UM-->>LC: status = 'no_user'
    else Estado == 0
        UM-->>LC: status = 'user_inactive'
    else
        UM->>PDO: validateUser() → PassHelper::verify(pass, hash)
        alt md5(pass) == hash
            UM->>S: User (sin Contrasena), UserType desde roles
            UM->>S: filtersSesionValuesDefault()
            UM->>S: loadPermissions() → Permissions
            UM->>S: loadMenu(Menu::$principal) → Menu filtrado
            UM->>S: loadShortcuts()
            UM->>UM: LogAccionesModel::saveAccess('Inicio Sesión')
            UM-->>LC: logged = true
        else Config::$directorioActivo
            UM->>UM: DirectorioActivoModel::autentication()
        else
            UM-->>LC: status = 'pass_wrong'
        end
    end
    LC->>RL: reset(clave)
    LC->>LC: regenerateSessionOnAuthentication()
    LC->>S: unset ColaboradorPublic; MenuOpen = true; ShowPopup = true
    LC-->>U: redirect ?c=colaboradores&a=list
```

**Mensaje de error único.** Ante `no_user`, `user_inactive` o `pass_wrong`, el controlador responde siempre:
`"No fue posible iniciar sesión con esas credenciales."` — no revela si la cuenta existe.

**🔴 `Config::$directorioActivo`** (variable `DIRECTORIO_ACTIVO`) activa una rama que llama a `DirectorioActivoModel::autentication()`. **Esa clase no existe en el repositorio.** Con `DIRECTORIO_ACTIVO=true` y una contraseña incorrecta, la aplicación produciría un fatal de clase no encontrada.

---

## 3. Flujo de login del portal

Idéntico en estructura, con tres diferencias:

1. **Exige un tercer campo, `periodo`** (entero > 0). El formulario ofrece los periodos visibles y el elegido queda en `$_SESSION[APP_ID]['filtersSesion']['Periodo']`.
2. La clave de rate limit es `'login_colaborador:' . sha256(lower(user))`, con el mismo límite 10/900 s.
3. **El rol se fuerza a 5.** La migración `2026_02_22_000021_drop_tipo_from_colaboradores` eliminó la columna `Tipo` de `colaboradores`, así que `ColaboradoresModel::validateUser()` hace:

```php
// FIX #3B: La tabla colaboradores NO tiene columna 'Tipo'.
// Usar el rol Colaborador (Id=5) directamente.
$usuario['Type'] = 5;
$tipo_usuario = RolesModel::getById(5);
```

### 🔴 Permisos concedidos por código, no por base de datos

Tras autenticar, `ColaboradoresModel::validateUser()` llama a `UsuariosModel::loadPermissions()` (que carga las filas de `permisos` del rol 5) y a continuación **sobrescribe o inyecta permisos directamente en la sesión** para ~12 módulos:

```php
$_SESSION[APP_ID]['Permissions']['Colaboradores']['list']  = 1;   // asignación incondicional
$_SESSION[APP_ID]['Permissions']['Colaboradores']['view']  = 1;
$_SESSION[APP_ID]['Permissions']['Colaboradores']['panel'] = 1;
$_SESSION[APP_ID]['Permissions']['Colaboradores']['edit']  = 1;
$_SESSION[APP_ID]['Permissions']['Colaboradores']['asistencia'] = 1;
$_SESSION[APP_ID]['Permissions']['Colaboradores']['planesDesarrollo'] = 1;
// … y bloques if(!isset(…)) para:
// Formaciones, MetasColaborador, EvaluacionesColaborador, VacacionesColaborador,
// Desempeno, AdelantosNominaPortal, PortalPlanesDesarrollo, PlanesDesarrolloEquipo,
// BeneficiosPortal, …
```

**Consecuencias que hay que tener presentes:**
- Las filas de `permisos` para el rol 5 **no controlan realmente** lo que el colaborador puede hacer en esos módulos. Quitar un permiso en la BD no tiene efecto sobre los asignados incondicionalmente.
- `Colaboradores` es el mismo `$MODULE_NAME` que usa `ColaboradoresController` (el CRUD administrativo). Conceder `list`, `view` y `edit` sobre `Colaboradores` a un colaborador **abriría el CRUD administrativo completo** si no existiera la segunda barrera.
- Esa segunda barrera es la lista blanca de rutas (§5).

---

## 4. Verificación de contraseña

```php
// core/helpers/PassHelper.php
class PassHelper
{
    public static function encode($pass)        { return md5($pass); }
    public static function verify($pass, $hash) { return md5($pass) == $hash; }
}
```

```php
// core/db/MysqlPDO.php
public function validateUser($user, $pass, $table = null)
{
    $usuario = $this->queryInt($table, array('Id','Contrasena'),
                               array('WHERE'=>array(array('name'=>'Usuario','value'=>$user))));
    if (!isset($usuario['Contrasena'])) { return false; }
    return PassHelper::verify($pass, $usuario['Contrasena']);
}
```

### 🔴 Problemas

| # | Problema | Detalle |
|---|---|---|
| 1 | **MD5 sin sal** | Algoritmo roto para contraseñas: rápido de calcular, tablas rainbow disponibles, sin factor de coste |
| 2 | **Comparación con `==`** | No es de tiempo constante (aunque el impacto real es menor que el punto 1) |
| 3 | **Columnas sobredimensionadas** | `usuarios.Contrasena` y `colaboradores.Contrasena` son `varchar(500)` — la migración a `password_hash()` no requeriría cambio de esquema |
| 4 | **Incoherencia interna** | `LineaEticaCasosModel` **sí** usa `password_hash(PASSWORD_DEFAULT)` y `password_verify()` para el PIN de seguimiento. El mecanismo correcto ya está en el proyecto, pero no se aplica al login |

Ver [18_KNOWN_ISSUES.md](18_KNOWN_ISSUES.md) § HR-AUTH-1.

### Política de contraseñas

`core/PasswordPolicy::validate($password)` exige:
- Mínimo **8** caracteres
- Al menos una **minúscula**
- Al menos una **mayúscula**
- Al menos un **dígito**

Devuelve `['isValid' => bool, 'errors' => []]`.

**Solo se aplica al establecer la contraseña**, desde `core/attributes/Password::validateData()`. El atributo además:
- Tiene `CanEditOnUpdate = false` → una contraseña **no se modifica** en un `save()` normal; hay que pedirla explícitamente: `$model->save(array('Contrasena'))`. Es lo que hace `UsuariosController::editPassAction()`.
- Exige confirmación: compara `$_POST[Modelo][Contrasena]` con `$_POST[Modelo][Contrasena1]`.
- Aplica `PassHelper::encode()` (MD5) solo si toda la validación pasa.
- `MinLength = 6`, `MaxLength = 50` — **el `MinLength=6` del atributo es inconsistente con el mínimo de 8 de la política**; en la práctica manda la política, que es más estricta.

### ⚠️ `LOGIN_MANUAL`

La variable `LOGIN_MANUAL=true` (`.env.example`) *"permite entrar usando el número de documento como contraseña"*. Está en `false` por defecto y el propio ejemplo advierte: *"Déjalo en false salvo que el negocio lo exija explícitamente."*

---

## 5. Autorización

### 5.1 El algoritmo completo

`Controller::validateAccess($action)` — método `final`, en `core/Controller.php`:

```mermaid
flowchart TD
    A["validateAccess(accion)"] --> B{"¿sesión de portal\nY no es modo demo\nY la ruta NO está en\n\$PUBLIC_COLLABORATOR_ROUTES?"}
    B -- sí --> R1["NO_PERMISSIONS"]
    B -- no --> C{"¿modo demo Y el módulo\nno está permitido en demo?"}
    C -- sí --> R1
    C -- no --> D{"¿modo demo, módulo permitido\ny acción NO sensible?"}
    D -- sí --> D1{"¿existe el método\naccionAction()?"}
    D1 -- sí --> OK["ACCESS"]
    D1 -- no --> R2["NO_ACTION"]
    D -- no --> E{"¿AccessControl[accion]\nestá definido?"}
    E -- no --> R3["ERROR_ACCESS"]
    E -- sí --> F{"valor"}
    F -- "'*'" --> OK
    F -- "'@'" --> G{"validateSession()?"}
    G -- no --> R4["NO_LOG_IN"]
    G -- sí --> H["loadPermission()"]
    H --> I{"¿Permission[accion] > 0?"}
    I -- sí --> J{"¿existe accionAction()?"}
    J -- sí --> OK
    J -- no --> R2
    I -- no --> K{"¿Módulo en \$classesGeneral\nO acción en \$actionsGeneral\nO usuario == getUserAccess()?"}
    K -- sí --> J
    K -- no --> R5["NO_PERMISSIONS"]
```

**Todos los resultados distintos de `ACCESS` producen la misma consecuencia:** `ROUTER::redirect_to_action(DIR_INDEX)` + `exit()`. El usuario no distingue "no autenticado" de "sin permiso" de "acción inexistente".

### 5.2 Los niveles de `AccessControl`

```php
protected function loadAccessControl()
{
    $this->AccessControl = array(
        'list'   => '@',   // sesión + permiso
        'export' => '*',   // abierto a todos
        // 'debug' no declarado  → denegado para todos
    );
}
```

Por defecto, `Controller::loadAccessControl()` declara `create`, `view`, `edit`, `remove`, `list`, `dataListAjax` como `'@'`, más `changeFiltersSesion` si `canChangeFiltersSesion()`.

### 5.3 Las puertas traseras legítimas

Tres mecanismos conceden acceso **sin** consultar la tabla `permisos`:

| Mecanismo | Definición | Contenido actual |
|---|---|---|
| `Config::$classesGeneral` | Módulos sin requisito de permiso | `Public`, `Home`, `Ajax`, `Log`, `Login`, `Perfil`, `CarritoFacturacion`, `CarritoDevolucion`, `CarritoCotizacion` |
| `Config::$actionsGeneral` | Acciones sin requisito de permiso, en cualquier módulo | `dataListAjax`, `testDB` |
| `Config::getUserAccess()` | Superusuario | Devuelve **`1`**: el usuario con `usuarios.Id = 1` tiene acceso total, siempre |

> 🔴 **`'dataListAjax'` está en `$actionsGeneral`.** Cualquier usuario autenticado puede invocar el endpoint de datos de **cualquier** módulo, aunque no tenga permiso `list` sobre él. Combinado con el parámetro `criteriaExt` (ver [08_API.md](08_API.md) §6), es un vector de exfiltración de datos.
>
> 🔴 Los tres últimos valores de `$classesGeneral` (`CarritoFacturacion`, `CarritoDevolucion`, `CarritoCotizacion`) son residuos de un producto de facturación. No existen como controladores, pero permanecer en la lista es innecesario.

### 5.4 El modelo de permisos

```
roles(Id, Nombre, Codigo, Inicio, Grupo, Descripcion, Estado)
   └─< permisos(Id, IdRol, ModuleName, Permission, Tabs, Other)
```

`Permission` es JSON en una columna `text`:

```json
{"create":1,"view":1,"edit":1,"remove":1,"list":1,"export":1}
```

`PermisosModel::getPermissions($moduleName)` devuelve el array normalizado (rellenando con `0` las claves ausentes). Si el usuario es el `getUserAccess()` (Id 1) y no hay fila, devuelve todo a `1`.

#### Los tres valores de un permiso

| Valor | Interpretado por `Controller::validateAccess()` | Interpretado por `ListaAjax` |
|---|---|---|
| `0` | Denegado | Sin acceso |
| `1` | Permitido | Acceso a todos los registros |
| `2` | **Permitido** (solo comprueba `> 0`) | **Solo los registros propios**: añade `WHERE UsuarioRegistro = {usuario}` |

> El valor `2` (registro propio) **solo lo implementa `ListaAjax`**. Una acción `view`/`edit` invocada directamente por URL con un `Id` ajeno **no** aplica esa restricción salvo que el controlador la compruebe a mano.

#### Pestañas

`permisos.Tabs` es otro JSON. `Controller::getCheckTabs()` filtra `$this->Tabs` (definido en `getTabs()`) contra él, permitiendo también todo al usuario 1.

#### Carga de permisos

Se cargan **una sola vez, en el login**:

```php
UsuariosModel::loadPermissions();
// SELECT * FROM permisos WHERE IdRol = <rol del usuario>
// $_SESSION[APP_ID]['Permissions'][ModuleName] = json_decode(Permission)
// $_SESSION[APP_ID]['Permissions'][ModuleName]['Tabs'] = json_decode(Tabs)
```

**Cambiar los permisos de un rol en la base de datos no afecta a las sesiones ya abiertas.** El usuario debe volver a entrar.

### 5.5 El menú se filtra por permisos

```php
UsuariosModel::loadMenu(Menu::$principal);
// → validarMenuItems(): conserva el ítem solo si
//   ROUTER::create_action_url($item['Controller'], $item['Action']) != '#'
// → ROUTER::create_action_url() devuelve '#' si PermisosModel::hasAccess() es falso
// → PermisosModel::hasAccess() instancia el controlador y llama a validateAccess()
```

Un grupo con `SubMenus` se conserva solo si al menos un hijo sobrevive.

> Este mecanismo hace que **instanciar un controlador sea una operación con efectos**: `hasAccess()` crea un objeto controlador por cada ítem de menú evaluado, y cada constructor ejecuta `loadPermission()`, `loadSystemUser()`, `loadAccessControl()` y `getTabs()`.

---

## 6. La lista blanca del portal del colaborador

Definida como `private static $PUBLIC_COLLABORATOR_ROUTES` en `core/Controller.php`. Su motivo está documentado en el propio código:

> *El portal utiliza el mismo almacén de permisos que el backoffice y, en instalaciones antiguas, el rol de colaborador puede conservar permisos administrativos. Esta lista es una segunda barrera: para una sesión con `ColaboradorPublic` solo se admiten las rutas necesarias para su portal, aunque la tabla de permisos contenga valores más amplios.*

**Se comprueba por nombre de clase, no por `$MODULE_NAME`**, precisamente porque `PerfilColaboradorController` comparte `MODULE_NAME='Colaboradores'` con el CRUD administrativo.

### Contenido completo

| Clase | Acciones permitidas |
|---|---|
| `PublicController` | `index`, `home`, `gestiones`, `manual`, `vacantesPublicas`, `postularVacante` |
| `LoginController` | `logout` |
| `PerfilColaboradorController` | `panel`, `asistencia`, `edit`, `planesDesarrollo` |
| `MetasColaboradorController` | `list` |
| `EvaluacionesColaboradorController` | `list`, `encuesta` |
| `PortalPlanesDesarrolloController` | `list`, `view` |
| `PlanesDesarrolloEquipoController` | `list`, `view`, `equipoTalento` |
| `CarreraPortalController` | `miPlanCarrera`, `miEstadoTalento` |
| `MiEquipoController` | `index`, `colaborador` |
| `VacacionesColaboradorController` | `list` |
| `BeneficiosPortalController` | `disponibles`, `verBeneficio`, `misAsignaciones`, `misSolicitudes`, `crearSolicitud`, `guardarSolicitud`, `verSolicitud`, `cancelarSolicitud` |
| `AdelantosNominaPortalController` | `misRecibos`, `certificadoLaboral`, `misSolicitudes`, `crear`, `guardar`, `verSolicitud`, `cancelar` |
| `TicketsController` | `index`, `crear`, `guardar`, `ver`, `responder`, `cerrar`, `calificar`, `exportar` |
| `CentroAyudaController` | `index`, `ver` |
| `CapacitacionPortalController` | `misCursos`, `verCurso`, `iniciar`, `completarLeccion`, `verProgreso`, `presentarQuiz`, `enviarQuiz`, `descargarCertificado`, `misCertificados` |
| `DesempenoController` | `home` |
| `TiempoBeneficiosController` | `home` |
| `ComunicacionInternaController` | `portalNoticias` |
| `LineaEticaPublicaController` | `crear`, `guardar`, `seguimiento`, `verCaso`, `agregarMensaje` |

### ⚠️ Ausencias notables

- **`AusenciasController`** usa layout `metronicPublic` y aparece en el menú del portal, pero **no está en la lista blanca**. Un colaborador autenticado en el portal recibirá `NO_PERMISSIONS` al intentar acceder. Verificar si es intencional.
- La lista **se ignora en modo demo** (`!static::$isDemoMode`).

### 🔴 Lo que la lista blanca NO hace

Autoriza **la ruta**, no **el registro**. Que `TicketsController::ver` esté permitido no significa que el colaborador solo pueda ver sus propios tickets. Eso depende de que la acción aplique el filtro por colaborador —lo hace `ServicioTicketsModel::listarConFiltros($filtros, $soloColaboradorId)`, pero **cada acción debe recordarlo**. La comprobación de propiedad (IDOR) no está centralizada.

---

## 7. CSRF

### API

```php
Controller::generateCsrfToken();               // crea si no existe; devuelve el token de sesión
Controller::validateCsrfToken($token);         // valida con hash_equals Y ROTA el token
Controller::validateCsrfTokenNoRotate($token); // valida sin rotar (endpoints AJAX repetibles)
```

El token vive en `$_SESSION[APP_ID]['csrf_token']`, es de 32 bytes (`bin2hex(random_bytes(32))` → 64 caracteres hex) y se rota al autenticar (`regenerateSessionOnAuthentication()`).

### Dónde se aplica realmente

| Punto | Obligatorio | Rota |
|---|---|---|
| `Controller::process()` para `action === 'remove'` | ✅ Sí, para **todos** los controladores | ❌ No (`validateCsrfTokenNoRotate`) |
| `Model::save()` cuando `REQUEST_METHOD === POST` y no es CLI | ✅ Sí | ✅ Sí |
| `LoginController::validateAction()` | ✅ Manual | ✅ Sí |
| `PublicController::validateAction()` | ✅ Manual | ✅ Sí |
| Acciones de cambio de estado (`aprobar`, `rechazar`, `cancelar`, …) | ⚠️ **Manual, por convención** | Depende |
| `AsistenteIAController::ask` / `clearHistory` | ✅ Manual | ❌ No |
| `AjaxController` (operaciones de firma) | ✅ Manual | ❌ No |
| `SincronizacionController` (importaciones) | ✅ Manual | Depende |
| Otras acciones POST | ❌ **No hay imposición** | — |

### La variable `CSRF_STRICT`

`Config::$CSRF_STRICT` se rellena desde `CSRF_STRICT` del `.env` y el ejemplo la recomienda en `true`.

> 🔴 **`grep -rn "CSRF_STRICT"` sobre `app/`, `core/` y `services/` la encuentra únicamente en `app/config/Config.php`** (declaración y `syncConfigFromEnv`). **Ninguna comprobación de CSRF la consulta.** La variable no tiene efecto. Ver [18_KNOWN_ISSUES.md](18_KNOWN_ISSUES.md).

### Defensa en el navegador

La cookie de sesión se emite con `SameSite=Strict`, lo que bloquea el envío desde peticiones cross-site. Es una defensa real y compensa parcialmente la falta de CSRF centralizado en las mutaciones — pero no protege contra un ataque desde el mismo origen (p. ej. XSS almacenado).

---

## 8. Inicio de sesión institucional (Microsoft Entra ID)

`PublicController::validatedAction()`, acción `'*'`. Flujo OAuth 2.0 Authorization Code.

```mermaid
sequenceDiagram
    participant U as Navegador
    participant P as PublicController::validated
    participant M as login.microsoftonline.com

    U->>P: GET ?c=public&a=validated  (sin ?code)
    P->>P: ¿tenant_id, client_id, client_secret, redirect_uri configurados?
    Note over P: Si falta alguno → flash + redirect a login
    P->>P: state = bin2hex(random_bytes(32))
    P->>P: guarda state en SESSION y en cookie temporal Lax (600 s)
    P-->>U: 302 a /{tenant}/oauth2/v2.0/authorize<br/>scope=openid profile User.Read<br/>response_mode=query&state=…
    U->>M: autenticación en Microsoft
    M-->>U: 302 a redirect_uri?code=…&state=…
    U->>P: GET ?c=public&a=validated&code=…&state=…
    P->>P: recupera state de SESSION y de cookie; limpia ambos
    P->>P: valida coherencia y hash_equals(esperado, recibido)
    Note over P: Si falla → flash "La respuesta … no es válida" + redirect
    P->>M: POST /{tenant}/oauth2/v2.0/token (curl)
    M-->>P: access_token
    P->>M: GET /me con el token
    M-->>P: perfil del usuario
    P->>P: resuelve el usuario local y crea la sesión
```

**Medidas de seguridad presentes:**
- Parámetro `state` de 32 bytes aleatorios, guardado **en sesión y en una cookie `Lax` temporal**. La doble fuente resuelve el problema de que la cookie de sesión `SameSite=Strict` no viaja en el callback cross-site. Si llegan ambas, deben coincidir.
- Ámbito mínimo: `openid profile User.Read` — solo se consulta `/me`, no se piden refresh tokens ni acceso al buzón.
- Comparación con `hash_equals()`.
- Comprobación de `function_exists('curl_init')` antes de usar cURL.
- Si falta cualquiera de las cuatro variables de configuración, la acción se aborta con un mensaje genérico.

**Configuración** (`.env`): `MICROSOFT_TENANT_ID`, `MICROSOFT_CLIENT_ID`, `MICROSOFT_CLIENT_SECRET`, `MICROSOFT_REDIRECT_URI`. La URL de retorno debe registrarse **exactamente igual** en la aplicación de Entra ID.

---

## 9. Modo demo

`DemoController`, controlado por `DEMO_MODE_ENABLED` en `.env`.

### Activación

```php
// core/Controller.php::__construct()
static::$isDemoMode = !empty($_SESSION[APP_ID]['DemoMode'])
                   && DemoController::isDemoModeEnabled();   // Env::bool('DEMO_MODE_ENABLED', false)
```

La doble condición es deliberada (hallazgo **HR-007**): así, **desactivar `DEMO_MODE_ENABLED` invalida inmediatamente las sesiones de demo existentes**.

### Qué hace `?c=Demo&a=index`

1. Aborta si `DEMO_MODE_ENABLED` no está activo.
2. `seedDemoData()` — **siembra datos en la base de datos real** (con caché de 6 h en `storage/cache/demo_data_ready.flag`).
3. `bootDemoSession()` — **crea una sesión suplantando a un colaborador real**.
4. Redirige a `?c=home&a=index`.

### Restricciones en modo demo

| Mecanismo | Detalle |
|---|---|
| Módulos permitidos (`$ALLOWED_ROUTES`) | `Demo`, `Home`, `Reclutamiento`, `Metas`, `PlanesDesarrollo`, `Capacitacion`, `CapacitacionInscripciones`, `Beneficios`, `Potenciales`, `PerfilColaborador`, `AdelantosNominaPortal`, `Tickets`, `Ausencias` |
| Acciones sensibles (`$WRITE_ACTION_PATTERN`) | Regex sobre `guardar\|save\|remove\|delete\|eliminar\|activar\|inactivar\|archivar\|aprobar\|rechazar\|publicar\|cambiarestado\|carguemasivo\|bulk\|importar\|asignar\|desactivar\|configur\|usuario\|rol\|permiso\|parametr\|basedatos\|database` |
| Acciones POST seguras (`$SAFE_POST_ACTIONS`) | Lista blanca de 20 acciones de lectura que pueden llegar por POST |
| Filtro de permisos | `UsuariosModel::loadDemoMenu()` aplica `DemoController::filterPermissionsForDemo()` a cada módulo |
| Bloqueo temprano | `Controller::process()` llama a `DemoController::shouldBlockAction()` **antes** de `validateAccess()`; si bloquea, redirige a `?c=Demo&a=index` con el flash *"Esta función está deshabilitada en el modo Demo."* |
| Layout | El controlador usa `$this->layout['demo']` si existe |

### 🔴 Advertencia

En modo demo, `validateAccess()` **omite la lista blanca del portal** y concede `ACCESS` a cualquier acción no sensible de los 13 módulos permitidos, **sin comprobar sesión ni permisos**. Esto es aceptable en una instalación de demostración y catastrófico en producción. La única salvaguarda es `DEMO_MODE_ENABLED=false`, que **debe** ser el valor en cualquier entorno real.

---

## 10. Rate limiting

`core/RateLimiter.php`. Almacén: `$_SESSION[APP_ID]['rate_limiter']`.

```php
$resultado = RateLimiter::check($clave, $maxIntentos = 5, $ventanaSegundos = 300);
// ['allowed'=>bool, 'remaining'=>int, 'retryAfter'=>int|null, 'locked'=>bool, 'locked_until'=>int]
RateLimiter::reset($clave);
RateLimiter::getStatus($clave);
RateLimiter::flushAll();
RateLimiter::getClientIp();
```

Usos reales: los dos logins, con `(10, 900)`.

> ⚠️ **Al vivir en la sesión, descartar la cookie reinicia el contador.** No protege contra fuerza bruta desde un cliente que no conserve cookies. Un almacén compartido (BD o fichero, indexado por IP + usuario) sería necesario para que fuese efectivo.

---

## 11. Otros controles

| Control | Configuración | Estado |
|---|---|---|
| `IP_BLOCKING` | `.env` → `Config::$IP_BLOCKING` | 🔴 **Sin efecto.** La tabla `ips_autorizadas`, su modelo y su CRUD existen, pero `grep -rF '$IP_BLOCKING'` sobre `app/`, `core/` y `services/` **solo la encuentra en `Config.php`**. Nada comprueba la IP del cliente |
| `DROP_FILES` | `.env` | Consultada únicamente por `ElFinderController` |
| `NO_COPY` | `.env` | 🔴 **Sin efecto.** No se consulta en ninguna parte |
| `cambio_rol` | `.env` → `CAMBIO_ROL` | 🔴 **Sin efecto.** Solo aparece en `Config.php` |
| `LOGIN_AUTOMATICO` | `.env` | 🔴 **Sin efecto.** Solo aparece en `Config.php` |
| `LOGIN_MANUAL` | `.env` | Consultada únicamente por `PublicModel` |
| Endurecimiento de sesión | `core/AutoLoad.php` | `use_strict_mode`, `HttpOnly`, `Secure` bajo HTTPS, `SameSite=Strict` |
| Regeneración de Id de sesión | `regenerateSessionOnAuthentication()` | Aplicada en ambos logins |
| Logout | `LoginController::logoutAction()` | `unset($_SESSION[APP_ID])`. **No llama a `session_destroy()`** ni invalida la cookie |

---

## 12. Cómo implementar una funcionalidad con permisos

### Paso a paso

**1. Elegir el `$MODULE_NAME`.** Es la clave en `permisos.ModuleName`. Si el módulo es nuevo, usa un nombre en `PascalCase` que no colisione con los 52 existentes.

**2. Declarar cada acción en `loadAccessControl()`.**

```php
protected function loadAccessControl()
{
    $this->AccessControl = array(
        'list'         => '@',
        'view'         => '@',
        'create'       => '@',
        'edit'         => '@',
        'remove'       => '@',
        'dataListAjax' => '@',
        'aprobar'      => '@',   // acción propia del módulo
    );
}
```

> Una acción que no aparezca aquí es inaccesible **incluso para el administrador**.

**3. Sembrar los permisos en la base de datos.** Crea una semilla numerada (o amplía `013_PermisosSeeder.php`):

```php
$this->upsert('permisos', array(
    array('IdRol' => 1, 'ModuleName' => 'MiModulo',
          'Permission' => '{"create":1,"view":1,"edit":1,"remove":1,"list":1,"export":1}',
          'Tabs' => '{}', 'Other' => ''),
    array('IdRol' => 2, 'ModuleName' => 'MiModulo',
          'Permission' => '{"create":0,"view":1,"edit":0,"remove":0,"list":1,"export":1}',
          'Tabs' => '{}', 'Other' => ''),
), array('IdRol', 'ModuleName'));
```

**4. Proteger las mutaciones a mano.** El framework solo impone CSRF en `remove`:

```php
public function aprobarAction()
{
    if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST'
        || !Controller::validateCsrfToken($_POST['_csrf_token'] ?? null)) {
        UserFlash::setFlash('Error', 'Token de seguridad inválido.');
        ROUTER::redirect_to_action($this->Module, 'list');
        return;
    }
    // …
}
```

**5. Comprobar la propiedad del registro cuando aplique.** No hay comprobación automática de IDOR:

```php
$idColaboradorSesion = (int)($_SESSION[Controller::getAppId()]['User']['Id'] ?? 0);
$solicitud = MiModeloModel::getById((int)$_GET['Id']);
if ((int)($solicitud['ColaboradorId'] ?? 0) !== $idColaboradorSesion) {
    UserFlash::setFlash('Error', 'No tienes acceso a este registro.');
    ROUTER::redirect_to_action($this->Module, 'list');
    return;
}
```

**6. Si es del portal del colaborador**, además:
- Usar `protected $MyLayout = 'metronicPublic';`
- **Añadir la clase y sus acciones a `Controller::$PUBLIC_COLLABORATOR_ROUTES`** — sin esto, la sesión de portal recibirá `NO_PERMISSIONS`.
- Añadir el ítem a `Menu::$public`.

**7. Añadir el ítem de menú** en `app/config/Menu.php`.

**8. Recordar que el menú y los permisos están congelados en sesión.** Tras cambiar `Menu.php` o la tabla `permisos`, hay que **cerrar sesión y volver a entrar** para ver el efecto.

---

## 13. Resumen de controles por capa

| Capa | Control | Efectivo |
|---|---|---|
| Transporte | HSTS bajo HTTPS | ✅ |
| Cookie | `HttpOnly`, `Secure`, `SameSite=Strict`, `use_strict_mode` | ✅ |
| Sesión | Regeneración de Id al autenticar | ✅ |
| Sesión | `logout` sin `session_destroy()` | ⚠️ Parcial |
| Credenciales | MD5 sin sal | 🔴 Insuficiente |
| Credenciales | Política de complejidad (8, may, min, dígito) | ✅ |
| Credenciales | Rate limit 10/900 s | ⚠️ Evitable (vive en sesión) |
| Credenciales | Mensaje de error genérico | ✅ |
| Autorización | `AccessControl` deny-by-default por acción | ✅ |
| Autorización | Permisos por rol en BD | ✅ para el backoffice; ⚠️ eludido por código en el portal |
| Autorización | Lista blanca de rutas del portal | ✅ |
| Autorización | `dataListAjax` en `$actionsGeneral` | 🔴 Elude el permiso de módulo |
| Autorización | Comprobación de propiedad de registro | 🔴 No centralizada |
| CSRF | Token de sesión rotativo | ✅ donde se aplica |
| CSRF | Imposición centralizada | 🔴 Solo en `remove` y `Model::save()` |
| CSRF | `CSRF_STRICT` | 🔴 Variable sin efecto |
| Inyección SQL | `addslashes` en INSERT/UPDATE | ⚠️ Parcial |
| Inyección SQL | Cláusulas WHERE | 🔴 Sin escapar |
| XSS | CSP con `'unsafe-inline'` | ⚠️ Debilitada |
| XSS | Escapado manual en vistas | ⚠️ Inconsistente |
| Cabeceras | X-Frame-Options, nosniff, Referrer-Policy | ✅ |
| Fugas de información | `echo` de la excepción en el `catch` global | 🔴 |

---

## Documentos relacionados
- [06_BACKEND.md](06_BACKEND.md)
- [08_API.md](08_API.md)
- [13_CONFIGURATION.md](13_CONFIGURATION.md)
- [18_KNOWN_ISSUES.md](18_KNOWN_ISSUES.md)
