Show where an account is signed in, and let it sign the other places out
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 <noreply@anthropic.com>feat/granted-plans
parent
2609393e3a
commit
07141a96bf
|
|
@ -0,0 +1,74 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\LoginSession;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Keeps a session row pointing at the session it describes.
|
||||
*
|
||||
* Laravel rotates the session id on every sign-in and on every regenerate().
|
||||
* A row that stored the id once stops matching within a request or two — and
|
||||
* the list would then show sessions that cannot be ended and hide the one the
|
||||
* reader is sitting in. The uuid inside the payload is what survives; the id is
|
||||
* refreshed from it here.
|
||||
*
|
||||
* Also the only thing that keeps "last seen" honest. Without it the list
|
||||
* reports the moment somebody signed in, which for a browser left open for a
|
||||
* fortnight is the least useful timestamp available.
|
||||
*
|
||||
* Written at most once a minute. This runs on every authenticated request, and
|
||||
* a write per request would turn a page of thumbnails into forty updates of the
|
||||
* same row.
|
||||
*/
|
||||
class TouchLoginSession
|
||||
{
|
||||
private const THROTTLE_SECONDS = 60;
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$response = $next($request);
|
||||
|
||||
if (! $request->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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
/**
|
||||
* Confirmation before an operator signs every other browser out (R23). The
|
||||
* console's own, because operators and customers are two identities (R21) and
|
||||
* one shared modal would raise an event both sides listen for.
|
||||
*/
|
||||
class ConfirmEndOtherSessions extends ModalComponent
|
||||
{
|
||||
public function confirm(): void
|
||||
{
|
||||
$this->dispatch('sessions-end-others-confirmed');
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.admin.confirm-end-other-sessions');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Livewire\Concerns\ResolvesOperator;
|
||||
use App\Services\Devices\SessionRegistry;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* "Where am I signed in", for an operator.
|
||||
*
|
||||
* Deliberately not the portal's component with a guard passed in: two
|
||||
* identities, two guards, two tables (R21), and a component that took the guard
|
||||
* as input would be one forged property away from ending a customer's sessions
|
||||
* from the console. The view is shared, because markup is not identity.
|
||||
*/
|
||||
class Sessions extends Component
|
||||
{
|
||||
use ResolvesOperator;
|
||||
|
||||
private const GUARD = 'operator';
|
||||
|
||||
public function end(string $uuid): void
|
||||
{
|
||||
if (! $operator = $this->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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
/**
|
||||
* Confirmation before a customer signs every other browser out (R23).
|
||||
*
|
||||
* Mutates nothing. Sessions::endOthers() still does the work, with its own
|
||||
* identity resolution — the check stays at the one place that already had it.
|
||||
*/
|
||||
class ConfirmEndOtherSessions extends ModalComponent
|
||||
{
|
||||
public function confirm(): void
|
||||
{
|
||||
$this->dispatch('sessions-end-others-confirmed');
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.confirm-end-other-sessions');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Services\Devices\SessionRegistry;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* "Where am I signed in", for a customer.
|
||||
*
|
||||
* The console has its own (App\Livewire\Admin\Sessions) rather than this one
|
||||
* being reused with a guard argument. Two identities, two guards, two tables
|
||||
* (R21) — and a component that took the guard as input would be one forged
|
||||
* property away from listing and ending the other side's sessions.
|
||||
*
|
||||
* The view is shared, because markup is not identity.
|
||||
*/
|
||||
class Sessions extends Component
|
||||
{
|
||||
private const GUARD = 'web';
|
||||
|
||||
public function end(string $uuid): void
|
||||
{
|
||||
app(SessionRegistry::class)->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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Devices;
|
||||
|
||||
use App\Models\LoginSession;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Where an account is signed in, and how to end one of them.
|
||||
*
|
||||
* Everything is scoped by guard as well as by id, in every method. Operators
|
||||
* and customers are two tables and two guards (R21), so operator 1 and customer
|
||||
* 1 share an integer and nothing else — an unscoped query here would let either
|
||||
* of them sign the other out.
|
||||
*
|
||||
* A session counts as live only if the framework still has its row AND that row
|
||||
* has been touched within the session lifetime. The join alone is not enough:
|
||||
* Laravel collects expired sessions by lottery, so on a quiet installation rows
|
||||
* for sessions nobody could still use sit there for days, and the list would
|
||||
* offer somebody a laptop they closed last month as somewhere they are signed
|
||||
* in right now.
|
||||
*/
|
||||
final class SessionRegistry
|
||||
{
|
||||
/**
|
||||
* The live sessions for one identity, newest activity first.
|
||||
*
|
||||
* @return Collection<int, object>
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'title' => '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',
|
||||
];
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'title' => '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',
|
||||
];
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
<div class="rounded-lg bg-surface p-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-warning-bg text-warning">
|
||||
<x-ui.icon name="log-out" class="size-5" />
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-base font-semibold text-ink">{{ __('sessions.end_others_title') }}</h3>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('sessions.end_others_body') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('sessions.cancel') }}</x-ui.button>
|
||||
<x-ui.button variant="primary" wire:click="confirm" wire:loading.attr="disabled">
|
||||
{{ __('sessions.end_others_confirm') }}
|
||||
</x-ui.button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -291,6 +291,13 @@
|
|||
</div>
|
||||
@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. --}}
|
||||
<div class="animate-rise [animation-delay:40ms]">
|
||||
@livewire('admin.sessions')
|
||||
</div>
|
||||
|
||||
{{-- 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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
<div class="rounded-lg bg-surface p-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-warning-bg text-warning">
|
||||
<x-ui.icon name="log-out" class="size-5" />
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-base font-semibold text-ink">{{ __('sessions.end_others_title') }}</h3>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('sessions.end_others_body') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('sessions.cancel') }}</x-ui.button>
|
||||
<x-ui.button variant="primary" wire:click="confirm" wire:loading.attr="disabled">
|
||||
{{ __('sessions.end_others_confirm') }}
|
||||
</x-ui.button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -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. --}}
|
||||
<div class="rounded-lg border border-line bg-surface p-6 shadow-xs">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<h2 class="font-semibold text-ink">{{ __('sessions.title') }}</h2>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('sessions.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
@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. --}}
|
||||
<x-ui.button variant="secondary"
|
||||
x-on:click="$dispatch('openModal', { component: '{{ $confirmComponent }}' })">
|
||||
<x-ui.icon name="log-out" class="size-4" />
|
||||
{{ __('sessions.end_others') }}
|
||||
</x-ui.button>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($sessions->isEmpty())
|
||||
<p class="mt-4 text-sm text-muted">{{ __('sessions.empty') }}</p>
|
||||
@else
|
||||
<ul class="mt-4 divide-y divide-line">
|
||||
@foreach ($sessions as $session)
|
||||
<li wire:key="session-{{ $session->uuid }}" class="flex flex-wrap items-center justify-between gap-3 py-3 first:pt-0 last:pb-0">
|
||||
<div class="min-w-0">
|
||||
<p class="flex flex-wrap items-center gap-2 text-sm font-medium text-body">
|
||||
{{ $session->device }}
|
||||
@if ($session->is_current)
|
||||
<span class="inline-flex items-center gap-1.5 rounded-pill border border-success-border bg-success-bg px-2 py-0.5 text-xs font-medium text-success">
|
||||
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>{{ __('sessions.this_device') }}
|
||||
</span>
|
||||
@endif
|
||||
</p>
|
||||
<p class="mt-0.5 font-mono text-xs text-muted">
|
||||
{{ $session->ip_address ?? '—' }}
|
||||
@if ($session->last_seen_at)
|
||||
· {{ __('sessions.last_seen', ['when' => $session->last_seen_at->diffForHumans()]) }}
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{-- 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)
|
||||
<x-ui.button variant="secondary" wire:click="end('{{ $session->uuid }}')"
|
||||
wire:loading.attr="disabled" wire:target="end('{{ $session->uuid }}')">
|
||||
{{ __('sessions.end') }}
|
||||
</x-ui.button>
|
||||
@endunless
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@endif
|
||||
</div>
|
||||
|
|
@ -125,6 +125,12 @@
|
|||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Directly under two-factor, because the two answer the same question:
|
||||
who can get into this account, and what can I do about it. --}}
|
||||
<div class="animate-rise [animation-delay:165ms]">
|
||||
@livewire('sessions')
|
||||
</div>
|
||||
|
||||
<form wire:submit="saveBranding" class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:120ms]">
|
||||
<div>
|
||||
<h2 class="font-semibold text-ink">{{ __('settings.branding_title') }}</h2>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,141 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Sessions as PortalSessions;
|
||||
use App\Models\LoginSession;
|
||||
use App\Models\User;
|
||||
use App\Models\UserDevice;
|
||||
use App\Services\Devices\SessionRegistry;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Livewire;
|
||||
|
||||
/**
|
||||
* Seeing where an account is signed in, and ending one of those places.
|
||||
*
|
||||
* The scoping tests are the point. Operators and customers are two guards and
|
||||
* two tables (R21), so operator 1 and customer 1 share an integer and nothing
|
||||
* else — an unscoped query here would let either of them sign the other out,
|
||||
* and it would look exactly like a working feature while doing it.
|
||||
*/
|
||||
function makeSession(string $guard, int $id, string $sessionId, string $device = 'Chrome auf macOS', ?int $lastActivity = null): LoginSession
|
||||
{
|
||||
$deviceRow = UserDevice::create([
|
||||
'guard' => $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);
|
||||
});
|
||||
Loading…
Reference in New Issue