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 + + @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); +});