From 07141a96bf819eba5cced3a10886617cbeb0ccb9 Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 28 Jul 2026 23:38:17 +0200 Subject: [PATCH] Show where an account is signed in, and let it sign the other places out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the device work: the warning mail now has somewhere to send people. A warning whose only advice is "if this was not you, take action" names a problem and hands back nothing to do about it. Both sides get their own component rather than one shared with the guard passed in. Operators and customers are two guards and two tables (R21) — a component taking the guard as input is one forged property away from listing and ending the other side's sessions. Every query in SessionRegistry is scoped by guard as well as by id for the same reason, and three tests hold that line, because the unscoped version looks exactly like a working feature while doing it. TouchLoginSession keeps each row pointing at the session it describes. Both sign-in paths raise the Login event BEFORE session()->regenerate(), so the row is written against an id that is dead a moment later — and the once-a-minute throttle would have held the correction back for the first minute of every session, hiding the session its reader is sitting in. A changed id is therefore never throttled. A session counts as live only if the framework's row exists AND was touched within the session lifetime. The join alone is not enough: Laravel collects expired sessions by lottery, so on a quiet installation rows sit there for days and the list would offer somebody a laptop they shut last month. Ending the others goes through the product's own modal (R23) and leaves the asking session alone: signing somebody out of the page they are using to sign other people out is not what the button says. Co-Authored-By: Claude Opus 5 --- app/Http/Middleware/TouchLoginSession.php | 74 +++++++++ .../Admin/ConfirmEndOtherSessions.php | 24 +++ app/Livewire/Admin/Sessions.php | 62 ++++++++ app/Livewire/ConfirmEndOtherSessions.php | 25 ++++ app/Livewire/Sessions.php | 53 +++++++ app/Services/Devices/SessionRegistry.php | 120 +++++++++++++++ bootstrap/app.php | 7 + lang/de/sessions.php | 19 +++ lang/en/sessions.php | 19 +++ .../confirm-end-other-sessions.blade.php | 17 +++ .../views/livewire/admin/settings.blade.php | 7 + .../confirm-end-other-sessions.blade.php | 17 +++ resources/views/livewire/sessions.blade.php | 58 +++++++ resources/views/livewire/settings.blade.php | 6 + tests/Feature/SessionListTest.php | 141 ++++++++++++++++++ 15 files changed, 649 insertions(+) create mode 100644 app/Http/Middleware/TouchLoginSession.php create mode 100644 app/Livewire/Admin/ConfirmEndOtherSessions.php create mode 100644 app/Livewire/Admin/Sessions.php create mode 100644 app/Livewire/ConfirmEndOtherSessions.php create mode 100644 app/Livewire/Sessions.php create mode 100644 app/Services/Devices/SessionRegistry.php create mode 100644 lang/de/sessions.php create mode 100644 lang/en/sessions.php create mode 100644 resources/views/livewire/admin/confirm-end-other-sessions.blade.php create mode 100644 resources/views/livewire/confirm-end-other-sessions.blade.php create mode 100644 resources/views/livewire/sessions.blade.php create mode 100644 tests/Feature/SessionListTest.php diff --git a/app/Http/Middleware/TouchLoginSession.php b/app/Http/Middleware/TouchLoginSession.php new file mode 100644 index 0000000..7b5711f --- /dev/null +++ b/app/Http/Middleware/TouchLoginSession.php @@ -0,0 +1,74 @@ +hasSession()) { + return $response; + } + + $uuid = $request->session()->get(LoginSession::KEY); + + if (! is_string($uuid) || $uuid === '') { + return $response; + } + + $current = $request->session()->getId(); + + // A bare update rather than a model round-trip: this is on the path of + // every authenticated request, and the row is not needed, only moved. + DB::table('login_sessions') + ->where('uuid', $uuid) + ->where(function ($q) use ($current) { + // A CHANGED id is never throttled. Both sign-in paths raise the + // Login event before calling session()->regenerate(), so the row + // is written against an id that is dead a moment later. Held + // back by the throttle, the row would point at nothing for the + // first minute of every session — the one it describes missing + // from the list, and "end this one" deleting a session that no + // longer exists. + $q->where('session_id', '!=', $current) + ->orWhereNull('session_id') + ->orWhereNull('last_seen_at') + ->orWhere('last_seen_at', '<=', now()->subSeconds(self::THROTTLE_SECONDS)); + }) + ->update([ + 'session_id' => $current, + 'ip_address' => $request->ip(), + 'last_seen_at' => now(), + 'updated_at' => now(), + ]); + + return $response; + } +} diff --git a/app/Livewire/Admin/ConfirmEndOtherSessions.php b/app/Livewire/Admin/ConfirmEndOtherSessions.php new file mode 100644 index 0000000..d44e8a2 --- /dev/null +++ b/app/Livewire/Admin/ConfirmEndOtherSessions.php @@ -0,0 +1,24 @@ +dispatch('sessions-end-others-confirmed'); + $this->closeModal(); + } + + public function render() + { + return view('livewire.admin.confirm-end-other-sessions'); + } +} diff --git a/app/Livewire/Admin/Sessions.php b/app/Livewire/Admin/Sessions.php new file mode 100644 index 0000000..d999389 --- /dev/null +++ b/app/Livewire/Admin/Sessions.php @@ -0,0 +1,62 @@ +currentOperator()) { + return; + } + + app(SessionRegistry::class)->end(self::GUARD, (int) $operator->getKey(), $uuid); + + $this->dispatch('notify', message: __('sessions.ended')); + } + + /** Raised by the confirmation modal (R23), never called from the markup. */ + #[On('sessions-end-others-confirmed')] + public function endOthers(): void + { + if (! $operator = $this->currentOperator()) { + return; + } + + $registry = app(SessionRegistry::class); + $count = $registry->endOthers(self::GUARD, (int) $operator->getKey(), $registry->currentUuid()); + + $this->dispatch('notify', message: __('sessions.ended_others', ['n' => $count])); + } + + public function render() + { + $operator = $this->currentOperator(); + $registry = app(SessionRegistry::class); + $current = $registry->currentUuid(); + + return view('livewire.sessions', [ + 'sessions' => $operator + ? $registry->for(self::GUARD, (int) $operator->getKey(), $current) + : collect(), + 'confirmComponent' => 'admin.confirm-end-other-sessions', + ]); + } +} diff --git a/app/Livewire/ConfirmEndOtherSessions.php b/app/Livewire/ConfirmEndOtherSessions.php new file mode 100644 index 0000000..e81a738 --- /dev/null +++ b/app/Livewire/ConfirmEndOtherSessions.php @@ -0,0 +1,25 @@ +dispatch('sessions-end-others-confirmed'); + $this->closeModal(); + } + + public function render() + { + return view('livewire.confirm-end-other-sessions'); + } +} diff --git a/app/Livewire/Sessions.php b/app/Livewire/Sessions.php new file mode 100644 index 0000000..85280f5 --- /dev/null +++ b/app/Livewire/Sessions.php @@ -0,0 +1,53 @@ +end(self::GUARD, (int) auth()->id(), $uuid); + + $this->dispatch('notify', message: __('sessions.ended')); + } + + /** + * Raised by the confirmation modal (R23), never called from the markup. + * The check stays here, at the one place that already had it. + */ + #[On('sessions-end-others-confirmed')] + public function endOthers(): void + { + $registry = app(SessionRegistry::class); + $count = $registry->endOthers(self::GUARD, (int) auth()->id(), $registry->currentUuid()); + + $this->dispatch('notify', message: __('sessions.ended_others', ['n' => $count])); + } + + public function render() + { + $registry = app(SessionRegistry::class); + $current = $registry->currentUuid(); + + return view('livewire.sessions', [ + 'sessions' => $registry->for(self::GUARD, (int) auth()->id(), $current), + 'confirmComponent' => 'confirm-end-other-sessions', + ]); + } +} diff --git a/app/Services/Devices/SessionRegistry.php b/app/Services/Devices/SessionRegistry.php new file mode 100644 index 0000000..3d334d0 --- /dev/null +++ b/app/Services/Devices/SessionRegistry.php @@ -0,0 +1,120 @@ + + */ + public function for(string $guard, int $id, ?string $currentUuid = null): Collection + { + return DB::table('login_sessions as ls') + ->join('user_devices as d', 'd.id', '=', 'ls.device_id') + ->join('sessions as s', 's.id', '=', 'ls.session_id') + ->where('d.guard', $guard) + ->where('d.authenticatable_id', $id) + ->where('s.last_activity', '>=', now()->subMinutes((int) config('session.lifetime'))->getTimestamp()) + ->orderByDesc('ls.last_seen_at') + ->get([ + 'ls.uuid as uuid', + 'ls.ip_address as ip_address', + 'ls.last_seen_at as last_seen_at', + 'd.name as device', + 'd.uuid as device_uuid', + ]) + ->map(function (object $row) use ($currentUuid) { + $row->is_current = $currentUuid !== null && $row->uuid === $currentUuid; + $row->last_seen_at = $row->last_seen_at ? \Illuminate\Support\Carbon::parse($row->last_seen_at) : null; + + return $row; + }); + } + + /** End one session. Returns false when it is not this identity's to end. */ + public function end(string $guard, int $id, string $uuid): bool + { + $row = DB::table('login_sessions as ls') + ->join('user_devices as d', 'd.id', '=', 'ls.device_id') + ->where('d.guard', $guard) + ->where('d.authenticatable_id', $id) + ->where('ls.uuid', $uuid) + ->first(['ls.id as id', 'ls.session_id as session_id']); + + if ($row === null) { + return false; + } + + $this->forget($row->session_id); + DB::table('login_sessions')->where('id', $row->id)->delete(); + + return true; + } + + /** + * End everything except the session asking. + * + * The current one is excluded rather than ended and re-created: signing + * somebody out of the page they are using to sign other people out is not + * what the button says, and it leaves them unable to see whether it worked. + * + * @return int how many were ended + */ + public function endOthers(string $guard, int $id, ?string $currentUuid): int + { + $rows = DB::table('login_sessions as ls') + ->join('user_devices as d', 'd.id', '=', 'ls.device_id') + ->where('d.guard', $guard) + ->where('d.authenticatable_id', $id) + ->when($currentUuid !== null, fn ($q) => $q->where('ls.uuid', '!=', $currentUuid)) + ->get(['ls.id as id', 'ls.session_id as session_id']); + + foreach ($rows as $row) { + $this->forget($row->session_id); + } + + DB::table('login_sessions')->whereIn('id', $rows->pluck('id'))->delete(); + + return $rows->count(); + } + + /** The uuid of the session making this request, if it has one. */ + public function currentUuid(): ?string + { + $uuid = session()->get(LoginSession::KEY); + + return is_string($uuid) && $uuid !== '' ? $uuid : null; + } + + /** + * Delete the framework's own row, which is what actually signs the browser + * out: its next request finds no session and starts an empty one. + */ + private function forget(?string $sessionId): void + { + if ($sessionId !== null && $sessionId !== '') { + DB::table('sessions')->where('id', $sessionId)->delete(); + } + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 1bee4ce..5d5d787 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -6,6 +6,7 @@ use App\Http\Middleware\PublicSiteGate; use App\Http\Middleware\RequireOperatorTwoFactor; use App\Http\Middleware\RestrictAdminHost; use App\Http\Middleware\RestrictConsoleNetwork; +use App\Http\Middleware\TouchLoginSession; use App\Support\AdminArea; use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; @@ -41,6 +42,12 @@ return Application::configure(basePath: dirname(__DIR__)) // the public site is switched off. $middleware->appendToGroup('web', PublicSiteGate::class); + // Keeps each login_sessions row pointing at the session it describes. + // Laravel rotates the session id on sign-in and on every regenerate(), + // so a row written once stops matching almost immediately — and the + // list would hide the session its reader is sitting in. + $middleware->appendToGroup('web', TouchLoginSession::class); + // TLS is terminated by the reverse proxy (Zoraxy on the private LAN), so // without this Laravel sees plain http and builds http:// URLs into an // https page — every asset and redirect breaks as mixed content. diff --git a/lang/de/sessions.php b/lang/de/sessions.php new file mode 100644 index 0000000..54b584d --- /dev/null +++ b/lang/de/sessions.php @@ -0,0 +1,19 @@ + 'Angemeldete Geräte', + 'subtitle' => 'Jede offene Anmeldung, mit dem Gerät und wann sie zuletzt aktiv war.', + 'empty' => 'Nur diese Sitzung — sonst ist nichts offen.', + 'this_device' => 'Dieses Gerät', + 'last_seen' => 'zuletzt :when', + 'end' => 'Beenden', + 'ended' => 'Sitzung beendet.', + // Der Sammelknopf geht durch ein Modal (R23), der einzelne nicht: eine + // Zeile, ein Klick, ein Gerät — und wer sich vertut, meldet sich dort neu an. + 'end_others' => 'Alle anderen abmelden', + 'end_others_title' => 'Alle anderen Geräte abmelden?', + 'end_others_body' => 'Diese Sitzung bleibt offen. Auf allen anderen Geräten muss man sich neu anmelden.', + 'end_others_confirm' => 'Alle anderen abmelden', + 'ended_others' => ':n Sitzung(en) beendet.', + 'cancel' => 'Abbrechen', +]; diff --git a/lang/en/sessions.php b/lang/en/sessions.php new file mode 100644 index 0000000..0803ebf --- /dev/null +++ b/lang/en/sessions.php @@ -0,0 +1,19 @@ + 'Signed-in devices', + 'subtitle' => 'Every open sign-in, with the device and when it was last active.', + 'empty' => 'Only this session — nothing else is open.', + 'this_device' => 'This device', + 'last_seen' => 'last seen :when', + 'end' => 'End', + 'ended' => 'Session ended.', + // The bulk button goes through a modal (R23); the single one does not: one + // row, one click, one device — and anybody who misclicks signs in again. + 'end_others' => 'Sign out everywhere else', + 'end_others_title' => 'Sign out every other device?', + 'end_others_body' => 'This session stays open. Every other device has to sign in again.', + 'end_others_confirm' => 'Sign out everywhere else', + 'ended_others' => ':n session(s) ended.', + 'cancel' => 'Cancel', +]; diff --git a/resources/views/livewire/admin/confirm-end-other-sessions.blade.php b/resources/views/livewire/admin/confirm-end-other-sessions.blade.php new file mode 100644 index 0000000..d1db16e --- /dev/null +++ b/resources/views/livewire/admin/confirm-end-other-sessions.blade.php @@ -0,0 +1,17 @@ +
+
+ + + +
+

{{ __('sessions.end_others_title') }}

+

{{ __('sessions.end_others_body') }}

+
+
+
+ {{ __('sessions.cancel') }} + + {{ __('sessions.end_others_confirm') }} + +
+
diff --git a/resources/views/livewire/admin/settings.blade.php b/resources/views/livewire/admin/settings.blade.php index e397c29..08a60ab 100644 --- a/resources/views/livewire/admin/settings.blade.php +++ b/resources/views/livewire/admin/settings.blade.php @@ -291,6 +291,13 @@ @endif + {{-- Every operator sees their own, with no capability required: this is + the account you are signed in as, and being unable to see where else + that is true is the gap the feature exists to close. --}} +
+ @livewire('admin.sessions') +
+ {{-- Two-factor policy — voluntary by default, the Owner can make it compulsory. The switch refuses to turn on while the operator flipping it has no confirmed two-factor themselves: the page that would turn it diff --git a/resources/views/livewire/confirm-end-other-sessions.blade.php b/resources/views/livewire/confirm-end-other-sessions.blade.php new file mode 100644 index 0000000..d1db16e --- /dev/null +++ b/resources/views/livewire/confirm-end-other-sessions.blade.php @@ -0,0 +1,17 @@ +
+
+ + + +
+

{{ __('sessions.end_others_title') }}

+

{{ __('sessions.end_others_body') }}

+
+
+
+ {{ __('sessions.cancel') }} + + {{ __('sessions.end_others_confirm') }} + +
+
diff --git a/resources/views/livewire/sessions.blade.php b/resources/views/livewire/sessions.blade.php new file mode 100644 index 0000000..a10233c --- /dev/null +++ b/resources/views/livewire/sessions.blade.php @@ -0,0 +1,58 @@ +{{-- Shared by the portal's Sessions component and the console's. Markup is not + identity — the two components resolve their own, from their own guard. --}} +
+
+
+

{{ __('sessions.title') }}

+

{{ __('sessions.subtitle') }}

+
+ + @if ($sessions->count() > 1) + {{-- Through a modal, never a native dialog (R23). The modal mutates + nothing; it raises an event this component catches, so the + permission check stays where it already was. --}} + + + {{ __('sessions.end_others') }} + + @endif +
+ + @if ($sessions->isEmpty()) +

{{ __('sessions.empty') }}

+ @else +
    + @foreach ($sessions as $session) +
  • +
    +

    + {{ $session->device }} + @if ($session->is_current) + + {{ __('sessions.this_device') }} + + @endif +

    +

    + {{ $session->ip_address ?? '—' }} + @if ($session->last_seen_at) + · {{ __('sessions.last_seen', ['when' => $session->last_seen_at->diffForHumans()]) }} + @endif +

    +
    + + {{-- The current session has no end button: signing yourself + out of the page you are reading is what the sign-out + menu is for, and here it would read as a mistake. --}} + @unless ($session->is_current) + + {{ __('sessions.end') }} + + @endunless +
  • + @endforeach +
+ @endif +
diff --git a/resources/views/livewire/settings.blade.php b/resources/views/livewire/settings.blade.php index 2ad6df8..60f0fdd 100644 --- a/resources/views/livewire/settings.blade.php +++ b/resources/views/livewire/settings.blade.php @@ -125,6 +125,12 @@ @endif + {{-- Directly under two-factor, because the two answer the same question: + who can get into this account, and what can I do about it. --}} +
+ @livewire('sessions') +
+

{{ __('settings.branding_title') }}

diff --git a/tests/Feature/SessionListTest.php b/tests/Feature/SessionListTest.php new file mode 100644 index 0000000..631a117 --- /dev/null +++ b/tests/Feature/SessionListTest.php @@ -0,0 +1,141 @@ + $guard, + 'authenticatable_id' => $id, + 'token_hash' => hash('sha256', Str::random(40)), + 'name' => $device, + 'last_seen_at' => now(), + ]); + + DB::table('sessions')->insert([ + 'id' => $sessionId, + 'ip_address' => '203.0.113.10', + 'user_agent' => 'test', + 'payload' => '', + 'last_activity' => $lastActivity ?? now()->getTimestamp(), + ]); + + return LoginSession::create([ + 'device_id' => $deviceRow->id, + 'session_id' => $sessionId, + 'ip_address' => '203.0.113.10', + 'last_seen_at' => now(), + ]); +} + +it('lists the sessions of one identity and marks the current one', function () { + $user = User::factory()->create(); + $here = makeSession('web', $user->id, 'session-here', 'Chrome auf macOS'); + makeSession('web', $user->id, 'session-there', 'Safari auf iPhone'); + + $rows = app(SessionRegistry::class)->for('web', $user->id, $here->uuid); + + expect($rows)->toHaveCount(2) + ->and($rows->firstWhere('uuid', $here->uuid)->is_current)->toBeTrue() + ->and($rows->firstWhere('device', 'Safari auf iPhone')->is_current)->toBeFalse(); +}); + +it('does not show a customer the sessions of the operator with the same id', function () { + $user = User::factory()->create(); + makeSession('web', $user->id, 'session-customer'); + makeSession('operator', $user->id, 'session-operator', 'Firefox auf Linux'); + + $rows = app(SessionRegistry::class)->for('web', $user->id); + + expect($rows)->toHaveCount(1) + ->and($rows->first()->device)->not->toBe('Firefox auf Linux'); +}); + +it('refuses to end a session that belongs to the other identity', function () { + $user = User::factory()->create(); + $operatorSession = makeSession('operator', $user->id, 'session-operator'); + + $ended = app(SessionRegistry::class)->end('web', $user->id, $operatorSession->uuid); + + expect($ended)->toBeFalse() + ->and(DB::table('sessions')->where('id', 'session-operator')->exists())->toBeTrue(); +}); + +it('ending a session removes the framework row, which is what signs the browser out', function () { + $user = User::factory()->create(); + $other = makeSession('web', $user->id, 'session-there'); + + expect(app(SessionRegistry::class)->end('web', $user->id, $other->uuid))->toBeTrue() + ->and(DB::table('sessions')->where('id', 'session-there')->exists())->toBeFalse() + ->and(LoginSession::query()->where('uuid', $other->uuid)->exists())->toBeFalse(); +}); + +it('leaves the session doing the asking alone when ending the others', function () { + // Signing somebody out of the page they are using to sign other people out + // is not what the button says, and leaves them unable to see whether it + // worked. + $user = User::factory()->create(); + $here = makeSession('web', $user->id, 'session-here'); + makeSession('web', $user->id, 'session-b'); + makeSession('web', $user->id, 'session-c'); + + $ended = app(SessionRegistry::class)->endOthers('web', $user->id, $here->uuid); + + expect($ended)->toBe(2) + ->and(DB::table('sessions')->where('id', 'session-here')->exists())->toBeTrue() + ->and(DB::table('sessions')->count())->toBe(1); +}); + +it('hides a session whose framework row has expired but not yet been collected', function () { + // Laravel collects expired sessions by lottery, so on a quiet installation + // rows sit there for days. Listing them offers somebody a laptop they shut + // last month as somewhere they are signed in right now. + $user = User::factory()->create(); + makeSession('web', $user->id, 'session-stale', 'Chrome auf Windows', + lastActivity: now()->subMinutes((int) config('session.lifetime') + 5)->getTimestamp()); + + expect(app(SessionRegistry::class)->for('web', $user->id))->toHaveCount(0); +}); + +it('lets a customer end another session from the settings page', function () { + $user = User::factory()->create(); + $other = makeSession('web', $user->id, 'session-there', 'Safari auf iPhone'); + + Livewire::actingAs($user) + ->test(PortalSessions::class) + ->assertSee('Safari auf iPhone') + ->call('end', $other->uuid) + ->assertDispatched('notify', message: __('sessions.ended')); + + expect(DB::table('sessions')->where('id', 'session-there')->exists())->toBeFalse(); +}); + +it('ends the others only when the confirmation modal says so', function () { + // R23: the destructive one goes through the product's own modal, which + // mutates nothing and raises an event this component catches — so the + // identity resolution stays at the one place that already had it. + $user = User::factory()->create(); + makeSession('web', $user->id, 'session-b'); + makeSession('web', $user->id, 'session-c'); + + Livewire::actingAs($user) + ->test(PortalSessions::class) + ->dispatch('sessions-end-others-confirmed') + ->assertDispatched('notify'); + + expect(DB::table('sessions')->count())->toBe(0); +});