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
boban 2026-06-14 23:34:00 +02:00
parent 5f6ad27329
commit 0cdc6c3a7b
8 changed files with 469 additions and 7 deletions

View File

@ -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=/

View File

@ -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()),
]);
}
}

View File

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

View File

@ -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 {

24
lang/de/sessions.php Normal file
View File

@ -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.',
];

24
lang/en/sessions.php Normal file
View File

@ -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.',
];

View File

@ -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>

View File

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