251 lines
12 KiB
Markdown
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.
|
|
|