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
apps/backend/graphql/resolvers/user-sessions.resolver.js— implementaactiveSessions,sessionDetails,loginHistory,getCurrentSession,revokeSession,revokeAllOtherSessions, y los aliasterminateSession/terminateAllSessions/terminateOtherSessions(movido aquí desde el antiguouser.resolver.jsmonolítico, que ya no existe — fue dividido en archivos de resolver por área de responsabilidad). Todos los campos de sesión declarados en el schema ahora tienen un resolver conectado, incluyendosessionDetailsy los aliasterminate*apps/backend/graphql/resolvers/admin-user.resolver.js— gestión de sesiones del lado de administración:adminActiveSessions,adminRevokeSession,adminRevokeAllSessionsapps/backend/graphql/types/sessions.type.js— definiciones del esquemaapps/backend/managers/user-managers/sessions-devices.manager.js— lógica de negocio de listado/revocación de sesionesapps/backend/managers/admin-managers/admin-session.manager.js— lógica de gestión de sesiones de administradorapps/backend/data-access-services/user/user-session.access-service.jsyadmin/admin-session.access-service.js— acceso a la base de datos de sesiones (ambos movidos a las subcarpetasuser//admin/como parte de una reorganización de los access-services en todo el repositorio)
Frontend
apps/frontend-nextjs/src/page-components/settings/SessionsSettingsPage.tsx(rutaapp/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/mutationsgqlen 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 aadminActiveSessions/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 enuser-sessions.resolver.js;SessionsSettingsPage.tsxlo consulta en vivo -
getCurrentSession— resolver conectado. Corregido en esta sesión:sessions-devices.manager.js#getCurrentSessionantes 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 pasacontext.sessionId— decodificado directamente del JWT de quien llama, engraphql/context/auth-helper.js— al manager, que obtiene esa sesión por ID (limitada al usuario autenticado) y devuelve la forma planaUserSessionque espera el schema, conisCurrentsiempre entrueya que la búsqueda está indexada por la propia sesión que hace la solicitud. Sigue sin llamarse en ningún lugar deSessionsSettingsPage.tsx -
revokeSession— resolver conectado;SessionsSettingsPage.tsxlo conecta a un botón real.terminateSessionestá conectado como un alias genuino (misma llamada subyacente auserManager.revokeSession) -
revokeAllOtherSessions— resolver conectado;terminateOtherSessionsestá conectado como alias (misma llamada subyacente auserManager.revokeAllOtherSessions);SessionsSettingsPage.tsxconectarevokeAllOtherSessionsa un botón real. Corregido en esta sesión: el manager antes devolvía su conteo comorevoked_count, que no coincidía con el nombre del camposessionsTerminateddel schema, por lo quesessionsTerminatedsiempre resolvía anully elres?.sessionsTerminated ?? 0del frontend siempre mostraba "0" incluso cuando se revocaban sesiones. TantorevokeAllOtherSessionscomoterminateAllSessions(ver el siguiente elemento) ahora devuelvensessionsTerminateddirectamente, coincidiendo con el schema. -
terminateAllSessions— Corregido en esta sesión: antes era simplemente un alias derevokeAllOtherSessions, 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 queterminateOtherSessions.userManager.terminateAllSessionsahora es un método genuinamente distinto (sessions-devices.manager.js#terminateAllSessions, respaldado poruserSessionAccessService.revokeAllSessions) que termina todas las sesiones del usuario, incluyendo la que hace la solicitud. -
loginHistory— resolver conectado ySessionsSettingsPage.tsxlo consulta en vivo. Corregido en esta sesión:sessions-devices.manager.js#getLoginHistoryantes devolvía{ login_history: [...], total, limit, offset }mientras que el schema espera{ entries: [...], total, limit, offset }— el desajuste de claves hacía queentries(no-nulo) resolviera anull, 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 campoid/userId/loginMethod/deviceName/deviceType/browser/os/ipAddress/location/success/failureReason/createdAtdel schema por entrada, obtenidos de filas reales deUserSession.loginMethod/success/failureReasonsiguen 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 enuser-sessions.resolver.js; devuelvenull(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 deSessionsSettingsPage.tsx -
adminActiveSessions/adminRevokeSession/adminRevokeAllSessions— resolvers conectados enadmin-user.resolver.js; no hay interfaz enapps/frontend-nextjs, pero están conectados a la propia página "Mi cuenta" del administrador enapps/frontend-admin(app/account/page.tsx) — consulta Cuentas de Administrador -
logout— agregado 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#verifyTokenAndGetUsersolo verificaba la firma/expiración del JWT — nunca consultabauser_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 auserSessionAccessService.isValid(decoded.sessionId)y rechaza la solicitud si esa sesión está inactiva, revocada, o pasó suexpiresAt(se omite para tokens antiguos sin el claimsessionId).logoutes una mutación nueva, sin argumentos, de autoservicio, agregada para que un cliente nunca tenga que conocer/pasar su propio id de sesión: revocacontext.sessionId(la sesión que hace la propia solicitud) mediante el mismouserManager.revokeSessionque usanrevokeSession/terminateSession.logout()/logoutAccount()/logoutAll()deAuthContext.tsxahora la llaman (o, paralogoutAccount/logoutAll, unfetchdirecto 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
| Campo | Descripción |
|---|---|
deviceName | por ejemplo, "iPhone 15 Pro" |
deviceType | por ejemplo, "mobile", "desktop" |
browser | Nombre del navegador |
os | Sistema operativo |
ipAddress | IP al momento del inicio de sesión |
location | Ciudad/país derivados de la geolocalización |
isCurrent | Verdadero para la sesión que realiza la llamada |
expiresAt | Cuá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
loginHistorymás arriba para ver el error de forma que se corrigió).loginMethod,successyfailureReasontodavía son valores de relleno hoy ('password',true,nullen 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.