seed(RolesAndPermissionsSeeder::class); Mail::fake(); }); function accountWithOtherSession(): array { $user = User::factory()->create(['is_active' => true, 'remember_token' => 'old-remember-token']); $user->assignRole('admin'); $user->createToken('Integration', ['press-releases:read']); // Anmeldezeitpunkt der „anderen“ Session: vor dem Passwortwechsel. $otherSessionStamp = now()->getTimestampMs(); test()->travel(2)->seconds(); return [$user, $otherSessionStamp]; } test('changing the password keeps the current session and ends every other one', function (string $component) { /** @var TestCase $this */ [$user, $otherSessionStamp] = accountWithOtherSession(); $this->actingAs($user)->withSession([SessionRevocation::SESSION_KEY => $otherSessionStamp]); Livewire::test($component) ->set('current_password', 'password') ->set('password', 'new-strong-password') ->set('password_confirmation', 'new-strong-password') ->call('updatePassword') ->assertHasNoErrors(); // Die Session, in der das Passwort geändert wurde, bleibt angemeldet. $this->get(route('me.security'))->assertSuccessful(); // Eine vorher angemeldete Session fliegt beim nächsten Request raus. $this->withSession([SessionRevocation::SESSION_KEY => $otherSessionStamp]) ->get(route('me.security')) ->assertRedirect(route('login')); $this->assertGuest(); $user->refresh(); expect($user->remember_token)->not->toBe('old-remember-token') ->and($user->tokens()->count())->toBe(1); Mail::assertQueued(SecurityChangeNotice::class, fn (SecurityChangeNotice $mail) => $mail->change === SecurityChange::PasswordChanged && $mail->hasTo($user->email)); })->with([ 'customer security page' => 'customer.security', 'admin settings page' => 'settings.password', ]); test('a password reset ends every session and voids open login links', function () { /** @var TestCase $this */ [$user, $otherSessionStamp] = accountWithOtherSession(); $openLink = MagicLink::query()->create([ 'user_id' => $user->id, 'token_hash' => hash('sha256', Str::random(40)), 'purpose' => MagicLink::PURPOSE_LOGIN, 'expires_at' => now()->addHour(), ]); Livewire::test('auth.reset-password', ['token' => Password::createToken($user)]) ->set('email', $user->email) ->set('password', 'new-strong-password') ->set('password_confirmation', 'new-strong-password') ->call('resetPassword') ->assertHasNoErrors() ->assertRedirect(route('login', absolute: false)); $this->actingAs($user->fresh()) ->withSession([SessionRevocation::SESSION_KEY => $otherSessionStamp]) ->get(route('me.security')) ->assertRedirect(route('login')); $this->assertGuest(); expect($openLink->fresh()->consumed_at)->not->toBeNull() ->and($user->fresh()->remember_token)->not->toBe('old-remember-token') ->and($user->tokens()->count())->toBe(1); Mail::assertQueued(SecurityChangeNotice::class, fn (SecurityChangeNotice $mail) => $mail->change === SecurityChange::PasswordReset); }); test('a session started after the reset is not affected', function () { /** @var TestCase $this */ [$user] = accountWithOtherSession(); Livewire::test('auth.reset-password', ['token' => Password::createToken($user)]) ->set('email', $user->email) ->set('password', 'new-strong-password') ->set('password_confirmation', 'new-strong-password') ->call('resetPassword'); $this->travel(1)->seconds(); $this->actingAs($user->fresh()) ->withSession([SessionRevocation::SESSION_KEY => now()->getTimestampMs()]) ->get(route('me.security')) ->assertSuccessful(); }); test('the unused fortify password update route is disabled (Entscheidung 4)', function () { expect(Route::has('user-password.update'))->toBeFalse(); }); test('livewire requests pass the session revocation check', function () { $route = collect(Route::getRoutes()->getRoutes()) ->first(fn ($route) => str_ends_with((string) $route->getName(), 'livewire.update')); expect($route)->not->toBeNull() ->and($route->gatherMiddleware())->toContain('web'); }); test('logging out other devices keeps this session and ends every other one', function () { /** @var TestCase $this */ [$user, $otherSessionStamp] = accountWithOtherSession(); $this->actingAs($user)->withSession([SessionRevocation::SESSION_KEY => $otherSessionStamp]); Livewire::test('customer.security') ->call('logoutOtherDevices') ->assertHasNoErrors(); $this->get(route('me.security'))->assertSuccessful(); $this->withSession([SessionRevocation::SESSION_KEY => $otherSessionStamp]) ->get(route('me.security')) ->assertRedirect(route('login')); $this->assertGuest(); // Remember-Cookies anderer Geräte werden ungültig, das Passwort bleibt. $user->refresh(); expect($user->remember_token)->not->toBe('old-remember-token') ->and(Hash::check('password', $user->password))->toBeTrue() ->and($user->tokens()->count())->toBe(1); }); test('other devices cannot be logged out during impersonation', function () { /** @var TestCase $this */ [$user] = accountWithOtherSession(); $admin = User::factory()->create(); $this->actingAs($user)->withSession([UserImpersonation::SessionKey => $admin->id]); Livewire::test('customer.security') ->call('logoutOtherDevices') ->assertForbidden(); expect($user->fresh()->remember_token)->toBe('old-remember-token') ->and($user->fresh()->sessions_revoked_at_ms)->toBeNull(); }); test('the revocation mark is stored on the account and survives a cache flush', function () { /** @var TestCase $this */ [$user, $otherSessionStamp] = accountWithOtherSession(); $revokedAt = app(SessionRevocation::class)->revokeAllFor($user); Cache::flush(); expect($user->fresh()->sessions_revoked_at_ms)->toBe($revokedAt); $this->actingAs($user->fresh()) ->withSession([SessionRevocation::SESSION_KEY => $otherSessionStamp]) ->get(route('me.security')) ->assertRedirect(route('login')); $this->assertGuest(); }); test('a cache-only mark from before v1.6.0 no longer revokes sessions', function () { /** @var TestCase $this */ [$user, $otherSessionStamp] = accountWithOtherSession(); Cache::put('auth.sessions-revoked-at.'.$user->id, now()->getTimestampMs(), now()->addHour()); $this->actingAs($user->fresh()) ->withSession([SessionRevocation::SESSION_KEY => $otherSessionStamp]) ->get(route('me.security')) ->assertSuccessful(); $this->assertAuthenticatedAs($user); });