Files
HoruxDespachosNuevo/docs/plans/2026-04-14-jwt-revocation.md

251 lines
12 KiB
Markdown

# 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<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.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<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.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.