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