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
- Al loguear:
generateAccessTokenincluyetokenVersion: user.tokenVersionen el payload - En
authenticatemiddleware: después de verificar la firma JWT, comparapayload.tokenVersioncontrauser.tokenVersionactual en BD. Si no coincide → 401. - 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 eninvalidate-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
- Deploy con
tokenVersiondefault 0 en todos los users - Los JWT viejos no incluyen el campo → interpretamos como
payload.tokenVersion ?? 0→ matcheará y seguirán funcionando - Cuando user cambia password o se invoca logout-all, se incrementa
- 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
- Cache miss burst: después del deploy, 0% cache hit ratio durante primeros 30s. Suma carga a Postgres temporal.
- Clock skew entre workers:
Date.now()basta, no hay dependencia entre workers más allá del broadcast de invalidación. - Usuarios confundidos: "¿por qué mi sesión expiró sin razón?" — necesario copy claro en error message.
Archivos a tocar
apps/api/prisma/schema.prisma—User.tokenVersionapps/api/src/middlewares/auth.middleware.ts— check + cacheapps/api/src/services/auth.service.ts— endpoints nuevos, incremento en password changeapps/api/src/utils/token.ts— incluirtokenVersionen payloadpackages/shared/src/types/auth.ts—JWTPayload.tokenVersionapps/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 unplatform_adminrevoca 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íapnpm 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 validauser.active)authenticatecomparapayload.tokenVersion ?? 0vs version actual → 401 si no coincideinvalidateTokenVersionCache(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 como0, matchea con el default → no hay re-login forzado masivo
Auth service (auth.service.ts):
login()yrefreshTokens()incluyentokenVersion: user.tokenVersionen el payload JWTconfirmPasswordReset()— ahora incrementatokenVersiondentro 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, auditLoguser.password_changedlogoutAllSessions(userId)— nuevo. Incrementa tokenVersion, borra refresh tokens, auditLoguser.sessions_invalidatedconreason: 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 ≠ newPOST /auth/logout-all(auth required) — sin body, cierra todas las sesiones del caller
Frontend:
lib/api/auth.ts—changePassword(currentPassword, newPassword)+logoutAll()app/(dashboard)/configuracion/seguridad/page.tsx— página nueva con 2 cards:- Cambiar contraseña (form con 3 campos, validación client-side, redirect a /login tras 2.5s con success message)
- Cerrar todas las sesiones (botón con confirm nativo, redirect inmediato a /login)
configuracion/page.tsx— tarjeta "Seguridad" con iconoKeyRoundlinkeando a la nueva página
JWT payload (packages/shared/src/types/auth.ts):
tokenVersion?: numberagregado al interface — opcional para compat con tokens viejos
Auditoría
Cada invalidación queda logeada en audit_log:
user.password_changed— change de password autenticadouser.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.prisma—tokenVersionen Usersrc/middlewares/auth.middleware.ts— cache + check + broadcast PM2src/services/auth.service.ts— payload includes tokenVersion, newchangePassword+logoutAllSessions,confirmPasswordResetincrementasrc/controllers/auth.controller.ts— handlerschangePasswordylogoutAllsrc/routes/auth.routes.ts— 2 rutas nuevas
Shared:
src/types/auth.ts—JWTPayload.tokenVersion?: number
Frontend:
lib/api/auth.ts— 2 métodos nuevosapp/(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.sendqueinvalidate-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 === newpassword 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 enrefresh_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 pusha migrate: el campotokenVersionse aplicó condb push. Cuando se genere la próxima migración SQL central, incluirla formalmente.