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>
376 lines
13 KiB
PHP
376 lines
13 KiB
PHP
<?php
|
|
|
|
use App\Actions\Admin\UserImpersonation;
|
|
use App\Enums\SecurityChange;
|
|
use App\Http\Responses\PasskeyLoginResponse;
|
|
use App\Mail\SecurityChangeNotice;
|
|
use App\Models\User;
|
|
use App\Services\Auth\SecurityConfirmation;
|
|
use App\Services\Auth\StrongAuthentication;
|
|
use Database\Seeders\RolesAndPermissionsSeeder;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Mail;
|
|
use Illuminate\Support\Str;
|
|
use Laravel\Fortify\Events\ValidTwoFactorAuthenticationCodeProvided;
|
|
use Laravel\Passkeys\Events\PasskeyDeleted;
|
|
use Laravel\Passkeys\Events\PasskeyRegistered;
|
|
use Laravel\Passkeys\Events\PasskeyVerified;
|
|
use Laravel\Passkeys\Passkey;
|
|
use Laravel\Passkeys\Passkeys;
|
|
use Livewire\Livewire;
|
|
use PragmaRX\Google2FA\Google2FA;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* Entscheidung 5 (07.10.2026): Passkeys als bequemer, starker Login für alle
|
|
* und als Pflicht-Zweitfaktor (alternativ Authenticator-App) für Super-Admins
|
|
* und Admins im Admin-Bereich. Die WebAuthn-Zeremonie selbst prüft das Paket
|
|
* laravel/passkeys; hier geht es um die Anbindung an die eigenen Schutzschichten.
|
|
*/
|
|
beforeEach(function () {
|
|
$this->seed(RolesAndPermissionsSeeder::class);
|
|
Mail::fake();
|
|
});
|
|
|
|
function userWithPasskeyRole(string $role, array $attributes = []): User
|
|
{
|
|
$user = User::factory()->create(['is_active' => true, ...$attributes]);
|
|
$user->assignRole($role);
|
|
|
|
return $user;
|
|
}
|
|
|
|
function passkeyFor(User $user): Passkey
|
|
{
|
|
return $user->passkeys()->create([
|
|
'name' => 'MacBook',
|
|
'credential_id' => Str::random(32),
|
|
'credential' => ['aaguid' => '00000000-0000-0000-0000-000000000000'],
|
|
]);
|
|
}
|
|
|
|
function withTotp(User $user): string
|
|
{
|
|
$secret = app(Google2FA::class)->generateSecretKey();
|
|
$user->forceFill([
|
|
'two_factor_secret' => encrypt($secret),
|
|
'two_factor_recovery_codes' => encrypt(json_encode(['recovery-eins'])),
|
|
'two_factor_confirmed_at' => now(),
|
|
])->save();
|
|
|
|
return $secret;
|
|
}
|
|
|
|
/*
|
|
* Pflicht im Admin-Bereich
|
|
*/
|
|
test('an admin without a confirmed second factor is sent to the confirmation page and back', function () {
|
|
/** @var TestCase $this */
|
|
config(['auth.require_strong_admin_authentication' => true]);
|
|
$admin = userWithPasskeyRole('admin');
|
|
$secret = withTotp($admin);
|
|
|
|
$this->actingAs($admin)
|
|
->get('https://pressekonto.test/admin/companies')
|
|
->assertRedirect(route('strong-auth.confirm'));
|
|
|
|
Livewire::test('auth.strong-confirmation')
|
|
->set('securityCode', app(Google2FA::class)->getCurrentOtp($secret))
|
|
->call('confirmWithCode')
|
|
->assertHasNoErrors()
|
|
->assertRedirect('https://pressekonto.test/admin/companies');
|
|
|
|
$this->get('https://pressekonto.test/admin/companies')->assertSuccessful();
|
|
});
|
|
|
|
test('a wrong authenticator code does not confirm the admin session', function () {
|
|
config(['auth.require_strong_admin_authentication' => true]);
|
|
$admin = userWithPasskeyRole('admin');
|
|
withTotp($admin);
|
|
|
|
$this->actingAs($admin);
|
|
|
|
Livewire::test('auth.strong-confirmation')
|
|
->set('securityCode', '000000')
|
|
->call('confirmWithCode')
|
|
->assertHasErrors(['securityCode']);
|
|
|
|
expect(app(StrongAuthentication::class)->isSatisfiedBy($admin))->toBeFalse();
|
|
});
|
|
|
|
test('magic link, google or password alone never satisfy the admin requirement', function () {
|
|
/** @var TestCase $this */
|
|
config(['auth.require_strong_admin_authentication' => true]);
|
|
$superAdmin = userWithPasskeyRole('admin', ['is_super_admin' => true]);
|
|
|
|
$this->actingAs($superAdmin)->get(route('dashboard'))->assertRedirect(route('strong-auth.confirm'));
|
|
$this->get('https://pressekonto.test/admin/companies')->assertRedirect(route('strong-auth.confirm'));
|
|
});
|
|
|
|
test('without any second factor the confirmation page leads to the setup', function () {
|
|
/** @var TestCase $this */
|
|
config(['auth.require_strong_admin_authentication' => true]);
|
|
$this->actingAs(userWithPasskeyRole('admin'));
|
|
|
|
$this->get(route('strong-auth.confirm'))
|
|
->assertSuccessful()
|
|
->assertSee('Zweiten Faktor einrichten')
|
|
->assertSee(route('me.security'));
|
|
});
|
|
|
|
test('editors and customers are not affected by the admin requirement', function () {
|
|
/** @var TestCase $this */
|
|
config(['auth.require_strong_admin_authentication' => true]);
|
|
|
|
$this->actingAs(userWithPasskeyRole('editor'))
|
|
->get('https://pressekonto.test/admin/press-releases')
|
|
->assertSuccessful();
|
|
|
|
$this->actingAs(userWithPasskeyRole('customer'))
|
|
->get(route('me.security'))
|
|
->assertSuccessful();
|
|
});
|
|
|
|
test('the emergency switch disables the admin requirement', function () {
|
|
/** @var TestCase $this */
|
|
config(['auth.require_strong_admin_authentication' => false]);
|
|
|
|
$this->actingAs(userWithPasskeyRole('admin'))
|
|
->get('https://pressekonto.test/admin/companies')
|
|
->assertSuccessful();
|
|
});
|
|
|
|
test('livewire requests of an unconfirmed admin session are rejected', function () {
|
|
/** @var TestCase $this */
|
|
config(['auth.require_strong_admin_authentication' => true]);
|
|
$this->actingAs(userWithPasskeyRole('admin'));
|
|
|
|
$this->getJson('https://pressekonto.test/admin/companies')->assertForbidden();
|
|
});
|
|
|
|
/*
|
|
* Passkey- und 2FA-Ereignisse
|
|
*/
|
|
test('a verified passkey confirms the session strongly and counts as identity proof', function () {
|
|
/** @var TestCase $this */
|
|
config(['auth.require_strong_admin_authentication' => true]);
|
|
$admin = userWithPasskeyRole('admin');
|
|
$passkey = passkeyFor($admin);
|
|
|
|
$this->actingAs($admin);
|
|
PasskeyVerified::dispatch($admin, $passkey);
|
|
|
|
expect(app(StrongAuthentication::class)->isSatisfiedBy($admin))->toBeTrue()
|
|
->and(app(SecurityConfirmation::class)->isFresh($admin))->toBeTrue();
|
|
|
|
Livewire::test('auth.strong-confirmation')->assertRedirect(route('dashboard'));
|
|
$this->get('https://pressekonto.test/admin/companies')->assertSuccessful();
|
|
});
|
|
|
|
test('the passkey confirmation callback fails without a verified passkey', function () {
|
|
$admin = userWithPasskeyRole('admin');
|
|
passkeyFor($admin);
|
|
$this->actingAs($admin);
|
|
|
|
Livewire::test('auth.strong-confirmation')
|
|
->call('passkeyConfirmed')
|
|
->assertHasErrors(['passkey']);
|
|
});
|
|
|
|
test('a valid code in the two-factor challenge confirms the session strongly', function () {
|
|
$admin = userWithPasskeyRole('admin');
|
|
$this->actingAs($admin);
|
|
|
|
event(new ValidTwoFactorAuthenticationCodeProvided($admin));
|
|
|
|
expect(app(StrongAuthentication::class)->isSatisfiedBy($admin))->toBeTrue();
|
|
});
|
|
|
|
test('added and removed passkeys are reported to the account address', function () {
|
|
$user = userWithPasskeyRole('customer', ['email' => 'passkey@example.com']);
|
|
$passkey = passkeyFor($user);
|
|
|
|
PasskeyRegistered::dispatch($user, $passkey);
|
|
PasskeyDeleted::dispatch($user, $passkey);
|
|
|
|
Mail::assertQueued(SecurityChangeNotice::class, fn (SecurityChangeNotice $mail) => $mail->change === SecurityChange::PasskeyAdded && $mail->hasTo('passkey@example.com'));
|
|
Mail::assertQueued(SecurityChangeNotice::class, fn (SecurityChangeNotice $mail) => $mail->change === SecurityChange::PasskeyRemoved);
|
|
});
|
|
|
|
/*
|
|
* Passkey-Login
|
|
*/
|
|
test('only active and verified accounts may sign in with a passkey', function (array $attributes, bool $allowed) {
|
|
$user = userWithPasskeyRole('customer', $attributes);
|
|
$passkey = passkeyFor($user);
|
|
|
|
expect(Passkeys::allowsLogin(Request::create('/passkeys/login', 'POST'), $passkey->fresh()))->toBe($allowed);
|
|
})->with([
|
|
'active and verified' => [[], true],
|
|
'deactivated' => [['is_active' => false], false],
|
|
'unverified' => [['email_verified_at' => null], false],
|
|
]);
|
|
|
|
test('the passkey login response records the login and keeps customers out of admin targets', function () {
|
|
/** @var TestCase $this */
|
|
$user = userWithPasskeyRole('customer');
|
|
$this->actingAs($user);
|
|
|
|
$request = Request::create('/passkeys/login', 'POST', server: ['HTTP_ACCEPT' => 'application/json']);
|
|
$request->setLaravelSession(app('session.store'));
|
|
$request->setUserResolver(fn () => $user);
|
|
session()->put('url.intended', 'https://pressekonto.test/admin/companies');
|
|
|
|
$response = (new PasskeyLoginResponse)->toResponse($request);
|
|
|
|
expect($response->getData(true)['redirect'])->not->toContain('/admin/companies')
|
|
->and($user->fresh()->last_login_at)->not->toBeNull();
|
|
});
|
|
|
|
/*
|
|
* Passkeys verwalten
|
|
*/
|
|
test('passkey registration options require a fresh identity proof', function () {
|
|
/** @var TestCase $this */
|
|
$user = userWithPasskeyRole('customer');
|
|
|
|
$this->actingAs($user)
|
|
->getJson('https://pressekonto.test/user/passkeys/options')
|
|
->assertForbidden();
|
|
|
|
$this->withSession([SecurityConfirmation::SESSION_KEY => ['user_id' => $user->id, 'at' => now()->getTimestamp()]])
|
|
->getJson('https://pressekonto.test/user/passkeys/options')
|
|
->assertSuccessful()
|
|
->assertJsonStructure(['options' => ['challenge', 'rp', 'user']]);
|
|
});
|
|
|
|
test('passkeys cannot be registered during impersonation', function () {
|
|
/** @var TestCase $this */
|
|
$admin = userWithPasskeyRole('admin');
|
|
$user = userWithPasskeyRole('customer');
|
|
|
|
$this->actingAs($user)
|
|
->withSession([
|
|
UserImpersonation::SessionKey => $admin->id,
|
|
SecurityConfirmation::SESSION_KEY => ['user_id' => $user->id, 'at' => now()->getTimestamp()],
|
|
])
|
|
->getJson('https://pressekonto.test/user/passkeys/options')
|
|
->assertForbidden();
|
|
});
|
|
|
|
test('the unused fortify passkey delete route is blocked', function () {
|
|
/** @var TestCase $this */
|
|
$user = userWithPasskeyRole('customer');
|
|
$passkey = passkeyFor($user);
|
|
|
|
$this->actingAs($user)
|
|
->deleteJson("https://pressekonto.test/user/passkeys/{$passkey->id}")
|
|
->assertNotFound();
|
|
|
|
expect($passkey->fresh())->not->toBeNull();
|
|
});
|
|
|
|
test('the security page starts a passkey registration only after the identity proof', function () {
|
|
$user = userWithPasskeyRole('customer');
|
|
$this->actingAs($user);
|
|
|
|
Livewire::test('customer.security')
|
|
->set('passkeyName', 'iPhone')
|
|
->call('startPasskeyRegistration')
|
|
->assertHasErrors(['securityPassword'])
|
|
->assertNotDispatched('passkey-register');
|
|
|
|
Livewire::test('customer.security')
|
|
->set('passkeyName', 'iPhone')
|
|
->set('securityPassword', 'password')
|
|
->call('startPasskeyRegistration')
|
|
->assertHasNoErrors()
|
|
->assertDispatched('passkey-register', name: 'iPhone');
|
|
});
|
|
|
|
test('removing a passkey needs the identity proof and reports it', function () {
|
|
$user = userWithPasskeyRole('customer');
|
|
$passkey = passkeyFor($user);
|
|
$foreign = passkeyFor(userWithPasskeyRole('customer'));
|
|
$this->actingAs($user);
|
|
|
|
Livewire::test('customer.security')
|
|
->call('deletePasskey', $passkey->id)
|
|
->assertHasErrors(['securityPassword']);
|
|
|
|
expect($passkey->fresh())->not->toBeNull();
|
|
|
|
Livewire::test('customer.security')
|
|
->set('securityPassword', 'password')
|
|
->call('deletePasskey', $passkey->id)
|
|
->assertHasNoErrors();
|
|
|
|
expect($passkey->fresh())->toBeNull();
|
|
Mail::assertQueued(SecurityChangeNotice::class, fn (SecurityChangeNotice $mail) => $mail->change === SecurityChange::PasskeyRemoved);
|
|
|
|
Livewire::test('customer.security')
|
|
->call('deletePasskey', $foreign->id)
|
|
->assertNotFound();
|
|
|
|
expect($foreign->fresh())->not->toBeNull();
|
|
});
|
|
|
|
test('a verified passkey replaces the password for a security change', function () {
|
|
$user = userWithPasskeyRole('customer', ['email' => 'alt-passkey@example.com']);
|
|
$passkey = passkeyFor($user);
|
|
$this->actingAs($user);
|
|
|
|
PasskeyVerified::dispatch($user, $passkey);
|
|
|
|
Livewire::test('customer.security')
|
|
->set('email', 'neu-passkey@example.com')
|
|
->call('updateEmail')
|
|
->assertHasNoErrors();
|
|
|
|
expect($user->fresh()->pending_email)->toBe('neu-passkey@example.com');
|
|
});
|
|
|
|
/*
|
|
* Notfall-Reset durch Super-Admins
|
|
*/
|
|
test('a super admin resets passkeys and authenticator of another account', function () {
|
|
/** @var TestCase $this */
|
|
$superAdmin = userWithPasskeyRole('admin', ['is_super_admin' => true]);
|
|
$target = userWithPasskeyRole('admin', ['email' => 'verloren@example.com']);
|
|
passkeyFor($target);
|
|
withTotp($target);
|
|
|
|
$this->actingAs($superAdmin);
|
|
|
|
Livewire::test('admin.users.edit', ['id' => $target->id])
|
|
->call('resetSecondFactors')
|
|
->assertHasNoErrors();
|
|
|
|
$target->refresh();
|
|
expect($target->passkeys()->count())->toBe(0)
|
|
->and($target->hasEnabledTwoFactorAuthentication())->toBeFalse();
|
|
|
|
Mail::assertQueued(SecurityChangeNotice::class, fn (SecurityChangeNotice $mail) => $mail->change === SecurityChange::SecondFactorsReset
|
|
&& $mail->hasTo('verloren@example.com'));
|
|
});
|
|
|
|
test('only super admins may reset second factors and never their own', function () {
|
|
$admin = userWithPasskeyRole('admin');
|
|
$superAdmin = userWithPasskeyRole('admin', ['is_super_admin' => true]);
|
|
$target = userWithPasskeyRole('customer');
|
|
passkeyFor($target);
|
|
passkeyFor($superAdmin);
|
|
|
|
$this->actingAs($admin);
|
|
Livewire::test('admin.users.edit', ['id' => $target->id])
|
|
->call('resetSecondFactors')
|
|
->assertForbidden();
|
|
|
|
$this->actingAs($superAdmin);
|
|
Livewire::test('admin.users.edit', ['id' => $superAdmin->id])
|
|
->call('resetSecondFactors')
|
|
->assertForbidden();
|
|
|
|
expect($target->passkeys()->count())->toBe(1)
|
|
->and($superAdmin->passkeys()->count())->toBe(1);
|
|
});
|