# 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 ```prisma 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 ```typescript // apps/api/src/middlewares/auth.middleware.ts const tokenVersionCache = new Map(); 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.prisma` — `User.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.ts` — `JWTPayload.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` 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.ts` — `changePassword(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.prisma` — `tokenVersion` 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.ts` — `JWTPayload.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.