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
d1d2ab3de7
commit
1c07fcb84b
66 changed files with 4035 additions and 144 deletions
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue