presseportale/tests/Feature/Auth/PasskeyAndStrongAdminAuthTest.php
Kevin Adametz bae8347f6c 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>
2026-10-07 16:09:35 +00:00

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);
});