Saltar al contenido principal

Sesiones e Historial de Inicio de Sesión — Referencia Técnica

← Volver a Sesiones e Historial de Inicio de Sesión

Dónde vive esto

Backend

Frontend

  • apps/frontend-nextjs/src/page-components/settings/SessionsSettingsPage.tsx (ruta app/settings/sessions) — lista las sesiones activas, un desplegable "Ver detalles" por sesión, botones de revocar-una y revocar-todas-las-demás, y un panel colapsable de historial de inicio de sesión. Declara sus propias queries/mutations gql en línea en lugar de usar hooks generados.
  • apps/frontend-admin/src/app/account/page.tsx — la propia página "Mi cuenta" del administrador tiene una sección de sesiones conectada a adminActiveSessions / adminRevokeSession / adminRevokeAllSessions (las sesiones de inicio de sesión propias del administrador, no las de un usuario regular — consulta Cuentas de Administrador para el desarrollo completo de ese flujo).

Checklist de implementación técnica

  • activeSessions — resolver conectado en user-sessions.resolver.js; SessionsSettingsPage.tsx lo consulta en vivo
  • getCurrentSession — resolver conectado. Corregido en esta sesión: sessions-devices.manager.js#getCurrentSession antes devolvía datos simulados codificados (una sesión falsa de "MacBook Pro") anidados bajo las claves { session, security } que el schema nunca declaró. Ahora realiza una búsqueda real: el resolver pasa context.sessionId — decodificado directamente del JWT de quien llama, en graphql/context/auth-helper.js — al manager, que obtiene esa sesión por ID (limitada al usuario autenticado) y devuelve la forma plana UserSession que espera el schema, con isCurrent siempre en true ya que la búsqueda está indexada por la propia sesión que hace la solicitud. Sigue sin llamarse en ningún lugar de SessionsSettingsPage.tsx
  • revokeSession — resolver conectado; SessionsSettingsPage.tsx lo conecta a un botón real. terminateSession está conectado como un alias genuino (misma llamada subyacente a userManager.revokeSession)
  • revokeAllOtherSessions — resolver conectado; terminateOtherSessions está conectado como alias (misma llamada subyacente a userManager.revokeAllOtherSessions); SessionsSettingsPage.tsx conecta revokeAllOtherSessions a un botón real. Corregido en esta sesión: el manager antes devolvía su conteo como revoked_count, que no coincidía con el nombre del campo sessionsTerminated del schema, por lo que sessionsTerminated siempre resolvía a null y el res?.sessionsTerminated ?? 0 del frontend siempre mostraba "0" incluso cuando se revocaban sesiones. Tanto revokeAllOtherSessions como terminateAllSessions (ver el siguiente elemento) ahora devuelven sessionsTerminated directamente, coincidiendo con el schema.
  • terminateAllSessionsCorregido en esta sesión: antes era simplemente un alias de revokeAllOtherSessions, por lo que, a pesar de su nombre, nunca cerraba realmente la sesión del dispositivo que llamaba — solo revocaba cada sesión distinta de la actual, igual que terminateOtherSessions. userManager.terminateAllSessions ahora es un método genuinamente distinto (sessions-devices.manager.js#terminateAllSessions, respaldado por userSessionAccessService.revokeAllSessions) que termina todas las sesiones del usuario, incluyendo la que hace la solicitud.
  • loginHistory — resolver conectado y SessionsSettingsPage.tsx lo consulta en vivo. Corregido en esta sesión: sessions-devices.manager.js#getLoginHistory antes devolvía { login_history: [...], total, limit, offset } mientras que el schema espera { entries: [...], total, limit, offset } — el desajuste de claves hacía que entries (no-nulo) resolviera a null, por lo que la query lanzaba un error de GraphQL en cada llamada y la interfaz mostraba un panel siempre vacío. Ahora devuelve { entries: [...], total, limit, offset } con los nombres de campo id/userId/loginMethod/deviceName/deviceType/browser/os/ipAddress/location/success/failureReason/createdAt del schema por entrada, obtenidos de filas reales de UserSession. loginMethod/success/failureReason siguen siendo valores de relleno (fijados en 'password'/true/null) — todavía no existe en el código un registro dedicado de intentos de inicio de sesión (solo los inicios de sesión exitosos crean una fila de sesión), por lo que los intentos fallidos y los métodos de autenticación reales no se reportan. Ese es un problema distinto al del desajuste de forma, que se deja tal cual
  • sessionDetails — resolver conectado en user-sessions.resolver.js; devuelve null (no un error) si la sesión no existe o pertenece a otro usuario, por lo que no puede usarse para sondear IDs de sesión válidos. No se llama en ningún lugar de SessionsSettingsPage.tsx
  • adminActiveSessions / adminRevokeSession / adminRevokeAllSessions — resolvers conectados en admin-user.resolver.js; no hay interfaz en apps/frontend-nextjs, pero están conectados a la propia página "Mi cuenta" del administrador en apps/frontend-admin (app/account/page.tsx) — consulta Cuentas de Administrador
  • logoutagregado en esta sesión, junto con una corrección real: revocar una sesión (revokeSession/terminateAllSessions/etc.) antes era pura teatralidad del lado del cliente web. graphql/context/auth-helper.js#verifyTokenAndGetUser solo verificaba la firma/expiración del JWT — nunca consultaba user_session, así que un token revocado/tras cerrar sesión seguía autenticando cada solicitud hasta su expiración natural (hasta 7 días en producción). Ahora también llama a userSessionAccessService.isValid(decoded.sessionId) y rechaza la solicitud si esa sesión está inactiva, revocada, o pasó su expiresAt (se omite para tokens antiguos sin el claim sessionId). logout es una mutación nueva, sin argumentos, de autoservicio, agregada para que un cliente nunca tenga que conocer/pasar su propio id de sesión: revoca context.sessionId (la sesión que hace la propia solicitud) mediante el mismo userManager.revokeSession que usan revokeSession/terminateSession. logout()/logoutAccount()/logoutAll() de AuthContext.tsx ahora la llaman (o, para logoutAccount/logoutAll, un fetch directo con el token propio de esa cuenta guardada específica, ya que el authLink de Apollo siempre envía el token de la cuenta activa) antes de limpiar el estado local — de mejor esfuerzo, así que una revocación sin conexión nunca bloquea el cierre de sesión del lado del cliente. Refleja la corrección equivalente ya publicada del lado de administrador (admin-auth-helper.js/admin-session.manager.js).

Sesiones activas

activeSessions devuelve todas las sesiones actualmente válidas para el usuario autenticado. currentSession es la sesión que realiza la solicitud — útil para etiquetar "Este dispositivo" en la interfaz. La lista completa sessions permite a los usuarios revisar dispositivos desconocidos y cerrarles la sesión.

sessionDetails obtiene los metadatos de una sesión específica por ID — útil en una vista de detalle cuando un usuario toca una fila de sesión.

getCurrentSession es un alias ligero que devuelve solo la sesión que hace la llamada — úsalo para completar la tarjeta "Dispositivo actual" sin obtener la lista completa de sesiones. Ahora realiza una búsqueda real usando el ID de sesión incrustado en el JWT de quien llama (context.sessionId), en lugar de devolver datos simulados — consulta el checklist anterior.

query ActiveSessions {
activeSessions {
total
currentSession { id deviceName deviceType browser os ipAddress location lastActivity }
sessions {
id deviceName deviceType browser os
ipAddress location isCurrent
lastActivity createdAt expiresAt
}
}
}

query SessionDetails($sessionId: ID!) {
sessionDetails(sessionId: $sessionId) { id deviceName deviceType browser os ipAddress location lastActivity }
}

query CurrentSession {
getCurrentSession { id deviceName deviceType browser os ipAddress isCurrent lastActivity }
}

Campos de UserSession

CampoDescripción
deviceNamepor ejemplo, "iPhone 15 Pro"
deviceTypepor ejemplo, "mobile", "desktop"
browserNombre del navegador
osSistema operativo
ipAddressIP al momento del inicio de sesión
locationCiudad/país derivados de la geolocalización
isCurrentVerdadero para la sesión que realiza la llamada
expiresAtCuándo expira el token de la sesión

Terminar sesiones

terminateSession invalida una sola sesión por ID — el dispositivo que usa esa sesión se cerrará en su próxima solicitud a la API.

terminateOtherSessions está implementado en user-sessions.resolver.js como alias directo de revokeAllOtherSessions (misma llamada a userManager.revokeAllOtherSessions) — solo revoca cada sesión distinta de la actual y mantiene viva la sesión actual. terminateAllSessions ahora es una mutación genuinamente distinta (userManager.terminateAllSessions): fiel a su nombre, termina todas las sesiones del usuario, incluyendo la que hace la solicitud.

revokeSession y revokeAllOtherSessions son las mutaciones subyacentes para una sola sesión y para "todas menos la actual"; terminateSession y terminateOtherSessions son alias con la misma semántica que sus equivalentes revoke*, agregados para mayor claridad semántica en flujos de contexto de seguridad. terminateAllSessions no tiene un equivalente revoke* — es la única mutación que cierra la sesión del dispositivo actual junto con todas las demás.

Terminar una sesión fuerza el cierre de sesión en ese dispositivo. El usuario deberá volver a autenticarse.

# Sign out a specific device
mutation TerminateSession($sessionId: ID!) { terminateSession(sessionId: $sessionId) { success sessionsTerminated } }

# Sign out all devices (including this one)
mutation TerminateAllSessions { terminateAllSessions { success sessionsTerminated } }

# Sign out all OTHER devices, keep the current session
mutation TerminateOtherSessions { terminateOtherSessions { success sessionsTerminated } }

mutation RevokeSession($sessionId: ID!) { revokeSession(sessionId: $sessionId) { success } }
mutation RevokeAllOtherSessions { revokeAllOtherSessions { success sessionsTerminated } }

logout — cierre de sesión de autoservicio (sin argumento sessionId)

logout revoca la sesión que hace la propia solicitud, resuelta del lado del servidor a partir de context.sessionId (el claim sessionId del propio JWT) — un cliente nunca pasa un id de sesión, por lo que no puede apuntar a la sesión de alguien más. Esto es lo que realmente hace que "Cerrar sesión" invalide el JWT del lado del servidor; sin esto (antes de esta sesión de trabajo), el token seguía siendo válido hasta su expiración natural sin importar ningún cierre de sesión del lado del cliente.

mutation Logout { logout { success message } }

apps/frontend-nextjs/src/contexts/AuthContext.tsx llama a esto desde logout() antes de limpiar el estado local. Ahora, la firma del JWT de cada solicitud se combina con una verificación de sesión viva (userSessionAccessService.isValid, en graphql/context/auth-helper.js) — consulta el elemento del checklist logout más arriba para ver el antes/después completo.

Historial de inicio de sesión

loginHistory devuelve un registro de auditoría completo de los intentos de inicio de sesión — tanto exitosos como fallidos. loginMethod identifica cómo intentó autenticarse el usuario. Los intentos fallidos incluyen un failureReason (por ejemplo, "invalid_password", "account_suspended"). Úsalo para mostrar una alerta de "Se detectó actividad sospechosa" cuando el usuario ve intentos fallidos que no reconoce.

query LoginHistory($limit: Int, $offset: Int) {
loginHistory(limit: $limit, offset: $offset) {
total limit offset
entries {
id loginMethod
deviceName deviceType browser os
ipAddress location
success failureReason
createdAt
}
}
}

Los valores de loginMethod coinciden con los métodos de autenticación: email, google, apple, phone.

La forma de la respuesta ahora coincide con este schema (consulta el elemento del checklist loginHistory más arriba para ver el error de forma que se corrigió). loginMethod, success y failureReason todavía son valores de relleno hoy ('password', true, null en cada entrada) — todavía no existe un registro de intentos de inicio de sesión fallidos que los respalde, así que el caso de uso de "Actividad sospechosa" mencionado arriba en realidad no puede entregarse hasta que eso se construya.