security: Sicherheitsaudit 07.10.2026 (S-01–S-06) behoben, Passkeys und Zweitfaktor-Pflicht für Admins
Audit: dev/sicherheit/SICHERHEITSAUDIT-2026-10-07.md, Plan und Entscheidungen: dev/sicherheit/ENTWICKLUNGSPLAN-SICHERHEIT-2026-10-07.md. - S-01 Legacy-Rechnungen fremder Kunden nur mit billing:manage (auch Kundenroute). - S-02 Kontaktzuordnung auf der Firmenanzeige verlangt companies:write, $id gesperrt. - S-03 CompanyMembershipObserver: automatische Firmenrechte werden bei jeder Adress-/Zuordnungsänderung sofort neu bewertet; memberships:resync-auto. - S-04 Passwortwechsel beendet alle anderen Sessions (PasswordChanger), Reset alle Sessions und offene Login-Links; Fortify PUT /user/password abgeschaltet. API-Tokens bleiben bestehen. - S-05 Google: email_verified Pflicht, feste Bindung über die Provider-ID; Erstverknüpfung bestehender Konten außerhalb gmail/hd nur per Bestätigungslink in derselben Browser-Session. - S-06 Frischer Identitätsnachweis (Passwort, App-Code, E-Mail-Code oder Passkey) vor E-Mail-Wechsel, erster 2FA-Einrichtung und Token-Erzeugung; Hinweis-Mails an die (bisherige) Kontoadresse. - Entscheidung 5: Passkeys (Fortify, @laravel/passkeys) für alle; Super-Admins und Admins bestätigen die Session für den Admin-Bereich per Passkey oder App-Code (EnsureStrongAdminAuthentication, Notfallschalter AUTH_REQUIRE_STRONG_ADMIN); Reset des zweiten Faktors durch Super-Admins. Neue Migration: create_passkeys_table. Suite 2.403 grün (1 übersprungen), Larastan ohne Meldung. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
fd8dd74d5f
commit
bae8347f6c
66 changed files with 4035 additions and 144 deletions
133
tests/Feature/Auth/PasswordChangeRevokesSessionsTest.php
Normal file
133
tests/Feature/Auth/PasswordChangeRevokesSessionsTest.php
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
<?php
|
||||
|
||||
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\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');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue