Auditoría de Sesiones — OnnixConnect
Fecha: 2026-05-04 Stack: Laravel 12, Spatie Activitylog, Livewire Volt Tablas:
user_sessions,users.last_login_ip
Registro histórico de todos los logins del sistema con IP, navegador, duración y razón de cierre. Visible para superadmin (todas las sesiones de todos los users) y admin per-empresa (solo miembros de su empresa).
1. Modelo de datos
Tabla user_sessions
id bigint primary key
user_id bigint NOT NULL → users.id ON DELETE CASCADE
empresa_id bigint NULL → empresas.id ON DELETE SET NULL
ip varchar(45) -- IPv4 o IPv6
user_agent text
login_at timestamp NOT NULL -- indexado
last_activity_at timestamp NOT NULL
logout_at timestamp NULL -- indexado; NULL = sesión activa
duration_seconds unsigned int NULL -- calculado al cerrar
closed_reason enum('logout','timeout','manual') NULL
created_at, updated_at
Índices: (user_id, login_at), (empresa_id, login_at), logout_at.
Columna users.last_login_ip varchar(45) nullable
Denormalizada — última IP de login. Permite mostrar "Última IP" en /usuarios sin join.
Migraciones
database/migrations/2026_05_04_100000_create_user_sessions_table.phpdatabase/migrations/2026_05_04_100100_add_last_login_ip_to_users_table.php
2. Captura de eventos
Laravel 12 no usa EventServiceProvider. Los listeners se registran explícitamente
en app/Providers/AppServiceProvider::boot():
Event::listen(Login::class, RecordUserLogin::class);
Event::listen(Logout::class, RecordUserLogout::class);
Event::listen(Failed::class, RecordFailedLogin::class);
RecordUserLogin (app/Listeners/RecordUserLogin.php)
Al evento Illuminate\Auth\Events\Login:
- Cierra todas las sesiones abiertas previas del mismo user marcándolas con
closed_reason='manual'y duración real. Evita acumular rows abiertas si el user logueó múltiples veces sin logout explícito. - Crea una row nueva en
user_sessionsconlogin_at = now(), IP y UA del request,empresa_id = session('current_empresa_id')si existe. - Guarda
session(['user_session_id' => $id])para que el listener de logout sepa qué row cerrar. - Actualiza
users.last_login_atyusers.last_login_ip(denormalizado).
RecordUserLogout (app/Listeners/RecordUserLogout.php)
Al evento Illuminate\Auth\Events\Logout:
- Lee
session('user_session_id'). Si no existe, sale. - Si la row ya tiene
logout_at(idempotente), sale. - Calcula
duration_seconds = login_at.diffInSeconds(now())y guarda conclosed_reason='logout'.
RecordFailedLogin (app/Listeners/RecordFailedLogin.php)
Al evento Illuminate\Auth\Events\Failed: log al canal default con email/username sanitizado, IP y UA. No crea row en user_sessions. Cumple el requisito de CLAUDE.md de loguear intentos fallidos.
3. Middleware TouchSessionActivity
app/Http/Middleware/TouchSessionActivity.php — montado en el grupo web (bootstrap/app.php). Actualiza user_sessions.last_activity_at con throttle de 5 min por sesión (no por request).
const TOUCH_INTERVAL_SECONDS = 300;
// Si han pasado >=5 min desde el último update (cacheado en sesión como
// last_touch_at), UPDATE user_sessions SET last_activity_at = now()
Costo en escritura mínimo aunque el user navegue intensamente: máximo 1 UPDATE cada 5 min.
4. Job CloseStaleSessionsJob
app/Jobs/CloseStaleSessionsJob.php — corre cada 10 min vía scheduler.
// routes/console.php
Schedule::job(new CloseStaleSessionsJob())->everyTenMinutes();
Cierra sesiones abiertas (logout_at IS NULL) cuyo last_activity_at sea > 15 min vieja:
logout_at = last_activity_atduration_seconds = login_at.diffInSeconds(last_activity_at)closed_reason = 'timeout'
Procesa en chunkById(200) para no cargar BD.
5. UI — Modal Actividad
/usuarios (solo superadmin)
Componente resources/views/components/⚡usuarios-admin.blade.php.
- Columna "Última IP" entre "Último Acceso" y "Estado". Muestra
users.last_login_ipo—. Hidden en mobile (m-hide-md). - Botón pulse (icono
bx-pulse) en cada fila.wire:click="abrirActividad({id})". - Modal con 2 tabs:
- Sesiones (default): tabla con
Inicio | Fin | Duración | IP | Navegador | Empresa | Estado(últimas 30 ordenadas DESC). Badge verde "Activa" si la sesión está abierta. Badges grises paraLogout/Timeout/Manual. - Actividad: lee
Spatie\Activitylog\Models\Activityfiltrado porcauser_type=Userycauser_id=$userId. ColumnasFecha | Acción | Sujeto | Log | Evento(últimas 50).
- Sesiones (default): tabla con
Properties Livewire: $showActividadModal, $actividadUserId, $actividadTab.
Computed: getUsuarioActividadProperty, getSesionesUsuarioProperty, getActividadUsuarioProperty.
Ficha emisor → tab Miembros (admin per-empresa + superadmin)
Componente resources/views/components/⚡emisor-ficha.blade.php + partial resources/views/partials/emisor/miembros.blade.php.
- Botón pulse por miembro.
wire:click="abrirActividadMiembro({id})". - Modal idéntico al de
/usuariospero con scope filtrado por tenant:- Superadmin: ve TODAS las sesiones del user.
- Admin per-empresa: solo ve sesiones donde
empresa_id = empresa_actual.idoNULL(sesiones globales del user).
Helper esSuperadminGlobal() consulta directo a model_has_roles con team_id=0 (bypassa el filtro automático de Spatie teams).
6. Cross-tenant safety
El admin de empresa A no puede ver actividad de un user que solo tiene sesiones en empresa B:
// ⚡emisor-ficha.blade.php — getSesionesMiembroProperty()
if (!$this->esSuperadminGlobal()) {
$empresaId = $this->empresa->id;
$q->where(function ($q) use ($empresaId) {
$q->whereNull('empresa_id')->orWhere('empresa_id', $empresaId);
});
}
El método abrirActividadMiembro también valida que el user objetivo sea efectivamente miembro del emisor antes de abrir el modal:
if (!$this->empresa->users()->where('users.id', $userId)->exists()) {
abort(403, 'Ese usuario no es miembro de este emisor.');
}
7. Helpers del modelo UserSession
app/Models/UserSession.php:
| Método | Devuelve |
|---|---|
scopeOpen() | Sesiones sin logout_at |
scopeClosed() | Sesiones con logout_at |
isOpen(): bool | True si logout_at es null |
browserShort() | "Chrome", "Edge", "Firefox", "Safari", "Opera", "Otro", "—" |
durationHuman() | "8m", "2h 15m", "1d 3h", "En curso", "—" |
8. Privacidad de datos
Cumplimiento de CLAUDE.md:
- ✅ No se loguean contraseñas, tokens ni P12 passwords en ninguna parte del flujo.
- ✅ El
user_agentse trunca a 500 chars al guardar. - ✅ El log de
auth.login.failedtrunca el UA a 200 chars y solo guarda email/username sanitizado. - ✅ La IP se almacena tal cual (varchar 45 soporta IPv6) — necesaria para auditoría/forense.
9. Verificación end-to-end
# 1. Migraciones
php artisan migrate
# → user_sessions y users.last_login_ip creados.
# 2. Login dispara listener
# Hacé login web con admin@onnix.com.py / <password real, pedí al equipo>.
php scripts/check_user_sessions.php
# → última row tiene login_at = ahora, ip = 127.0.0.1, logout_at = NULL.
# 3. Logout cierra row
# Click en "Cerrar sesión".
# → la row se cierra con logout_at, duration_seconds > 0, closed_reason='logout'.
# 4. Job de stale cierra huérfanas
php artisan tinker
>>> dispatch_sync(new App\Jobs\CloseStaleSessionsJob())
# → rows con last_activity_at > 15 min se cierran con closed_reason='timeout'.
# 5. Tests
php artisan test
# → 102 unit tests pasan; ningún regression.
10. Pendiente / follow-up
- Tests Pest: cubrir listeners + middleware + job.
- Modal Actividad: mostrar geolocalización aproximada por IP (MaxMind GeoIP).
- Notificación al user cuando hay login desde IP nueva (alerta Mailable).
- Filtros en el modal: por empresa, por rango de fechas.
Referencias cruzadas
- Seguridad Web — rate limiting, headers, CSRF.
- Configuración del Sistema — toggle "die on tab close".
- Auth Testing — endpoints /usuarios, /emisores, /logout-tab-closed.