# RF-AUTH-004: Logout y Revocacion de Sesion ## Identificacion | Campo | Valor | |-------|-------| | **ID** | RF-AUTH-004 | | **Modulo** | MGN-001 | | **Nombre Modulo** | Auth - Autenticacion | | **Prioridad** | P0 | | **Complejidad** | Baja | | **Estado** | Aprobado | | **Autor** | System | | **Fecha** | 2025-12-05 | --- ## Descripcion El sistema debe permitir a los usuarios cerrar su sesion de forma segura, revocando todos los tokens asociados (access token y refresh token). Esto garantiza que los tokens no puedan ser reutilizados despues del logout, incluso si no han expirado. ### Contexto de Negocio El logout seguro es esencial para: - Proteger cuentas en dispositivos compartidos - Cumplir con politicas de seguridad corporativas - Permitir al usuario revocar acceso si sospecha compromiso - Terminar sesiones en dispositivos perdidos o robados --- ## Criterios de Aceptacion - [x] **CA-001:** El sistema debe aceptar el refresh token para identificar la sesion a cerrar - [x] **CA-002:** El sistema debe invalidar el refresh token en la base de datos - [x] **CA-003:** El sistema debe agregar el access token actual a la blacklist - [x] **CA-004:** El sistema debe eliminar la cookie httpOnly del refresh token - [x] **CA-005:** El sistema debe responder con 200 OK en logout exitoso - [x] **CA-006:** El sistema debe registrar el logout en el historial de sesiones - [x] **CA-007:** El sistema debe permitir logout de todas las sesiones (logout global) - [x] **CA-008:** El frontend debe limpiar tokens de memoria/storage ### Ejemplos de Verificacion ```gherkin Scenario: Logout exitoso Given un usuario autenticado con sesion activa When el usuario hace POST /api/v1/auth/logout Then el sistema revoca el refresh token actual And agrega el access token a la blacklist And elimina la cookie del refresh token And responde con status 200 And registra el evento en session_history Scenario: Logout de todas las sesiones Given un usuario con multiples sesiones activas en diferentes dispositivos When el usuario hace POST /api/v1/auth/logout-all Then el sistema revoca TODOS los refresh tokens del usuario And invalida toda la familia de tokens And responde con status 200 And el usuario es forzado a re-autenticarse en todos los dispositivos Scenario: Logout con token ya expirado Given un usuario con access token expirado pero refresh token valido When el usuario intenta hacer logout Then el sistema permite el logout usando solo el refresh token And responde con status 200 ``` --- ## Reglas de Negocio | ID | Regla | Validacion | |----|-------|------------| | RN-001 | El logout revoca la sesion actual unicamente | Por defecto solo sesion actual | | RN-002 | Logout-all revoca todas las sesiones del usuario | Parametro all=true | | RN-003 | Los tokens revocados se almacenan hasta su expiracion natural | Cleanup job posterior | | RN-004 | El access token se blacklistea en Redis/memoria | TTL = tiempo restante de exp | | RN-005 | El logout debe funcionar aunque el access token este expirado | Usar refresh token | | RN-006 | El evento de logout se registra para auditoria | session_history.action = 'logout' | ### Blacklist de Tokens Para invalidacion inmediata de access tokens (que son stateless), se usa una blacklist: ``` Token activo + logout → jti agregado a blacklist Validacion de token → verificar si jti esta en blacklist Blacklist TTL → igual al tiempo restante del token ``` --- ## Impacto en Capas ### Database | Elemento | Accion | Descripcion | |----------|--------|-------------| | Tabla | usar | `refresh_tokens` - marcar como revocado | | Tabla | usar | `session_history` - registrar logout | | Columna | agregar | `refresh_tokens.revoked_at` TIMESTAMPTZ | | Columna | agregar | `refresh_tokens.revoked_reason` VARCHAR(50) | ### Backend | Elemento | Accion | Descripcion | |----------|--------|-------------| | Controller | crear | `AuthController.logout()` | | Controller | crear | `AuthController.logoutAll()` | | Method | crear | `TokenService.revokeRefreshToken()` | | Method | crear | `TokenService.revokeAllUserTokens()` | | Method | crear | `TokenService.blacklistAccessToken()` | | Service | crear | `BlacklistService` (Redis) | | Endpoint | crear | `POST /api/v1/auth/logout` | | Endpoint | crear | `POST /api/v1/auth/logout-all` | ### Frontend | Elemento | Accion | Descripcion | |----------|--------|-------------| | Service | modificar | `authService.logout()` | | Store | modificar | `authStore.clearSession()` | | Method | crear | `tokenService.clearAllTokens()` | | Interceptor | modificar | Redirect a login en 401 post-logout | --- ## Dependencias ### Depende de (Bloqueantes) | ID | Requerimiento | Estado | |----|---------------|--------| | RF-AUTH-001 | Login | Crea la sesion a cerrar | | RF-AUTH-002 | JWT Tokens | Tokens a revocar | | RF-AUTH-003 | Refresh Token | Token a invalidar | ### Dependencias Relacionadas | ID | Requerimiento | Relacion | |----|---------------|----------| | RF-AUTH-005 | Password Recovery | Puede forzar logout-all | --- ## Especificaciones Tecnicas ### Flujo de Logout ``` ┌─────────────────────────────────────────────────────────────────┐ │ 1. Frontend llama POST /api/v1/auth/logout │ │ - Envia refresh token en cookie httpOnly │ │ - Envia access token en header Authorization │ └───────────────────────────┬─────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────────┐ │ 2. Backend extrae tokens │ │ - Decodifica refresh token para obtener jti │ │ - Decodifica access token para obtener jti │ └───────────────────────────┬─────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────────┐ │ 3. Revoca refresh token en BD │ │ - UPDATE refresh_tokens SET │ │ revoked_at = NOW(), │ │ revoked_reason = 'user_logout' │ │ WHERE jti = :refresh_jti │ └───────────────────────────┬─────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────────┐ │ 4. Blacklistea access token en Redis │ │ - SET blacklist:{access_jti} = 1 │ │ - EXPIRE blacklist:{access_jti} {remaining_ttl} │ └───────────────────────────┬─────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────────┐ │ 5. Elimina cookie de refresh token │ │ - Set-Cookie: refresh_token=; Max-Age=0; HttpOnly │ └───────────────────────────┬─────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────────┐ │ 6. Registra evento en session_history │ │ - INSERT INTO session_history (user_id, action, ...) │ └───────────────────────────┬─────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────────┐ │ 7. Responde al frontend │ │ - Status 200 OK │ │ - Body: { message: "Sesion cerrada exitosamente" } │ └─────────────────────────────────────────────────────────────────┘ ``` ### Blacklist Service (Redis) ```typescript @Injectable() export class BlacklistService { constructor(private redis: RedisService) {} async blacklistToken(jti: string, expiresIn: number): Promise { const key = `blacklist:${jti}`; await this.redis.set(key, '1', 'EX', expiresIn); } async isBlacklisted(jti: string): Promise { const key = `blacklist:${jti}`; const result = await this.redis.get(key); return result !== null; } } ``` ### Logout All (Logout Global) ```typescript async logoutAll(userId: string): Promise { // Revocar todos los refresh tokens del usuario await this.refreshTokenRepo.update( { userId, revokedAt: IsNull() }, { revokedAt: new Date(), revokedReason: 'logout_all' } ); // Blacklistear todos los access tokens activos // (requiere tracking de tokens activos o usar family_id) await this.blacklistUserTokens(userId); // Registrar evento await this.sessionHistoryService.record({ userId, action: 'logout_all', metadata: { reason: 'user_requested' } }); } ``` --- ## Datos de Prueba | Escenario | Entrada | Resultado | |-----------|---------|-----------| | Logout exitoso | Token valido | 200, sesion cerrada | | Logout sin token | Sin Authorization | 401, "Token requerido" | | Logout token expirado | Access expirado, refresh valido | 200, permite logout | | Logout-all | all=true | 200, todas sesiones cerradas | | Logout token ya revocado | Refresh ya revocado | 200, idempotente | --- ## Estimacion | Capa | Story Points | Notas | |------|--------------|-------| | Database | 1 | Columnas adicionales | | Backend | 3 | Controller, Services, Redis | | Frontend | 2 | Limpieza de estado | | **Total** | **6** | | --- ## Notas Adicionales - Implementar logout como operacion idempotente (no falla si ya esta logged out) - Considerar endpoint para logout de sesion especifica por device_id - El blacklist en Redis debe tener alta disponibilidad - Cleanup job para eliminar tokens revocados expirados de BD - Notificar al usuario via email si se hace logout-all (seguridad) --- ## Historial de Cambios | Version | Fecha | Autor | Cambios | |---------|-------|-------|---------| | 1.0 | 2025-12-05 | System | Creacion inicial | --- ## Aprobaciones | Rol | Nombre | Fecha | Firma | |-----|--------|-------|-------| | Analista | System | 2025-12-05 | [x] | | Tech Lead | - | - | [ ] | | Product Owner | - | - | [ ] |