diff --git a/packages/forms/src/Components/OneTimeCodeInput.php b/packages/forms/src/Components/OneTimeCodeInput.php index 4385711d60..eb5a160830 100644 --- a/packages/forms/src/Components/OneTimeCodeInput.php +++ b/packages/forms/src/Components/OneTimeCodeInput.php @@ -23,8 +23,7 @@ class OneTimeCodeInput extends Field { parent::setUp(); - $this->rule('numeric'); - $this->rule('integer'); + $this->rule('numeric'); // Integer validation does not allow leading zeros. $this->rule(static fn (OneTimeCodeInput $component): string => "digits:{$component->getLength()}"); } diff --git a/packages/panels/src/MultiFactorAuthentication/EmailCode/EmailCodeAuthentication.php b/packages/panels/src/MultiFactorAuthentication/EmailCode/EmailCodeAuthentication.php index 78effa4b2d..4058ae6f12 100644 --- a/packages/panels/src/MultiFactorAuthentication/EmailCode/EmailCodeAuthentication.php +++ b/packages/panels/src/MultiFactorAuthentication/EmailCode/EmailCodeAuthentication.php @@ -60,11 +60,16 @@ class EmailCodeAuthentication implements HasBeforeChallengeHook, MultiFactorAuth } $user->notify(app($this->getCodeNotification(), [ - 'code' => $this->google2FA->getCurrentOtp($secret ?? $this->getSecret($user)), + 'code' => $this->getCurrentCode($user, $secret), 'codeWindow' => $this->getCodeWindow(), ])); } + public function getCurrentCode(HasEmailCodeAuthentication $user, ?string $secret = null): string + { + return $this->google2FA->getCurrentOtp($secret ?? $this->getSecret($user)); + } + public function getSecret(HasEmailCodeAuthentication $user): string { return $user->getEmailCodeAuthenticationSecret(); diff --git a/packages/panels/src/MultiFactorAuthentication/GoogleTwoFactor/GoogleTwoFactorAuthentication.php b/packages/panels/src/MultiFactorAuthentication/GoogleTwoFactor/GoogleTwoFactorAuthentication.php index ffe1b06944..618930d6e8 100644 --- a/packages/panels/src/MultiFactorAuthentication/GoogleTwoFactor/GoogleTwoFactorAuthentication.php +++ b/packages/panels/src/MultiFactorAuthentication/GoogleTwoFactor/GoogleTwoFactorAuthentication.php @@ -97,6 +97,11 @@ class GoogleTwoFactorAuthentication implements MultiFactorAuthenticationProvider return $this->google2FA->generateSecretKey(); } + public function getCurrentCode(HasGoogleTwoFactorAuthentication $user, ?string $secret = null): string + { + return $this->google2FA->getCurrentOtp($secret ?? $this->getSecret($user)); + } + public function generateQRCodeDataUri(string $secret): string { /** @var HasGoogleTwoFactorAuthentication $user */ diff --git a/packages/panels/src/Pages/Auth/Login.php b/packages/panels/src/Pages/Auth/Login.php index 107ab34f93..13e55294cf 100644 --- a/packages/panels/src/Pages/Auth/Login.php +++ b/packages/panels/src/Pages/Auth/Login.php @@ -33,7 +33,7 @@ use Livewire\Attributes\Locked; /** * @property-read Action $registerAction * @property-read Schema $form - * @property-read Schema $multiFactorForm + * @property-read Schema $multiFactorChallengeForm */ class Login extends SimplePage { @@ -83,7 +83,7 @@ class Login extends SimplePage filled($this->userUndertakingMultiFactorAuthentication) && (decrypt($this->userUndertakingMultiFactorAuthentication) === $user->getAuthIdentifier()) ) { - $this->multiFactorForm->validate(); + $this->multiFactorChallengeForm->validate(); } else { foreach (Filament::getMultiFactorAuthenticationProviders() as $multiFactorAuthenticationProvider) { if (! $multiFactorAuthenticationProvider->isEnabled($user)) { @@ -98,7 +98,7 @@ class Login extends SimplePage } if (filled($this->userUndertakingMultiFactorAuthentication)) { - $this->multiFactorForm->fill(); + $this->multiFactorChallengeForm->fill(); return null; } @@ -150,7 +150,7 @@ class Login extends SimplePage return $form; } - public function multiFactorForm(Schema $form): Schema + public function multiFactorChallengeForm(Schema $form): Schema { return $form; } @@ -170,7 +170,7 @@ class Login extends SimplePage ]) ->statePath('data'), ), - 'multiFactorForm' => $this->multiFactorForm( + 'multiFactorChallengeForm' => $this->multiFactorChallengeForm( $this->makeSchema() ->schema(function (): array { if (blank($this->userUndertakingMultiFactorAuthentication)) { @@ -262,7 +262,7 @@ class Login extends SimplePage /** * @return array */ - protected function getMultiFactorFormActions(): array + protected function getMultiFactorChallengeFormActions(): array { return [ $this->getMultiFactorAuthenticateFormAction(), @@ -281,7 +281,7 @@ class Login extends SimplePage return true; } - protected function hasFullWidthMultiFactorFormActions(): bool + protected function hasFullWidthMultiFactorChallengeFormActions(): bool { return $this->hasFullWidthFormActions(); } @@ -317,7 +317,7 @@ class Login extends SimplePage ->components([ RenderHook::make(PanelsRenderHook::AUTH_LOGIN_FORM_BEFORE), $this->getFormContentComponent(), - $this->getMultiFactorFormContentComponent(), + $this->getMultiFactorChallengeFormContentComponent(), RenderHook::make(PanelsRenderHook::AUTH_LOGIN_FORM_AFTER), ]); } @@ -333,18 +333,18 @@ class Login extends SimplePage ->visible(fn (): bool => blank($this->userUndertakingMultiFactorAuthentication)); } - public function getMultiFactorFormContentComponent(): Component + public function getMultiFactorChallengeFormContentComponent(): Component { - return Form::make([NestedSchema::make('multiFactorForm')]) - ->id('multiFactorForm') + return Form::make([NestedSchema::make('multiFactorChallengeForm')]) + ->id('multiFactorChallengeForm') ->livewireSubmitHandler('authenticate') - ->footer(FormActionsDecorations::make($this->getMultiFactorFormActions()) - ->alignment($this->getMultiFactorFormActionsAlignment()) - ->fullWidth($this->hasFullWidthMultiFactorFormActions())) + ->footer(FormActionsDecorations::make($this->getMultiFactorChallengeFormActions()) + ->alignment($this->getMultiFactorChallengeFormActionsAlignment()) + ->fullWidth($this->hasFullWidthMultiFactorChallengeFormActions())) ->visible(fn (): bool => filled($this->userUndertakingMultiFactorAuthentication)); } - public function getMultiFactorFormActionsAlignment(): string | Alignment + public function getMultiFactorChallengeFormActionsAlignment(): string | Alignment { return $this->getFormActionsAlignment(); } diff --git a/tests/database/factories/UserFactory.php b/tests/database/factories/UserFactory.php index eb811e0847..68db90c23b 100644 --- a/tests/database/factories/UserFactory.php +++ b/tests/database/factories/UserFactory.php @@ -2,6 +2,8 @@ namespace Filament\Tests\Database\Factories; +use Filament\MultiFactorAuthentication\EmailCode\EmailCodeAuthentication; +use Filament\MultiFactorAuthentication\GoogleTwoFactor\GoogleTwoFactorAuthentication; use Filament\Tests\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Str; @@ -20,4 +22,23 @@ class UserFactory extends Factory 'remember_token' => Str::random(10), ]; } + + public function hasEmailCodeAuthentication(): self + { + $emailCodeAuthentication = EmailCodeAuthentication::make(); + + return $this->state(fn (): array => [ + 'email_code_authentication_secret' => $emailCodeAuthentication->generateSecret(), + ]); + } + + public function hasGoogleTwoFactorAuthentication(): self + { + $googleTwoFactorAuthentication = GoogleTwoFactorAuthentication::make(); + + return $this->state(fn (): array => [ + 'google_two_factor_authentication_secret' => $googleTwoFactorAuthentication->generateSecret(), + 'google_two_factor_authentication_recovery_codes' => $googleTwoFactorAuthentication->generateRecoveryCodes(), + ]); + } } diff --git a/tests/database/migrations/modify_users_table.php b/tests/database/migrations/modify_users_table.php new file mode 100644 index 0000000000..51c96cd3e3 --- /dev/null +++ b/tests/database/migrations/modify_users_table.php @@ -0,0 +1,36 @@ +after('password', function (Blueprint $table) { + $table->string('email_code_authentication_secret')->nullable(); + $table->string('google_two_factor_authentication_secret')->nullable(); + $table->text('google_two_factor_authentication_recovery_codes')->nullable(); + }); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn([ + 'email_code_authentication_secret', + 'google_two_factor_authentication_secret', + 'google_two_factor_authentication_recovery_codes', + ]); + }); + } +}; diff --git a/tests/src/EmailCodeAuthenticationPanelProvider.php b/tests/src/EmailCodeAuthenticationPanelProvider.php new file mode 100644 index 0000000000..9efacc8c37 --- /dev/null +++ b/tests/src/EmailCodeAuthenticationPanelProvider.php @@ -0,0 +1,45 @@ +id('email-code-authentication') + ->path('email-code-authentication') + ->login() + ->multiFactorAuthentication(EmailCodeAuthentication::make()) + ->resources([]) + ->pages([]) + ->middleware([ + EncryptCookies::class, + AddQueuedCookiesToResponse::class, + StartSession::class, + AuthenticateSession::class, + ShareErrorsFromSession::class, + VerifyCsrfToken::class, + SubstituteBindings::class, + DisableBladeIconComponents::class, + DispatchServingFilamentEvent::class, + ]) + ->authMiddleware([ + Authenticate::class, + ]); + } +} diff --git a/tests/src/GoogleTwoFactorAuthenticationPanelProvider.php b/tests/src/GoogleTwoFactorAuthenticationPanelProvider.php new file mode 100644 index 0000000000..e956e5f426 --- /dev/null +++ b/tests/src/GoogleTwoFactorAuthenticationPanelProvider.php @@ -0,0 +1,45 @@ +id('google-two-factor-authentication') + ->path('google-two-factor-authentication') + ->login() + ->multiFactorAuthentication(GoogleTwoFactorAuthentication::make()->recoverable()) + ->resources([]) + ->pages([]) + ->middleware([ + EncryptCookies::class, + AddQueuedCookiesToResponse::class, + StartSession::class, + AuthenticateSession::class, + ShareErrorsFromSession::class, + VerifyCsrfToken::class, + SubstituteBindings::class, + DisableBladeIconComponents::class, + DispatchServingFilamentEvent::class, + ]) + ->authMiddleware([ + Authenticate::class, + ]); + } +} diff --git a/tests/src/Models/User.php b/tests/src/Models/User.php index 82e98a060b..6c7ec9bec2 100644 --- a/tests/src/Models/User.php +++ b/tests/src/Models/User.php @@ -4,6 +4,8 @@ namespace Filament\Tests\Models; use Filament\Models\Contracts\FilamentUser; use Filament\Models\Contracts\HasTenants; +use Filament\MultiFactorAuthentication\GoogleTwoFactor\Contracts\HasGoogleTwoFactorAuthentication; +use Filament\MultiFactorAuthentication\GoogleTwoFactor\Contracts\HasGoogleTwoFactorAuthenticationRecovery; use Filament\Panel; use Filament\Tests\Database\Factories\UserFactory; use Illuminate\Contracts\Auth\MustVerifyEmail; @@ -14,7 +16,7 @@ use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Illuminate\Support\Collection; -class User extends Authenticatable implements FilamentUser, HasTenants, MustVerifyEmail +class User extends Authenticatable implements FilamentUser, HasGoogleTwoFactorAuthentication, HasGoogleTwoFactorAuthenticationRecovery, HasTenants, MustVerifyEmail { use HasFactory; use Notifiable; @@ -24,11 +26,24 @@ class User extends Authenticatable implements FilamentUser, HasTenants, MustVeri protected $hidden = [ 'password', 'remember_token', + 'google_two_factor_authentication_secret', + 'google_two_factor_authentication_recovery_codes', + 'email_code_authentication_secret', + ]; + + /** + * @var array + */ + protected $casts = [ + 'email_verified_at' => 'datetime', + 'google_two_factor_authentication_secret' => 'encrypted', + 'google_two_factor_authentication_recovery_codes' => 'encrypted:array', + 'email_code_authentication_secret' => 'encrypted', ]; public function canAccessPanel(Panel $panel): bool { - return in_array($panel->getId(), ['admin', 'slugs']); + return in_array($panel->getId(), ['admin', 'slugs', 'google-two-factor-authentication', 'email-code-authentication']); } public function posts(): HasMany @@ -50,4 +65,52 @@ class User extends Authenticatable implements FilamentUser, HasTenants, MustVeri { return Team::all(); } + + public function hasGoogleTwoFactorAuthentication(): bool + { + return filled($this->google_two_factor_authentication_secret); + } + + public function getGoogleTwoFactorAuthenticationSecret(): string + { + return $this->google_two_factor_authentication_secret; + } + + public function saveGoogleTwoFactorAuthenticationSecret(?string $secret): void + { + $this->google_two_factor_authentication_secret = $secret; + $this->save(); + } + + public function getGoogleTwoFactorAuthenticationRecoveryCodes(): array + { + return $this->google_two_factor_authentication_recovery_codes; + } + + public function saveGoogleTwoFactorAuthenticationRecoveryCodes(?array $codes): void + { + $this->google_two_factor_authentication_recovery_codes = $codes; + $this->save(); + } + + public function getGoogleTwoFactorAuthenticationHolderName(): string + { + return $this->email; + } + + public function hasEmailCodeAuthentication(): bool + { + return filled($this->email_code_authentication_secret); + } + + public function getEmailCodeAuthenticationSecret(): string + { + return $this->email_code_authentication_secret; + } + + public function saveEmailCodeAuthenticationSecret(?string $secret): void + { + $this->email_code_authentication_secret = $secret; + $this->save(); + } } diff --git a/tests/src/Panels/MultiFactorAuthentication/GoogleTwoFactorAuthenticationTest.php b/tests/src/Panels/MultiFactorAuthentication/GoogleTwoFactorAuthenticationTest.php new file mode 100644 index 0000000000..af922fcd4b --- /dev/null +++ b/tests/src/Panels/MultiFactorAuthentication/GoogleTwoFactorAuthenticationTest.php @@ -0,0 +1,345 @@ +hasGoogleTwoFactorAuthentication() + ->create(); + + $livewire = livewire(Login::class) + ->fillForm([ + 'email' => $userToAuthenticate->email, + 'password' => 'password', + ]) + ->assertSet('userUndertakingMultiFactorAuthentication', null) + ->call('authenticate') + ->assertNotSet('userUndertakingMultiFactorAuthentication', null) + ->assertNoRedirect(); + + expect(decrypt($livewire->instance()->userUndertakingMultiFactorAuthentication)) + ->toBe($userToAuthenticate->getKey()); + + $this->assertGuest(); +}); + +it('will authenticate the user after a valid challenge code is used', function () { + $googleTwoFactorAuthentication = Arr::first(filament::getCurrentPanel()->getMultiFactorAuthenticationProviders()); + + $userToAuthenticate = User::factory() + ->hasGoogleTwoFactorAuthentication() + ->create(); + + livewire(Login::class) + ->fillForm([ + 'email' => $userToAuthenticate->email, + 'password' => 'password', + ]) + ->call('authenticate') + ->assertNotSet('userUndertakingMultiFactorAuthentication', null) + ->assertNoRedirect() + ->fillForm([ + $googleTwoFactorAuthentication->getId() => [ + 'code' => $googleTwoFactorAuthentication->getCurrentCode($userToAuthenticate), + ], + ], 'multiFactorChallengeForm') + ->call('authenticate') + ->assertHasNoErrors() + ->assertRedirect(Filament::getUrl()); + + $this->assertAuthenticatedAs($userToAuthenticate); +}); + +it('will make the recovery code field visible when the user requests it', function () { + $googleTwoFactorAuthentication = Arr::first(filament::getCurrentPanel()->getMultiFactorAuthenticationProviders()); + + $userToAuthenticate = User::factory() + ->hasGoogleTwoFactorAuthentication() + ->create(); + + livewire(Login::class) + ->fillForm([ + 'email' => $userToAuthenticate->email, + 'password' => 'password', + ]) + ->call('authenticate') + ->assertNotSet('userUndertakingMultiFactorAuthentication', null) + ->assertNoRedirect() + ->assertFormFieldExists( + "{$googleTwoFactorAuthentication->getId()}.recoveryCode", + 'multiFactorChallengeForm', + fn (TextInput $field): bool => $field->isHidden(), + ) + ->callAction(TestAction::make('useRecoveryCode') + ->schemaComponent("multiFactorChallengeForm.{$googleTwoFactorAuthentication->getId()}.code")) + ->assertFormFieldExists( + "{$googleTwoFactorAuthentication->getId()}.recoveryCode", + 'multiFactorChallengeForm', + fn (TextInput $field): bool => $field->isVisible(), + ); +}); + +it('will authenticate the user after a valid recovery code is used', function () { + $googleTwoFactorAuthentication = Arr::first(filament::getCurrentPanel()->getMultiFactorAuthenticationProviders()); + + $userToAuthenticate = User::factory() + ->hasGoogleTwoFactorAuthentication() + ->create(); + + livewire(Login::class) + ->fillForm([ + 'email' => $userToAuthenticate->email, + 'password' => 'password', + ]) + ->call('authenticate') + ->assertNotSet('userUndertakingMultiFactorAuthentication', null) + ->assertNoRedirect() + ->callAction(TestAction::make('useRecoveryCode') + ->schemaComponent("multiFactorChallengeForm.{$googleTwoFactorAuthentication->getId()}.code")) + ->fillForm([ + $googleTwoFactorAuthentication->getId() => [ + 'recoveryCode' => Arr::random($googleTwoFactorAuthentication->getRecoveryCodes($userToAuthenticate)), + ], + ], 'multiFactorChallengeForm') + ->call('authenticate') + ->assertHasNoErrors() + ->assertRedirect(Filament::getUrl()); + + $this->assertAuthenticatedAs($userToAuthenticate); +}); + +it('will not render the challenge form after invalid login credentials are used', function () { + $userToAuthenticate = User::factory() + ->hasGoogleTwoFactorAuthentication() + ->create(); + + livewire(Login::class) + ->fillForm([ + 'email' => $userToAuthenticate->email, + 'password' => 'incorrect-password', + ]) + ->assertSet('userUndertakingMultiFactorAuthentication', null) + ->call('authenticate') + ->assertSet('userUndertakingMultiFactorAuthentication', null) + ->assertNoRedirect(); + + $this->assertGuest(); +}); + +it('will not render the challenge form if a user does not have multi-factor authentication enabled', function () { + $userToAuthenticate = User::factory()->create(); + + livewire(Login::class) + ->fillForm([ + 'email' => $userToAuthenticate->email, + 'password' => 'password', + ]) + ->assertSet('userUndertakingMultiFactorAuthentication', null) + ->call('authenticate') + ->assertSet('userUndertakingMultiFactorAuthentication', null) + ->assertRedirect(Filament::getUrl()); + + $this->assertAuthenticatedAs($userToAuthenticate); +}); + +it('will not authenticate the user when an invalid challenge code is used', function () { + $googleTwoFactorAuthentication = Arr::first(filament::getCurrentPanel()->getMultiFactorAuthenticationProviders()); + + $userToAuthenticate = User::factory() + ->hasGoogleTwoFactorAuthentication() + ->create(); + + livewire(Login::class) + ->fillForm([ + 'email' => $userToAuthenticate->email, + 'password' => 'password', + ]) + ->call('authenticate') + ->assertNotSet('userUndertakingMultiFactorAuthentication', null) + ->assertNoRedirect() + ->fillForm([ + $googleTwoFactorAuthentication->getId() => [ + 'code' => ($googleTwoFactorAuthentication->getCurrentCode($userToAuthenticate) === '000000') + ? '111111' + : '000000', + ], + ], 'multiFactorChallengeForm') + ->call('authenticate') + ->assertHasFormErrors([ + "{$googleTwoFactorAuthentication->getId()}.code", + ], 'multiFactorChallengeForm') + ->assertNoRedirect(); + + $this->assertGuest(); +}); + +test('challenge codes are required', function () { + $googleTwoFactorAuthentication = Arr::first(filament::getCurrentPanel()->getMultiFactorAuthenticationProviders()); + + $userToAuthenticate = User::factory() + ->hasGoogleTwoFactorAuthentication() + ->create(); + + livewire(Login::class) + ->fillForm([ + 'email' => $userToAuthenticate->email, + 'password' => 'password', + ]) + ->call('authenticate') + ->assertNotSet('userUndertakingMultiFactorAuthentication', null) + ->assertNoRedirect() + ->fillForm([ + $googleTwoFactorAuthentication->getId() => [ + 'code' => '', + ], + ], 'multiFactorChallengeForm') + ->call('authenticate') + ->assertHasFormErrors([ + "{$googleTwoFactorAuthentication->getId()}.code" => 'required', + ], 'multiFactorChallengeForm') + ->assertNoRedirect(); + + $this->assertGuest(); +}); + +test('challenge codes must be numeric', function () { + $googleTwoFactorAuthentication = Arr::first(filament::getCurrentPanel()->getMultiFactorAuthenticationProviders()); + + $userToAuthenticate = User::factory() + ->hasGoogleTwoFactorAuthentication() + ->create(); + + livewire(Login::class) + ->fillForm([ + 'email' => $userToAuthenticate->email, + 'password' => 'password', + ]) + ->call('authenticate') + ->assertNotSet('userUndertakingMultiFactorAuthentication', null) + ->assertNoRedirect() + ->fillForm([ + $googleTwoFactorAuthentication->getId() => [ + 'code' => Str::random(6), + ], + ], 'multiFactorChallengeForm') + ->call('authenticate') + ->assertHasFormErrors([ + "{$googleTwoFactorAuthentication->getId()}.code" => 'numeric', + ], 'multiFactorChallengeForm') + ->assertNoRedirect(); + + $this->assertGuest(); +}); + +test('challenge codes must be 6 digits', function () { + $googleTwoFactorAuthentication = Arr::first(filament::getCurrentPanel()->getMultiFactorAuthenticationProviders()); + + $userToAuthenticate = User::factory() + ->hasGoogleTwoFactorAuthentication() + ->create(); + + livewire(Login::class) + ->fillForm([ + 'email' => $userToAuthenticate->email, + 'password' => 'password', + ]) + ->call('authenticate') + ->assertNotSet('userUndertakingMultiFactorAuthentication', null) + ->assertNoRedirect() + ->fillForm([ + $googleTwoFactorAuthentication->getId() => [ + 'code' => Str::limit($googleTwoFactorAuthentication->getCurrentCode($userToAuthenticate), limit: 5, end: ''), + ], + ], 'multiFactorChallengeForm') + ->call('authenticate') + ->assertHasFormErrors([ + "{$googleTwoFactorAuthentication->getId()}.code" => 'digits', + ], 'multiFactorChallengeForm') + ->assertNoRedirect(); + + $this->assertGuest(); +}); + +it('will not authenticate the user when an invalid recovery code is used', function () { + $googleTwoFactorAuthentication = Arr::first(filament::getCurrentPanel()->getMultiFactorAuthenticationProviders()); + + $userToAuthenticate = User::factory() + ->hasGoogleTwoFactorAuthentication() + ->create(); + + livewire(Login::class) + ->fillForm([ + 'email' => $userToAuthenticate->email, + 'password' => 'password', + ]) + ->call('authenticate') + ->assertNotSet('userUndertakingMultiFactorAuthentication', null) + ->assertNoRedirect() + ->assertFormFieldExists( + "{$googleTwoFactorAuthentication->getId()}.recoveryCode", + 'multiFactorChallengeForm', + fn (TextInput $field): bool => $field->isHidden(), + ) + ->callAction(TestAction::make('useRecoveryCode') + ->schemaComponent("multiFactorChallengeForm.{$googleTwoFactorAuthentication->getId()}.code")) + ->assertFormFieldExists( + "{$googleTwoFactorAuthentication->getId()}.recoveryCode", + 'multiFactorChallengeForm', + fn (TextInput $field): bool => $field->isVisible(), + ) + ->fillForm([ + $googleTwoFactorAuthentication->getId() => [ + 'recoveryCode' => 'invalid-recovery-code', + ], + ], 'multiFactorChallengeForm') + ->call('authenticate') + ->assertHasFormErrors([ + "{$googleTwoFactorAuthentication->getId()}.recoveryCode", + ], 'multiFactorChallengeForm') + ->assertNoRedirect(); + + $this->assertGuest(); +}); + +it('will not authenticate the user with a valid recovery code if recovery is disabled', function () { + $googleTwoFactorAuthentication = Arr::first(Filament::getCurrentPanel()->getMultiFactorAuthenticationProviders()) + ->recoverable(false); + + $userToAuthenticate = User::factory() + ->hasGoogleTwoFactorAuthentication() + ->create(); + + livewire(Login::class) + ->fillForm([ + 'email' => $userToAuthenticate->email, + 'password' => 'password', + ]) + ->call('authenticate') + ->assertNotSet('userUndertakingMultiFactorAuthentication', null) + ->assertNoRedirect() + ->fillForm([ + $googleTwoFactorAuthentication->getId() => [ + 'recoveryCode' => Arr::random($googleTwoFactorAuthentication->getRecoveryCodes($userToAuthenticate)), + ], + ], 'multiFactorChallengeForm') + ->call('authenticate') + ->assertHasErrors() + ->assertNoRedirect(); + + $this->assertGuest(); +}); diff --git a/tests/src/TestCase.php b/tests/src/TestCase.php index 8ef116c9af..f39eaccf0c 100644 --- a/tests/src/TestCase.php +++ b/tests/src/TestCase.php @@ -47,11 +47,13 @@ abstract class TestCase extends BaseTestCase WidgetsServiceProvider::class, AdminPanelProvider::class, CustomPanelProvider::class, - SlugsPanelProvider::class, - SingleDomainPanel::class, - MultiDomainPanel::class, - TenancyPanelProvider::class, + EmailCodeAuthenticationPanelProvider::class, + GoogleTwoFactorAuthenticationPanelProvider::class, DomainTenancyPanelProvider::class, + MultiDomainPanel::class, + SingleDomainPanel::class, + SlugsPanelProvider::class, + TenancyPanelProvider::class, ]; sort($providers);