feat(sessions): database sessions + list/revoke (other devices, per-user, global) with remember-token rotation
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>feat/v1-foundation
parent
5f6ad27329
commit
0cdc6c3a7b
|
|
@ -26,7 +26,7 @@ DB_PASSWORD=change-me-strong
|
|||
# mariadb container only (compose), not read by Laravel:
|
||||
DB_ROOT_PASSWORD=change-me-strong-root
|
||||
|
||||
# ── Cache / Queue / Session via Redis ────────────────────────────────
|
||||
# ── Cache / Queue via Redis · Session via database ───────────────────
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
|
|
@ -34,7 +34,8 @@ REDIS_PASSWORD=null
|
|||
|
||||
CACHE_STORE=redis
|
||||
QUEUE_CONNECTION=redis
|
||||
SESSION_DRIVER=redis
|
||||
# database = sessions are listable/revocable in Settings (Redis stays for cache/queue)
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
|
|
|
|||
|
|
@ -2,12 +2,84 @@
|
|||
|
||||
namespace App\Livewire\Settings;
|
||||
|
||||
use App\Services\SessionService;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
class Sessions extends Component
|
||||
{
|
||||
/**
|
||||
* Open the R5 confirm for "sign out other devices". The current session is
|
||||
* kept; only the other rows are deleted on confirm. The modal writes the
|
||||
* audit row (session.logout_others) and re-dispatches the apply event.
|
||||
*/
|
||||
public function confirmLogoutOthers(): void
|
||||
{
|
||||
$this->dispatch('openModal',
|
||||
component: 'modals.confirm-action',
|
||||
arguments: [
|
||||
'heading' => __('sessions.logout_others'),
|
||||
'body' => __('sessions.logout_others_body'),
|
||||
'confirmLabel' => __('sessions.logout_others'),
|
||||
'danger' => true,
|
||||
'icon' => 'logout',
|
||||
'auditAction' => 'session.logout_others',
|
||||
'auditTarget' => Auth::user()?->email,
|
||||
'event' => 'sessionsLogoutOthers',
|
||||
'notify' => __('sessions.logout_others_notify'),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[On('sessionsLogoutOthers')]
|
||||
public function logoutOthers(SessionService $sessions): void
|
||||
{
|
||||
$sessions->logoutOtherDevices(Auth::user());
|
||||
// List re-renders on the next round trip; no explicit refresh needed.
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the R5 confirm for the destructive GLOBAL logout. On confirm every
|
||||
* session is wiped and every remember_token rotated — including this
|
||||
* operator's, so the apply handler redirects to login.
|
||||
*/
|
||||
public function confirmLogoutAll(): void
|
||||
{
|
||||
$this->dispatch('openModal',
|
||||
component: 'modals.confirm-action',
|
||||
arguments: [
|
||||
'heading' => __('sessions.logout_all'),
|
||||
'body' => __('sessions.logout_all_body'),
|
||||
'confirmLabel' => __('sessions.logout_all'),
|
||||
'danger' => true,
|
||||
'icon' => 'power',
|
||||
'auditAction' => 'session.logout_all',
|
||||
'auditTarget' => Auth::user()?->email,
|
||||
'event' => 'sessionsLogoutAll',
|
||||
// Defer the toast: this session is about to be destroyed and the
|
||||
// user redirected to login, so a toast would never be seen.
|
||||
'notify' => '',
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[On('sessionsLogoutAll')]
|
||||
public function logoutAll(SessionService $sessions)
|
||||
{
|
||||
// Truncates all sessions + rotates every remember_token. This logs the
|
||||
// operator out too, so push them to login (AuthenticateSession would
|
||||
// otherwise bounce the very next request).
|
||||
$sessions->logoutEveryone();
|
||||
Auth::logout();
|
||||
|
||||
return $this->redirect(route('login'), navigate: true);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.settings.sessions');
|
||||
return view('livewire.settings.sessions', [
|
||||
'sessions' => app(SessionService::class)->forUser(Auth::user()),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Database-session logic for the session-management UI (no Livewire here).
|
||||
*
|
||||
* The `database` session driver makes every login row in the `sessions` table
|
||||
* enumerable per user, so we can list devices and revoke them. Revoking a user
|
||||
* "everywhere" also rotates their `remember_token` — otherwise a stolen
|
||||
* remember-me cookie would silently re-create a session.
|
||||
*/
|
||||
class SessionService
|
||||
{
|
||||
/**
|
||||
* The user's sessions, newest activity first, decorated for the UI.
|
||||
*
|
||||
* Only id/ip/user-agent/last-activity are exposed — never the payload (it
|
||||
* holds the serialized session, including other users' data is impossible
|
||||
* here but the payload still contains CSRF tokens etc.).
|
||||
*
|
||||
* @return array<int, array{id: string, ip: ?string, userAgentShort: string, lastActivity: Carbon, isCurrent: bool}>
|
||||
*/
|
||||
public function forUser(User $user): array
|
||||
{
|
||||
$currentId = Session::getId();
|
||||
|
||||
return DB::table('sessions')
|
||||
->where('user_id', $user->id)
|
||||
->orderByDesc('last_activity')
|
||||
->get(['id', 'ip_address', 'user_agent', 'last_activity'])
|
||||
->map(fn ($row) => [
|
||||
'id' => $row->id,
|
||||
'ip' => $row->ip_address,
|
||||
'userAgentShort' => $this->shortenUserAgent($row->user_agent),
|
||||
'lastActivity' => Carbon::createFromTimestamp((int) $row->last_activity),
|
||||
'isCurrent' => $row->id === $currentId,
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign the user out of every device EXCEPT the current one. The current
|
||||
* session stays valid (nothing rotated for self), so the operator keeps
|
||||
* working uninterrupted.
|
||||
*/
|
||||
public function logoutOtherDevices(User $user): void
|
||||
{
|
||||
DB::table('sessions')
|
||||
->where('user_id', $user->id)
|
||||
->where('id', '!=', Session::getId())
|
||||
->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill ALL of this user's sessions and rotate their remember_token, so a
|
||||
* stolen remember-me cookie can no longer resurrect a session. Used by the
|
||||
* (separate) multi-user admin feature; not wired to a per-row button here.
|
||||
*/
|
||||
public function logoutUserEverywhere(User $user): void
|
||||
{
|
||||
DB::table('sessions')->where('user_id', $user->id)->delete();
|
||||
|
||||
$user->forceFill(['remember_token' => Str::random(60)])->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Global logout: truncate every session and rotate every user's
|
||||
* remember_token. Destructive — logs out the current operator too. The
|
||||
* caller (Livewire) is responsible for the R5 confirm, audit, and the
|
||||
* redirect to login that must follow.
|
||||
*/
|
||||
public function logoutEveryone(): void
|
||||
{
|
||||
DB::table('sessions')->truncate();
|
||||
|
||||
User::query()->eachById(function (User $user) {
|
||||
$user->forceFill(['remember_token' => Str::random(60)])->save();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse a raw User-Agent header into a short "Browser · OS" label for the
|
||||
* list. Best-effort string sniffing (no extra dependency); falls back to
|
||||
* "unknown" copy when the header is empty or unrecognized.
|
||||
*/
|
||||
private function shortenUserAgent(?string $userAgent): string
|
||||
{
|
||||
if ($userAgent === null || trim($userAgent) === '') {
|
||||
return __('sessions.ua_unknown');
|
||||
}
|
||||
|
||||
$browser = match (true) {
|
||||
str_contains($userAgent, 'Edg/') => 'Edge',
|
||||
str_contains($userAgent, 'OPR/') || str_contains($userAgent, 'Opera') => 'Opera',
|
||||
str_contains($userAgent, 'Firefox') => 'Firefox',
|
||||
str_contains($userAgent, 'Chrome') => 'Chrome',
|
||||
str_contains($userAgent, 'Safari') => 'Safari',
|
||||
default => null,
|
||||
};
|
||||
|
||||
$os = match (true) {
|
||||
str_contains($userAgent, 'Windows') => 'Windows',
|
||||
str_contains($userAgent, 'Android') => 'Android',
|
||||
str_contains($userAgent, 'iPhone') || str_contains($userAgent, 'iPad') => 'iOS',
|
||||
str_contains($userAgent, 'Mac OS') || str_contains($userAgent, 'Macintosh') => 'macOS',
|
||||
str_contains($userAgent, 'Linux') => 'Linux',
|
||||
default => null,
|
||||
};
|
||||
|
||||
$label = trim(implode(' · ', array_filter([$browser, $os])));
|
||||
|
||||
// Nothing matched → show a trimmed prefix of the raw header so the row is
|
||||
// still distinguishable, rather than a useless "unknown".
|
||||
return $label !== '' ? $label : Str::limit($userAgent, 40);
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ use Illuminate\Foundation\Application;
|
|||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Session\Middleware\AuthenticateSession;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
|
|
@ -32,10 +33,13 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||
|
||||
// PanelScheme first (scheme/host enforcement before auth), so an HTTP request to
|
||||
// the active domain is sent straight to HTTPS and a wrong host is refused before
|
||||
// any auth redirect.
|
||||
// any auth redirect. AuthenticateSession ties each request to the password hash that
|
||||
// was current when the session started, so a password change (or a remember-token
|
||||
// rotation in the session-management actions) invalidates every OTHER session — the
|
||||
// database session driver makes those sessions enumerable/revocable in Settings.
|
||||
$middleware->web(
|
||||
prepend: [PanelScheme::class],
|
||||
append: [SetLocale::class, SecurityHeaders::class],
|
||||
append: [SetLocale::class, SecurityHeaders::class, AuthenticateSession::class],
|
||||
);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
// Session-management page strings (R16). Lists the user's active logins and the
|
||||
// revoke actions. Shared buttons live in common.php.
|
||||
return [
|
||||
// Panel header
|
||||
'title' => 'Sitzungen',
|
||||
'subtitle' => 'Aktive Anmeldungen · Geräte abmelden',
|
||||
|
||||
// List
|
||||
'this_device' => 'Dieses Gerät',
|
||||
'last_active' => 'zuletzt aktiv :time',
|
||||
'ua_unknown' => 'Unbekanntes Gerät',
|
||||
'none' => 'Keine aktiven Sitzungen.',
|
||||
'hint' => 'Meldet überall ab, wo du angemeldet bist.',
|
||||
|
||||
// Actions
|
||||
'logout_others' => 'Andere Geräte abmelden',
|
||||
'logout_others_body' => 'Alle anderen Sitzungen werden beendet. Dieses Gerät bleibt angemeldet.',
|
||||
'logout_others_notify' => 'Andere Geräte abgemeldet.',
|
||||
|
||||
'logout_all' => 'Alle abmelden (global)',
|
||||
'logout_all_body' => 'Beendet ALLE Sitzungen aller Benutzer und macht „Angemeldet bleiben“-Cookies ungültig. Du wirst ebenfalls abgemeldet.',
|
||||
];
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
// Session-management page strings (R16). Lists the user's active logins and the
|
||||
// revoke actions. Shared buttons live in common.php.
|
||||
return [
|
||||
// Panel header
|
||||
'title' => 'Sessions',
|
||||
'subtitle' => 'Active sign-ins · sign out devices',
|
||||
|
||||
// List
|
||||
'this_device' => 'This device',
|
||||
'last_active' => 'last active :time',
|
||||
'ua_unknown' => 'Unknown device',
|
||||
'none' => 'No active sessions.',
|
||||
'hint' => 'Signs you out everywhere you are signed in.',
|
||||
|
||||
// Actions
|
||||
'logout_others' => 'Sign out other devices',
|
||||
'logout_others_body' => 'All other sessions will be ended. This device stays signed in.',
|
||||
'logout_others_notify' => 'Other devices signed out.',
|
||||
|
||||
'logout_all' => 'Sign out all (global)',
|
||||
'logout_all_body' => 'Ends ALL sessions of all users and invalidates remember-me cookies. You will be signed out too.',
|
||||
];
|
||||
|
|
@ -1,5 +1,45 @@
|
|||
<div class="space-y-5">
|
||||
<x-panel :title="__('settings.sessions_title')" :subtitle="__('settings.sessions_subtitle')">
|
||||
<p class="font-mono text-[11px] text-ink-4">{{ __('settings.coming_soon') }}</p>
|
||||
<x-panel :title="__('sessions.title')" :subtitle="__('sessions.subtitle')">
|
||||
<div class="space-y-4">
|
||||
<div class="divide-y divide-line">
|
||||
@forelse ($sessions as $session)
|
||||
<div class="flex items-center gap-3 py-3">
|
||||
<span @class([
|
||||
'grid h-9 w-9 shrink-0 place-items-center rounded-md border',
|
||||
'border-accent/30 bg-accent/10 text-accent-text' => $session['isCurrent'],
|
||||
'border-line bg-inset text-ink-3' => ! $session['isCurrent'],
|
||||
])>
|
||||
<x-icon name="logout" class="h-4 w-4" />
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="truncate text-sm text-ink">{{ $session['userAgentShort'] }}</p>
|
||||
@if ($session['isCurrent'])
|
||||
<x-badge tone="accent">{{ __('sessions.this_device') }}</x-badge>
|
||||
@endif
|
||||
</div>
|
||||
<p class="truncate font-mono text-[11px] text-ink-3">
|
||||
{{ $session['ip'] ?? __('common.none') }}
|
||||
· {{ __('sessions.last_active', ['time' => $session['lastActivity']->diffForHumans()]) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<p class="py-3 font-mono text-[11px] text-ink-4">{{ __('sessions.none') }}</p>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 border-t border-line pt-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p class="font-mono text-[11px] text-ink-4">{{ __('sessions.hint') }}</p>
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<x-btn variant="danger-soft" class="shrink-0" wire:click="confirmLogoutOthers">
|
||||
<x-icon name="logout" class="h-3.5 w-3.5" /> {{ __('sessions.logout_others') }}
|
||||
</x-btn>
|
||||
<x-btn variant="danger" class="shrink-0" wire:click="confirmLogoutAll">
|
||||
<x-icon name="power" class="h-3.5 w-3.5" /> {{ __('sessions.logout_all') }}
|
||||
</x-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-panel>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,174 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Livewire\Modals\ConfirmAction;
|
||||
use App\Livewire\Settings\Sessions;
|
||||
use App\Models\AuditEvent;
|
||||
use App\Models\User;
|
||||
use App\Services\SessionService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SessionManagementTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// phpunit.xml pins SESSION_DRIVER=array for the suite; the session-management
|
||||
// feature only exists with the database driver (rows in the `sessions` table).
|
||||
config(['session.driver' => 'database']);
|
||||
}
|
||||
|
||||
/** Insert a raw session row for $user; returns the generated id. */
|
||||
private function seedSession(User $user, string $id, ?string $ip = '203.0.113.1', ?string $ua = 'Mozilla/5.0 Chrome', int $ago = 0): string
|
||||
{
|
||||
DB::table('sessions')->insert([
|
||||
'id' => $id,
|
||||
'user_id' => $user->id,
|
||||
'ip_address' => $ip,
|
||||
'user_agent' => $ua,
|
||||
'payload' => base64_encode(serialize([])),
|
||||
'last_activity' => now()->subSeconds($ago)->getTimestamp(),
|
||||
]);
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
public function test_for_user_returns_rows_with_current_flagged(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$other = User::factory()->create();
|
||||
|
||||
$currentId = Session::getId();
|
||||
$this->seedSession($user, $currentId, ago: 0);
|
||||
$this->seedSession($user, 'sess-old', ago: 600);
|
||||
$this->seedSession($other, 'sess-other'); // must NOT appear
|
||||
|
||||
$rows = app(SessionService::class)->forUser($user);
|
||||
|
||||
$this->assertCount(2, $rows);
|
||||
// Newest activity first.
|
||||
$this->assertSame($currentId, $rows[0]['id']);
|
||||
$this->assertTrue($rows[0]['isCurrent']);
|
||||
$this->assertSame('sess-old', $rows[1]['id']);
|
||||
$this->assertFalse($rows[1]['isCurrent']);
|
||||
$this->assertSame('203.0.113.1', $rows[0]['ip']);
|
||||
$this->assertStringContainsString('Chrome', $rows[0]['userAgentShort']);
|
||||
$this->assertTrue($rows[0]['lastActivity']->isToday());
|
||||
}
|
||||
|
||||
public function test_logout_other_devices_deletes_others_keeps_current(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$currentId = Session::getId();
|
||||
$this->seedSession($user, $currentId);
|
||||
$this->seedSession($user, 'sess-b');
|
||||
$this->seedSession($user, 'sess-c');
|
||||
|
||||
app(SessionService::class)->logoutOtherDevices($user);
|
||||
|
||||
$this->assertDatabaseHas('sessions', ['id' => $currentId]);
|
||||
$this->assertDatabaseMissing('sessions', ['id' => 'sess-b']);
|
||||
$this->assertDatabaseMissing('sessions', ['id' => 'sess-c']);
|
||||
}
|
||||
|
||||
public function test_logout_user_everywhere_deletes_all_and_rotates_token(): void
|
||||
{
|
||||
$user = User::factory()->create(['remember_token' => 'original-token']);
|
||||
$this->seedSession($user, Session::getId());
|
||||
$this->seedSession($user, 'sess-b');
|
||||
|
||||
app(SessionService::class)->logoutUserEverywhere($user);
|
||||
|
||||
$this->assertSame(0, DB::table('sessions')->where('user_id', $user->id)->count());
|
||||
$this->assertNotSame('original-token', $user->fresh()->remember_token);
|
||||
$this->assertNotNull($user->fresh()->remember_token);
|
||||
}
|
||||
|
||||
public function test_logout_everyone_truncates_and_rotates_all_tokens(): void
|
||||
{
|
||||
$a = User::factory()->create(['remember_token' => 'token-a']);
|
||||
$b = User::factory()->create(['remember_token' => 'token-b']);
|
||||
$this->seedSession($a, 'sess-a');
|
||||
$this->seedSession($b, 'sess-b');
|
||||
|
||||
app(SessionService::class)->logoutEveryone();
|
||||
|
||||
$this->assertSame(0, DB::table('sessions')->count());
|
||||
$this->assertNotSame('token-a', $a->fresh()->remember_token);
|
||||
$this->assertNotSame('token-b', $b->fresh()->remember_token);
|
||||
}
|
||||
|
||||
public function test_component_lists_the_users_sessions(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->seedSession($user, Session::getId());
|
||||
|
||||
Livewire::actingAs($user)->test(Sessions::class)
|
||||
->assertOk()
|
||||
->assertViewHas('sessions', fn ($sessions) => count($sessions) === 1);
|
||||
}
|
||||
|
||||
public function test_logout_others_action_confirms_then_deletes(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$current = Session::getId();
|
||||
$this->seedSession($user, $current);
|
||||
$this->seedSession($user, 'sess-other');
|
||||
|
||||
// The button opens the R5 confirm modal carrying the apply event + audit descriptor.
|
||||
Livewire::actingAs($user)->test(Sessions::class)
|
||||
->call('confirmLogoutOthers')
|
||||
->assertDispatched('openModal');
|
||||
|
||||
// The confirm modal re-dispatches the apply event; simulate that.
|
||||
Livewire::actingAs($user)->test(Sessions::class)
|
||||
->call('logoutOthers');
|
||||
|
||||
$this->assertDatabaseHas('sessions', ['id' => $current]);
|
||||
$this->assertDatabaseMissing('sessions', ['id' => 'sess-other']);
|
||||
}
|
||||
|
||||
public function test_logout_all_action_truncates_and_redirects_to_login(): void
|
||||
{
|
||||
$user = User::factory()->create(['remember_token' => 'tok']);
|
||||
$this->seedSession($user, Session::getId());
|
||||
|
||||
Livewire::actingAs($user)->test(Sessions::class)
|
||||
->call('confirmLogoutAll')
|
||||
->assertDispatched('openModal');
|
||||
|
||||
Livewire::actingAs($user)->test(Sessions::class)
|
||||
->call('logoutAll')
|
||||
->assertRedirect(route('login'));
|
||||
|
||||
$this->assertSame(0, DB::table('sessions')->count());
|
||||
$this->assertNotSame('tok', $user->fresh()->remember_token);
|
||||
}
|
||||
|
||||
public function test_confirm_modal_writes_audit_row_on_confirm(): void
|
||||
{
|
||||
// The generic confirm modal persists the audit row from the descriptor the
|
||||
// Sessions component passes (mirrors Settings\Security). Exercise it directly.
|
||||
$user = User::factory()->create();
|
||||
|
||||
Livewire::actingAs($user)->test(ConfirmAction::class, [
|
||||
'auditAction' => 'session.logout_others',
|
||||
'auditTarget' => $user->email,
|
||||
'event' => 'sessionsLogoutOthers',
|
||||
])->call('confirm');
|
||||
|
||||
$this->assertDatabaseHas('audit_events', [
|
||||
'action' => 'session.logout_others',
|
||||
'target' => $user->email,
|
||||
]);
|
||||
$this->assertSame(1, AuditEvent::where('action', 'session.logout_others')->count());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue