Saltar al contenido principal

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:

TokenVida útilAlmacenamientoPropósito
Access Token (JWT)15 minutosMemoria de la app (no en disco)Autorizar llamadas a la API
Refresh Token (opaco, aleatorio)30 díasSecureStore del dispositivo + DBObtener 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

PlanSesiones simultáneas máximas
Gratuito2 dispositivos
Premium5 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_id del 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:

EventoAcciónrevoked_by
Cambio de contraseñaRevocar todas las sesiones excepto la actual'password_change'
Solicitud de reseteo de contraseñaRevocar todas las sesiones'password_reset'
Cuenta suspendida por adminRevocar todas las sesiones'admin'
Detección de actividad sospechosaRevocar todas las sesiones + notificar al usuario'security_event'
Token de refresh comprometido detectadoRevocar 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ñalUmbralAcción
Múltiples intentos de refresh fallidos5 en 10 minutosBloquear IP temporalmente (15 min)
Refresh token usado desde IP muy distintaCambio de país en < 1 horaNotificar al usuario, solicitar confirmación
Login exitoso desde país nunca antes vistoPrimera vezEmail de alerta al usuario
Mismo refresh token usado dos veces simultáneamenteConcurrencia detectadaRevocar 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

EventoCanalContenido
Login desde nuevo dispositivoPush + EmailDispositivo, OS, hora, IP aproximada
Sesión cerrada remotamentePushQué dispositivo fue desconectado y por quién
Cambio de contraseñaEmailConfirmación + enlace para reportar si no fue el usuario
Actividad sospechosa detectadaPush + EmailDescripción del evento + botón "No fui yo"
Todas las sesiones cerradasEmailRazó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 sessions con los campos definidos
  • Implementar endpoints /auth/login, /auth/refresh, /auth/logout
  • Guardar refresh token en SecureStore en la app (nunca en AsyncStorage)
  • Incluir session_id y device_id en 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/:id para 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)

Referencias