Control de Sesiones entre Dispositivos — Finnova
Finnova es una app mobile que los usuarios pueden instalar en más de un dispositivo. Esta página define cómo se crean, mantienen, inspeccionan y revocan las sesiones, y qué ocurre ante eventos de seguridad (cambio de contraseña, robo de dispositivo, actividad sospechosa).
1. Modelo de sesión
Finnova usa un esquema de doble token:
| Token | Vida útil | Almacenamiento | Propósito |
|---|---|---|---|
| Access Token (JWT) | 15 minutos | Memoria de la app (no en disco) | Autorizar llamadas a la API |
| Refresh Token (opaco, aleatorio) | 30 días | SecureStore del dispositivo + DB | Obtener un nuevo access token sin re-login |
Cada par de tokens está ligado a una sesión, y cada sesión está ligada a un dispositivo específico.
2. Modelo de datos de sesiones
CREATE TABLE sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
refresh_token_hash TEXT NOT NULL, -- SHA-256 del refresh token; nunca el token en claro
device_name TEXT, -- ej. "iPhone 14 de Daniel"
device_os TEXT, -- ej. "iOS 17.4"
device_id TEXT, -- identificador de dispositivo (expo-device)
ip_address INET,
user_agent TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
last_used_at TIMESTAMPTZ DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL, -- created_at + 30 días
revoked_at TIMESTAMPTZ, -- NULL = activa
revoked_by TEXT -- 'user', 'admin', 'password_change', 'security_event'
);
CREATE INDEX idx_sessions_user_id ON sessions(user_id);
CREATE INDEX idx_sessions_refresh_token_hash ON sessions(refresh_token_hash);
El refresh token nunca se almacena en claro en la base de datos — solo su hash SHA-256. Si la DB se compromete, los tokens no son utilizables directamente.
3. Flujos de sesión
3.1 Login (nueva sesión)
3.2 Renovación de access token (token refresh)
3.3 Logout (revocar sesión actual)
4. Gestión multidispositivo
4.1 Listado de sesiones activas
Los usuarios pueden ver todas sus sesiones activas desde la app en Cuenta → Dispositivos conectados:
┌─────────────────────────────────────────────┐
│ Dispositivos conectados │
├─────────────────────────────────────────────┤
│ 📱 iPhone 14 de Daniel ← Este dispositivo │
│ iOS 17.4 · Última actividad: ahora │
│ │
│ 📱 Samsung Galaxy S23 │
│ Android 14 · Última actividad: hace 2 h │
│ [Cerrar sesión] │
│ │
│ 📱 iPad Pro │
│ iOS 16.5 · Última actividad: hace 3 días│
│ [Cerrar sesión] │
│ │
│ [Cerrar todas las otras sesiones] │
└─────────────────────────────────────────────┘
Endpoint: GET /auth/sessions — devuelve todas las sesiones no revocadas del usuario, marcando cuál es la actual (por session_id del JWT).
4.2 Límite de sesiones simultáneas
| Plan | Sesiones simultáneas máximas |
|---|---|
| Gratuito | 2 dispositivos |
| Premium | 5 dispositivos |
Al superar el límite, se revoca automáticamente la sesión más antigua (last_used_at más viejo).
4.3 Revocar sesión de otro dispositivo
Endpoint: DELETE /auth/sessions/:sessionId
- El usuario solo puede revocar sesiones propias (validado por
user_iddel JWT) - La sesión revocada no puede renovar tokens a partir del momento de revocación
- El dispositivo afectado quedará con el access token activo hasta que expire (máximo 15 min), luego será forzado a re-login
5. Eventos de revocación automática
Ciertos eventos de seguridad invalidan todas las sesiones activas del usuario:
| Evento | Acción | revoked_by |
|---|---|---|
| Cambio de contraseña | Revocar todas las sesiones excepto la actual | 'password_change' |
| Solicitud de reseteo de contraseña | Revocar todas las sesiones | 'password_reset' |
| Cuenta suspendida por admin | Revocar todas las sesiones | 'admin' |
| Detección de actividad sospechosa | Revocar todas las sesiones + notificar al usuario | 'security_event' |
| Token de refresh comprometido detectado | Revocar sesión específica + notificar | 'security_event' |
// Ejemplo: revocación masiva al cambiar contraseña
async function revokeAllSessionsExceptCurrent(
userId: string,
currentSessionId: string
): Promise<void> {
await db.query(
`UPDATE sessions
SET revoked_at = now(), revoked_by = 'password_change'
WHERE user_id = $1
AND id != $2
AND revoked_at IS NULL`,
[userId, currentSessionId]
);
}
6. Detección de uso anómalo
6.1 Señales de alerta
| Señal | Umbral | Acción |
|---|---|---|
| Múltiples intentos de refresh fallidos | 5 en 10 minutos | Bloquear IP temporalmente (15 min) |
| Refresh token usado desde IP muy distinta | Cambio de país en < 1 hora | Notificar al usuario, solicitar confirmación |
| Login exitoso desde país nunca antes visto | Primera vez | Email de alerta al usuario |
| Mismo refresh token usado dos veces simultáneamente | Concurrencia detectada | Revocar toda la familia de tokens (token reuse detection) |
6.2 Token reuse detection
Si un refresh token se usa dos veces (indica que fue robado y alguien más lo está usando), se revoca la sesión completa y se notifica al usuario:
async function handleRefreshToken(token: string): Promise<TokenPair | null> {
const hash = sha256(token);
const session = await db.query(
'SELECT * FROM sessions WHERE refresh_token_hash = $1',
[hash]
);
if (!session) return null;
if (session.revoked_at !== null) {
// Token ya revocado pero alguien lo usa → posible robo
await revokeAllSessionsForUser(session.user_id, 'security_event');
await notifyUserOfSuspiciousActivity(session.user_id);
return null;
}
// Token válido → proceder con renovación
await updateLastUsed(session.id);
return issueNewAccessToken(session);
}
7. Notificaciones de seguridad al usuario
| Evento | Canal | Contenido |
|---|---|---|
| Login desde nuevo dispositivo | Push + Email | Dispositivo, OS, hora, IP aproximada |
| Sesión cerrada remotamente | Push | Qué dispositivo fue desconectado y por quién |
| Cambio de contraseña | Confirmación + enlace para reportar si no fue el usuario | |
| Actividad sospechosa detectada | Push + Email | Descripción del evento + botón "No fui yo" |
| Todas las sesiones cerradas | Razón (reseteo de contraseña, seguridad) |
8. Limpieza de sesiones expiradas
Las sesiones expiradas o revocadas se mantienen en la tabla por 90 días para auditoría, luego se purgan:
-- Job programado: ejecutar diariamente
DELETE FROM sessions
WHERE (expires_at < now() OR revoked_at IS NOT NULL)
AND created_at < now() - INTERVAL '90 days';
9. Checklist de implementación
Antes del MVP
- Crear tabla
sessionscon los campos definidos - Implementar endpoints
/auth/login,/auth/refresh,/auth/logout - Guardar refresh token en
SecureStoreen la app (nunca en AsyncStorage) - Incluir
session_idydevice_iden el payload del JWT - Revocar todas las sesiones al cambiar contraseña
Post-MVP (primer mes)
- Implementar pantalla de "Dispositivos conectados" en la app
- Implementar endpoint
DELETE /auth/sessions/:idpara revocar desde otro dispositivo - Implementar límite de sesiones por plan (2 gratuito / 5 premium)
- Implementar token reuse detection
- Notificaciones push de login desde nuevo dispositivo
Primer trimestre post-MVP
- Implementar detección de anomalías por geolocalización de IP
- Job de limpieza de sesiones expiradas (cron diario)
- Dashboard de auditoría de sesiones para el equipo (uso interno)