presseportale/tests/Feature/Auth/PasswordChangeRevokesSessionsTest.php
Kevin Adametz d1b9ffdcf1 security: Breadcrumbs ohne Daten an GlitchTip, Übergangsmarke der Session-Sperre entfernt
- ErrorReportScrubber entfernt die Daten aller Breadcrumbs und bereinigt ihre
  Nachrichten; Livewire-Breadcrumbs standardmäßig aus — das SDK hängt dort
  Formularwerte und Properties an (Roadmap 4.9). Bestand in GlitchTip geprüft:
  keine Formular- oder Passwortdaten.
- SessionRevocation::legacyCachedMark() entfernt (Übergang bis v1.6.0)

Changelog: Sicherheit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:35:54 +00:00

204 lines
7.5 KiB
PHP

<?php
use App\Actions\Admin\UserImpersonation;
use App\Enums\SecurityChange;
use App\Mail\SecurityChangeNotice;
use App\Models\MagicLink;
use App\Models\User;
use App\Services\Auth\SessionRevocation;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Str;
use Livewire\Livewire;
use Tests\TestCase;
/**
* Sicherheitsaudit 07.10.2026, S-04: Nach Passwortwechsel oder -Reset darf eine
* vorher aufgebaute Browser-Session nicht weiterarbeiten. Beim angemeldeten
* Wechsel bleibt die eigene Session angemeldet (Entscheidung 2), API-Tokens
* bleiben bestehen (Entscheidung 1).
*/
beforeEach(function () {
$this->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);
});