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
Ejemplos de Verificacion
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)
@Injectable()
export class BlacklistService {
constructor(private redis: RedisService) {}
async blacklistToken(jti: string, expiresIn: number): Promise<void> {
const key = `blacklist:${jti}`;
await this.redis.set(key, '1', 'EX', expiresIn);
}
async isBlacklisted(jti: string): Promise<boolean> {
const key = `blacklist:${jti}`;
const result = await this.redis.get(key);
return result !== null;
}
}
Logout All (Logout Global)
async logoutAll(userId: string): Promise<void> {
// 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 |
- |
- |
[ ] |