Files
HoruxDespachos/docs/plans/2026-04-14-jwt-revocation.md
2026-04-27 22:09:36 -06:00

12 KiB

Revocación de JWT por tokenVersion

Estado: IMPLEMENTADO (2026-04-14) — mecanismo de revocación vía User.tokenVersion operativo. Password change + "cerrar todas las sesiones" invalidan todos los access tokens del user en el siguiente request (≤30s por cache). Ver sección final "Implementación ejecutada".

Problema

Hoy los access tokens son válidos 15 min y refresh tokens 7 días. No hay manera de revocar un token antes de su expiración natural. Si un token leak (laptop robada, XSS, compromiso de sesión en un café internet, phishing), el atacante tiene:

  • Hasta 15 min de acceso con el access token activo
  • Hasta 7 días de capacidad para refresh via refresh token

Para un admin global (acceso a datos fiscales de todos los clientes) esto es inaceptable. Incluso para un user normal, si descubre compromise debería poder "cerrar todas las sesiones" inmediatamente.

Propuesta

Enfoque tokenVersion — contador incremental por usuario, incluido en JWT. Al incrementar, todos los tokens viejos quedan inválidos en el siguiente request.

Schema

model User {
  // ... campos existentes
  tokenVersion Int @default(0) @map("token_version")
}

Flujo

  1. Al loguear: generateAccessToken incluye tokenVersion: user.tokenVersion en el payload
  2. En authenticate middleware: después de verificar la firma JWT, compara payload.tokenVersion contra user.tokenVersion actual en BD. Si no coincide → 401.
  3. Al invalidar: UPDATE users SET token_version = token_version + 1 WHERE id = ?. Todas las sesiones del user mueren en el siguiente request.

Disparadores de incremento

Evento Acción
Cambio de password Incrementar tokenVersion → fuerza re-login en todas las sesiones
"Cerrar todas las sesiones" (UI nueva) Incrementar
Detección de sesión sospechosa (futuro) Incrementar
Borrar user N/A (user ya no existe)
Logout normal NO incrementar — solo invalida el refresh token actual, resto de sesiones del user sobreviven

Performance

Cada request autenticada = 1 lookup extra a User.tokenVersion. Mitigación:

  • Cache in-memory por worker con TTL 30s — 1 DB hit cada 30s por user activo
  • Al incrementar tokenVersion, broadcast vía process.send (ya hay patrón en invalidate-tenant-cache) para propagar la invalidación entre workers
  • Para altísimo tráfico: Redis con el tokenVersion. Hoy no aplica al tamaño del sistema

Sin cache, 1 query por request: medida. Con Postgres local y user indexed by PK, ~0.3ms. Aceptable para el stage actual.

Middleware modificado

// apps/api/src/middlewares/auth.middleware.ts
const tokenVersionCache = new Map<string, { version: number; expires: number }>();
const TOKEN_VERSION_TTL = 30 * 1000; // 30 segundos

export async function authenticate(req, res, next) {
  // ... verify JWT signature como hoy
  const payload = jwt.verify(token, env.JWT_SECRET) as JWTPayload;

  // Check token version
  let current = tokenVersionCache.get(payload.userId);
  if (!current || current.expires < Date.now()) {
    const user = await prisma.user.findUnique({
      where: { id: payload.userId },
      select: { tokenVersion: true },
    });
    if (!user) return res.status(401).json({ message: 'Usuario no existe' });
    current = { version: user.tokenVersion, expires: Date.now() + TOKEN_VERSION_TTL };
    tokenVersionCache.set(payload.userId, current);
  }

  if ((payload.tokenVersion ?? 0) !== current.version) {
    return res.status(401).json({ message: 'Sesión expirada, vuelve a iniciar sesión' });
  }

  req.user = payload;
  next();
}

// Llamar desde auth.service.ts cuando cambia password, o desde nuevo endpoint /auth/logout-all
export function invalidateTokenVersionCache(userId: string) {
  tokenVersionCache.delete(userId);
  if (typeof process.send === 'function') {
    process.send({ type: 'invalidate-token-version', userId });
  }
}

Cluster broadcast idéntico al que ya existe para invalidate-tenant-cache.

Nuevos endpoints

POST /auth/password-change      # Cambia password + incrementa tokenVersion
POST /auth/logout-all           # "Cerrar todas las sesiones" (incrementa tokenVersion)
GET  /auth/sessions             # (opcional) Lista sesiones activas (refresh tokens)
POST /auth/sessions/:id/revoke  # (opcional) Revocar una sesión específica

UI

Agregar en /configuracion/seguridad (página nueva o sección):

  • Botón "Cerrar todas las sesiones excepto esta" → llama /auth/logout-all
  • (Opcional) Lista de sesiones activas con device/IP/última actividad

Consideraciones

Type en JWTPayload

JWTPayload en packages/shared debe incluir tokenVersion?: number. Optional para compatibilidad con tokens viejos al momento del deploy — default 0.

Rollout

  1. Deploy con tokenVersion default 0 en todos los users
  2. Los JWT viejos no incluyen el campo → interpretamos como payload.tokenVersion ?? 0 → matcheará y seguirán funcionando
  3. Cuando user cambia password o se invoca logout-all, se incrementa
  4. Funciona retroactivamente sin forzar re-login masivo

Auditoría

Cada incremento de tokenVersion debe logearse vía auditLog({ action: 'user.token_version_incremented', userId, reason: 'password_changed'|'logout_all'|... }).

Alcance

Tarea Estimación
Schema + migración (campo en User) 30 min
Middleware con cache 1 h
Incrementar en changePassword 30 min
Endpoint /auth/logout-all 1 h
JWT payload type + seed 30 min
UI "Cerrar sesiones" 1 h
Tests 1 h
Total ~1 día

Riesgos

  1. Cache miss burst: después del deploy, 0% cache hit ratio durante primeros 30s. Suma carga a Postgres temporal.
  2. Clock skew entre workers: Date.now() basta, no hay dependencia entre workers más allá del broadcast de invalidación.
  3. Usuarios confundidos: "¿por qué mi sesión expiró sin razón?" — necesario copy claro en error message.

Archivos a tocar

  • apps/api/prisma/schema.prismaUser.tokenVersion
  • apps/api/src/middlewares/auth.middleware.ts — check + cache
  • apps/api/src/services/auth.service.ts — endpoints nuevos, incremento en password change
  • apps/api/src/utils/token.ts — incluir tokenVersion en payload
  • packages/shared/src/types/auth.tsJWTPayload.tokenVersion
  • apps/web/app/(dashboard)/configuracion/seguridad/page.tsx — UI (nueva página)
  • apps/web/lib/api/auth.ts — clients para endpoints nuevos

Relación con otros planes

  • 2026-04-14-audit-log.md: cada incremento de tokenVersion debería auditarse.
  • 2026-04-14-platform-admin-roles.md: cuando un platform_admin revoca un rol de otro staff, puede ser útil forzar logout-all del afectado (incrementar tokenVersion).

Implementación ejecutada (2026-04-14)

Lo que se construyó

Schema:

  • Campo User.tokenVersion Int @default(0) @map("token_version") aplicado vía pnpm prisma db push (no se generó migración SQL porque usamos db push en desarrollo).

Middleware (auth.middleware.ts):

  • Cache in-memory Map<userId, { version, expires }> con TTL 30 segundos
  • getCurrentTokenVersion(userId) — lee cache o Postgres (también valida user.active)
  • authenticate compara payload.tokenVersion ?? 0 vs version actual → 401 si no coincide
  • invalidateTokenVersionCache(userId) — limpia cache local + process.send({ type: 'invalidate-token-version', userId }) para broadcast PM2
  • Listener process.on('message') recibe el broadcast en otros workers y limpia su cache local
  • Backward compat: JWTs emitidos antes del deploy llevan undefined → se interpreta como 0, matchea con el default → no hay re-login forzado masivo

Auth service (auth.service.ts):

  • login() y refreshTokens() incluyen tokenVersion: user.tokenVersion en el payload JWT
  • confirmPasswordReset() — ahora incrementa tokenVersion dentro del transaction (antes solo borraba refresh tokens, dejaba access tokens vivos hasta 15min)
  • changePassword({ userId, currentPassword, newPassword }) — nuevo. Valida password actual, incrementa tokenVersion, borra refresh tokens, auditLog user.password_changed
  • logoutAllSessions(userId) — nuevo. Incrementa tokenVersion, borra refresh tokens, auditLog user.sessions_invalidated con reason: logout_all

Endpoints nuevos (auth.controller.ts + auth.routes.ts):

  • POST /auth/password-change (auth required) — body { currentPassword, newPassword }, zod validado, min 8 chars, current ≠ new
  • POST /auth/logout-all (auth required) — sin body, cierra todas las sesiones del caller

Frontend:

  • lib/api/auth.tschangePassword(currentPassword, newPassword) + logoutAll()
  • app/(dashboard)/configuracion/seguridad/page.tsx — página nueva con 2 cards:
    1. Cambiar contraseña (form con 3 campos, validación client-side, redirect a /login tras 2.5s con success message)
    2. Cerrar todas las sesiones (botón con confirm nativo, redirect inmediato a /login)
  • configuracion/page.tsx — tarjeta "Seguridad" con icono KeyRound linkeando a la nueva página

JWT payload (packages/shared/src/types/auth.ts):

  • tokenVersion?: number agregado al interface — opcional para compat con tokens viejos

Auditoría

Cada invalidación queda logeada en audit_log:

  • user.password_changed — change de password autenticado
  • user.password_reset_completed — reset por flujo "olvidé contraseña" (ya existía, ahora además incrementa tokenVersion)
  • user.sessions_invalidated — botón "cerrar todas las sesiones"

Archivos tocados

Backend:

  • prisma/schema.prismatokenVersion en User
  • src/middlewares/auth.middleware.ts — cache + check + broadcast PM2
  • src/services/auth.service.ts — payload includes tokenVersion, new changePassword + logoutAllSessions, confirmPasswordReset incrementa
  • src/controllers/auth.controller.ts — handlers changePassword y logoutAll
  • src/routes/auth.routes.ts — 2 rutas nuevas

Shared:

  • src/types/auth.tsJWTPayload.tokenVersion?: number

Frontend:

  • lib/api/auth.ts — 2 métodos nuevos
  • app/(dashboard)/configuracion/seguridad/page.tsx (nuevo)
  • app/(dashboard)/configuracion/page.tsx — tarjeta "Seguridad"

Decisiones de diseño

  • Cache TTL 30s (no Redis): a este tamaño de tráfico el lookup a Postgres por PK es ~0.3ms. Un worker PM2 con 50 requests/s de un mismo user haría 1 DB hit cada 30s (1500 requests cacheados). Redis se añadirá solo si el perfil muestra contención.
  • Cross-worker broadcast: reutiliza el mismo patrón process.send que invalidate-tenant-cache — evita tener un usuario con "media sesión inválida" porque solo 1 de N workers vio el cambio.
  • Logout-all cierra la sesión actual también: por diseño — si el user está en /configuracion/seguridad y hace clic, espera que "todas" signifique todas. Se le redirige a /login inmediato.
  • current === new password bloqueado: defensivo — evita el caso de usuario que quiere "refrescar" su password pero por error escribe la misma. Sin esto, tokenVersion se incrementa sin razón y cierra otras sesiones gratis.

Verificación manual

1. Login → el JWT ahora incluye tokenVersion (decode en jwt.io para verificar)
2. /configuracion → aparece tarjeta "Seguridad"
3. /configuracion/seguridad:
   - Cambiar contraseña con current incorrecto → error "Contraseña actual incorrecta"
   - Cambiar con current = new → error "debe ser distinta"
   - Cambiar con new < 8 chars → error client
   - Cambiar correcto → mensaje verde + redirect a /login en 2.5s
   - Re-loguear con nueva password → OK
4. En 2 browsers logueados como mismo user:
   - Browser A: /configuracion/seguridad → "Cerrar todas las sesiones"
   - Browser A: redirect a /login
   - Browser B: en siguiente request (max 30s) → 401 "Sesión expirada..."
5. Audit log: aparecen user.password_changed y user.sessions_invalidated

Pendientes / futuro

  • GET /auth/sessions — listar sesiones activas (refresh tokens) con device/IP/lastUsed. Requiere columnas adicionales en refresh_tokens.
  • POST /auth/sessions/:id/revoke — revocar una sesión específica sin cerrar las demás.
  • Auto logout-all en grant/revoke de platform_role: cuando admin da/quita rol de plataforma, podría forzar logout-all del afectado para que su nuevo JWT refleje el cambio sin esperar refresh.
  • Migración de db push a migrate: el campo tokenVersion se aplicó con db push. Cuando se genere la próxima migración SQL central, incluirla formalmente.