Saltar al contenido principal

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.php
  • database/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:

  1. 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.
  2. Crea una row nueva en user_sessions con login_at = now(), IP y UA del request, empresa_id = session('current_empresa_id') si existe.
  3. Guarda session(['user_session_id' => $id]) para que el listener de logout sepa qué row cerrar.
  4. Actualiza users.last_login_at y users.last_login_ip (denormalizado).

RecordUserLogout (app/Listeners/RecordUserLogout.php)

Al evento Illuminate\Auth\Events\Logout:

  1. Lee session('user_session_id'). Si no existe, sale.
  2. Si la row ya tiene logout_at (idempotente), sale.
  3. Calcula duration_seconds = login_at.diffInSeconds(now()) y guarda con closed_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_at
  • duration_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_ip o . 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 para Logout/Timeout/Manual.
    • Actividad: lee Spatie\Activitylog\Models\Activity filtrado por causer_type=User y causer_id=$userId. Columnas Fecha | Acción | Sujeto | Log | Evento (últimas 50).

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 /usuarios pero con scope filtrado por tenant:
    • Superadmin: ve TODAS las sesiones del user.
    • Admin per-empresa: solo ve sesiones donde empresa_id = empresa_actual.id o NULL (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étodoDevuelve
scopeOpen()Sesiones sin logout_at
scopeClosed()Sesiones con logout_at
isOpen(): boolTrue 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_agent se trunca a 500 chars al guardar.
  • ✅ El log de auth.login.failed trunca 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