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:
Kevin Adametz 2026-10-07 16:09:35 +00:00
parent d1d2ab3de7
commit 1c07fcb84b
66 changed files with 4035 additions and 144 deletions

View file

@ -2,7 +2,10 @@
use App\Enums\Portal;
use App\Enums\PressReleaseStatus;
use App\Enums\SecurityChange;
use App\Mail\EmailChangeConfirmation;
use App\Mail\SecurityChangeNotice;
use App\Mail\SecurityConfirmationCode;
use App\Models\Category;
use App\Models\Company;
use App\Models\Contact;
@ -10,6 +13,7 @@ use App\Models\LegacyInvoice;
use App\Models\PressRelease;
use App\Models\PressReleaseStatusLog;
use App\Models\User;
use App\Services\Auth\SecurityConfirmation;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
@ -215,6 +219,7 @@ test('two factor setup only becomes active after confirming an app code', functi
$this->actingAs($customer);
$component = Livewire::test('customer.security')
->set('securityPassword', 'password')
->call('enableTwoFactorAuthentication');
$customer->refresh();
@ -244,6 +249,7 @@ test('an invalid two factor confirmation code is rejected', function () {
$this->actingAs($customer);
Livewire::test('customer.security')
->set('securityPassword', 'password')
->call('enableTwoFactorAuthentication')
->set('twoFactorCode', '000000')
->call('confirmTwoFactorAuthentication')
@ -623,6 +629,7 @@ test('cancelling an unconfirmed two-factor setup needs no proof', function () {
$this->actingAs($customer);
Livewire::test('customer.security')
->set('securityPassword', 'password')
->call('enableTwoFactorAuthentication')
->call('disableTwoFactorAuthentication')
->assertHasNoErrors();
@ -672,7 +679,12 @@ test('changing the email asks for the password, or for the app code once two-fac
expect($customer->fresh()->pending_email)->toBe('neu2@example.com');
});
test('a passwordless magic-link account can still start an email change', function () {
/*
* Sicherheitsaudit 07.10.2026, S-06: Ein passwortloses Konto ohne 2FA kann die
* Adresse weiterhin ändern, aber nur mit einem Code an die BISHERIGE Adresse —
* eine fremde Session allein reicht nicht mehr.
*/
test('a passwordless magic-link account confirms an email change with a code sent to its current address', function () {
/** @var TestCase $this */
Mail::fake();
@ -685,7 +697,153 @@ test('a passwordless magic-link account can still start an email change', functi
Livewire::test('customer.security')
->set('email', 'magic-neu@example.com')
->call('updateEmail')
->assertHasErrors(['securityEmailCode']);
expect($customer->fresh()->pending_email)->toBeNull();
$component = Livewire::test('customer.security')
->call('requestSecurityEmailCode')
->assertHasNoErrors();
$code = null;
Mail::assertQueued(SecurityConfirmationCode::class, function (SecurityConfirmationCode $mail) use (&$code) {
$code = $mail->code;
return $mail->hasTo('magic@example.com');
});
$component
->set('email', 'magic-neu@example.com')
->set('securityEmailCode', $code === '000000' ? '111111' : '000000')
->call('updateEmail')
->assertHasErrors(['securityEmailCode']);
$component
->set('securityEmailCode', $code)
->call('updateEmail')
->assertHasNoErrors();
expect($customer->fresh()->pending_email)->toBe('magic-neu@example.com');
});
test('the email code is single use and burns after five wrong attempts', function () {
/** @var TestCase $this */
Mail::fake();
$customer = User::factory()->create(['is_active' => true, 'email' => 'magic-burn@example.com']);
DB::table('users')->where('id', $customer->id)->update(['password' => '']);
$customer = $customer->fresh();
$confirmation = app(SecurityConfirmation::class);
$confirmation->sendEmailCode($customer);
$code = null;
Mail::assertQueued(SecurityConfirmationCode::class, function (SecurityConfirmationCode $mail) use (&$code) {
$code = $mail->code;
return true;
});
$wrong = $code === '000000' ? '111111' : '000000';
foreach (range(1, SecurityConfirmation::MAX_CODE_ATTEMPTS) as $attempt) {
expect($confirmation->verifyEmailCode($customer, $wrong))->toBeFalse();
}
expect($confirmation->verifyEmailCode($customer, $code))->toBeFalse();
$confirmation->sendEmailCode($customer);
Mail::assertQueued(SecurityConfirmationCode::class, function (SecurityConfirmationCode $mail) use (&$code) {
$code = $mail->code;
return true;
});
expect($confirmation->verifyEmailCode($customer, $code))->toBeTrue()
->and($confirmation->verifyEmailCode($customer, $code))->toBeFalse();
});
test('first-time two-factor setup needs a fresh identity proof', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);
$this->actingAs($customer);
Livewire::test('customer.security')
->call('enableTwoFactorAuthentication')
->assertHasErrors(['securityPassword']);
Livewire::test('customer.security')
->set('securityPassword', 'falsch')
->call('enableTwoFactorAuthentication')
->assertHasErrors(['securityPassword']);
expect($customer->fresh()->two_factor_secret)->toBeNull();
// Nach erfolgreichem Nachweis gilt die Session eine Weile als bestätigt.
Livewire::test('customer.security')
->set('securityPassword', 'password')
->call('enableTwoFactorAuthentication')
->assertHasNoErrors()
->call('disableTwoFactorAuthentication')
->call('enableTwoFactorAuthentication')
->assertHasNoErrors();
expect($customer->fresh()->two_factor_secret)->not->toBeNull();
$this->travel(SecurityConfirmation::FRESH_FOR_SECONDS + 60)->seconds();
Livewire::test('customer.security')
->call('disableTwoFactorAuthentication')
->call('enableTwoFactorAuthentication')
->assertHasErrors(['securityPassword']);
});
test('security changes are reported to the account address', function () {
/** @var TestCase $this */
Mail::fake();
$customer = User::factory()->create(['is_active' => true, 'email' => 'hinweis@example.com']);
$this->actingAs($customer);
$component = Livewire::test('customer.security')
->set('securityPassword', 'password')
->call('enableTwoFactorAuthentication');
$otp = app(Google2FA::class)->getCurrentOtp(decrypt($customer->fresh()->two_factor_secret));
$component->set('twoFactorCode', $otp)->call('confirmTwoFactorAuthentication')->assertHasNoErrors();
Mail::assertQueued(SecurityChangeNotice::class, fn (SecurityChangeNotice $mail) => $mail->change === SecurityChange::TwoFactorEnabled
&& $mail->hasTo('hinweis@example.com'));
Livewire::test('customer.security')
->set('current_password', 'password')
->set('password', 'new-strong-password')
->set('password_confirmation', 'new-strong-password')
->call('updatePassword')
->assertHasNoErrors();
Mail::assertQueued(SecurityChangeNotice::class, fn (SecurityChangeNotice $mail) => $mail->change === SecurityChange::PasswordChanged);
});
test('a confirmed email change is reported to the previous address', function () {
/** @var TestCase $this */
Mail::fake();
$customer = User::factory()->create([
'is_active' => true,
'email' => 'vorher@example.com',
'pending_email' => 'nachher@example.com',
]);
$url = URL::temporarySignedRoute('email-change.confirm', now()->addHour(), [
'user' => $customer->id,
'hash' => sha1('nachher@example.com'),
]);
$this->actingAs($customer)->get($url)->assertRedirect(route('me.security'));
expect($customer->fresh()->email)->toBe('nachher@example.com');
Mail::assertQueued(SecurityChangeNotice::class, fn (SecurityChangeNotice $mail) => $mail->change === SecurityChange::EmailChanged
&& $mail->hasTo('vorher@example.com')
&& $mail->newEmail === 'nachher@example.com');
});