Saltar a contenido

0018. Seguridad del pre_token (expiración corta, un solo uso)

Estado: Propuesto Fecha: 2026-09-08 Autor: Duval Alcivar Módulo(s) afectado(s): svc-identidad, frontend (React 18), log_sesiones

Contexto

El login en dos pasos del ADR 0010 emite un pre_token (5 min) tras validar la contraseña: sirve solo para elegir empresa en el paso 2 (/api/v1/auth/login/empresa). Quedó registrado como deuda: "debe tener expiración corta (5 min) y un solo uso; su implementación puede ser un JWT firmado con claim scope: "pre-auth" o un registro opaco en caché". Este ADR resuelve cómo.

Decisión

El pre_token es un JWT firmado con la misma clave RS256 del 0011, con scope: "pre-auth", y el consumo de un solo uso se garantiza con un registro en PostgreSQL (no Redis: no añadimos infraestructura que hoy no tenemos).

1. Formato y claims

Claim Valor Por qué
sub usuario_id (UUID) a quién pertenece
scope "pre-auth" nunca access; cualquier endpoint de negocio lo rechaza
jti UUID aleatorio identifica el consumo (un solo uso)
iat / exp exp = iat + 5 min expiración corta, configurable con PRE_TOKEN_TTL_MIN
aud "com.sigfa.plataforma" audiencia fija de plataforma (ADR 0020)

El pre_token no lleva empresa_id, tipo_usuario ni permisos: revelar las empresas antes de la contraseña era la fuga a evitar (0010), y el paso 2 las re-validará contra la base.

2. Registro de consumo (un solo uso)

Tabla nueva trx_pre_tokens (solo se registra el jti, no el token):

CREATE TABLE core.identidad.trx_pre_tokens (
    jti        UUID PRIMARY KEY,              -- claim jti del pre_token
    usuario_id UUID NOT NULL REFERENCES core.identidad.mae_usuarios(id),
    expiracion TIMESTAMPTZ NOT NULL,
    usado_en   TIMESTAMPTZ NULL,              -- NULL = aún no consumido
    created_at TIMESTAMPTZ DEFAULT now()
);

CREATE INDEX ixp_trx_pre_tokens_usuario ON core.identidad.trx_pre_tokens (usuario_id);

Emisión (paso 1 del login): insertar la fila en la misma transacción que el hash de sesión, y responder lista de empresas + pre_token.

Consumo (paso 2):

  1. Verificar firma RS256 + scope=pre-auth + exp + aud (los fallos del JWT se rechazan con 401 sin tocar la base).
  2. Marcar el jti como usado: INSERT ... (jti, ...) VALUES (...) ON CONFLICT (jti) DO NOTHING seguido de SELECT usado_en .... Si ya existía (replay) → tratar como fuga (§3).
  3. Re-validar que empresa_id pertenezca al usuario (rel_usuario_empresa activa, ADR 0005/0016). El pre_token no confía en un id de empresa incrustado.

3. Replay = sospecha de compromiso

Reusar un jti ya consumido (o expirado) se registra en log_sesiones (accion='login', exitoso=false, razon_fallo='pre_token_replay') y se invalidan los pre_tokens pendientes del usuario (UPDATE usado_en a los NULL de ese usuario): un atacante que copió un login no puede repetirlo ni disparar más pasos 2.

4. Flujo "una sola empresa" (0010)

Aunque el frontend no muestre el modal cuando el usuario tiene una sola empresa, el paso 2 usa igual el pre_token (el servidor no sabe "cuántas empresas" por el layout): mismo flujo, sin atajos.

5. Cambio de empresa sin cerrar sesión

El cambio de empresa del 0010 usa un refresh token válido, no un pre_token: el pre_token es exclusivo del flujo credenciales → primer token.

Alternativas descartadas

  • Registro opaco aleatorio en caché (Redis) — añade infraestructura y un punto de fallo para algo que PostgreSQL resuelve con una tabla; al mismo tiempo, sin firma, no se puede verificar exp sin consultar la tabla en cada paso.
  • pre_token con permisos/empresas incrustados — infla el token y vuelve a filtrar información antes de elegir empresa.
  • pre_token de larga duración (30 min) — amplía la ventana de replay de un capturador de tráfico.
  • Sin un solo uso (reusable los 5 min) — un token robado se puede reusar hasta que expire; el registro de consumo lo neutraliza al primer uso.
  • pre_token como JWT "access" camuflado — un downstream que no revise scope aceptaría un token sin empresa_id/permisos; scope: "pre-auth" explícito evita ese error de clase.

Consecuencias

  • El pre_token se valida en dos movimientos: firma (estático, sin base) y consumo (una fila en trx_pre_tokens). Replay detectado → respuesta de fuga.
  • trx_pre_tokens crece con cada login; la purga de usados/expirados es un job programado (ADRs 0015 y 0021).
  • El login en dos pasos se mantiene sin exponer las empresas antes de la contraseña y sin depender de Redis.
  • Todos los tokens del sistema comparten la audiencia fija de plataforma (ADR 0020) y las mismas claves; el pre_token solo se distingue por scope.

Este ADR resuelve la deuda de seguridad del 0010 (pre_token). La tabla trx_pre_tokens convive con trx_refresh_tokens/log_sesiones (0005); su limpieza programada está en el 0021; la expiración (5 min) es configurable junto al resto de TTLs en el 0020.