From 4bc642dde77899c028a2b44d9736fd616ef212af Mon Sep 17 00:00:00 2001 From: Dan Harrin Date: Wed, 7 May 2025 09:42:57 +0100 Subject: [PATCH] Refactor email authentication to issue codes randomly instead of TOTPs --- .../02-multi-factor-authentication.md | 49 +++------- .../DisableEmailAuthenticationAction.php | 6 +- .../SetUpEmailAuthenticationAction.php | 38 +++----- .../Contracts/HasEmailAuthentication.php | 4 +- .../MultiFactor/Email/EmailAuthentication.php | 91 ++++++++++-------- .../VerifyEmailAuthentication.php | 8 +- tests/database/factories/UserFactory.php | 5 +- .../migrations/modify_users_table.php | 5 +- tests/src/Fixtures/Models/User.php | 21 +---- .../DisableEmailAuthenticationActionTest.php | 58 ++++-------- .../EmailAuthenticationChallengeTest.php | 35 ++++--- .../SetUpEmailAuthenticationActionTest.php | 93 ++++++------------- 12 files changed, 163 insertions(+), 250 deletions(-) diff --git a/docs/07-users/02-multi-factor-authentication.md b/docs/07-users/02-multi-factor-authentication.md index 7ef339ad41..bfe3444f9e 100644 --- a/docs/07-users/02-multi-factor-authentication.md +++ b/docs/07-users/02-multi-factor-authentication.md @@ -12,7 +12,7 @@ When MFA is enabled, users must perform an extra step before they are authentica Filament includes two methods of MFA which you can enable out of the box: - [App authentication](#app-authentication) uses a Google Authenticator-compatible app (such as the Google Authenticator, Authy, or Microsoft Authenticator apps) to generate a time-based one-time password (TOTP) that is used to verify the user. -- [Email authentication](#email-authentication) sends a time-based one-time password (TOTP) to the user's email address, which they must enter to verify their identity. +- [Email authentication](#email-authentication) sends a one-time code to the user's email address, which they must enter to verify their identity. In Filament, users set up multi-factor authentication from their [profile page](overview#authentication-features). If you use Filament's profile page feature, setting up multi-factor authentication will automatically add the correct UI elements to the profile page: @@ -315,20 +315,20 @@ public function panel(Panel $panel): Panel ## Email authentication -Email authentication sends the user time-based one-time passwords (TOTP) to their email address, which they must enter to verify their identity. These TOTP codes are generated using the same algorithm as [app authentication](#app-authentication). +Email authentication sends the user one-time codes to their email address, which they must enter to verify their identity. -To enable email authentication in a panel, you must first add a new column to your `users` table (or whichever table is being used for your "authenticatable" Eloquent model in this panel). The column needs to store the secret key used to generate and verify the time-based one-time passwords. It can be a normal `text()` column in a migration: +To enable email authentication in a panel, you must first add a new column to your `users` table (or whichever table is being used for your "authenticatable" Eloquent model in this panel). The column needs to store a boolean indicating whether or not email authentication is enabled: ```php use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; Schema::table('users', function (Blueprint $table) { - $table->text('email_authentication_secret')->nullable(); + $table->boolean('has_email_authentication')->default(false); }); ``` -In the `User` model, you need to ensure that this column is encrypted and `$hidden`, since this is incredibly sensitive information that should be stored securely: +In the `User` model, you need to ensure that this column is cast to a boolean: ```php use Filament\Models\Contracts\FilamentUser; @@ -338,28 +338,19 @@ use Illuminate\Foundation\Auth\User as Authenticatable; class User extends Authenticatable implements FilamentUser, MustVerifyEmail { // ... - - /** - * @var array - */ - protected $hidden = [ - // ... - 'email_authentication_secret', - ]; - /** * @var array */ protected $casts = [ // ... - 'email_authentication_secret' => 'encrypted', + 'has_email_authentication' => 'boolean', ]; // ... } ``` -Next, you should implement the `HasEmailAuthentication` interface on the `User` model. This provides Filament with the necessary methods to interact with the secret code and other information about the integration: +Next, you should implement the `HasEmailAuthentication` interface on the `User` model. This provides Filament with the necessary methods to interact with the column that indicates whether or not email authentication is enabled: ```php use Filament\Auth\MultiFactor\Email\Contracts\HasEmailAuthentication; @@ -374,32 +365,22 @@ class User extends Authenticatable implements FilamentUser, HasEmailAuthenticati public function hasEmailAuthentication(): bool { // This method should return true if the user has enabled email authentication. - // We know that the user has enabled it if the secret is not null, but if your app has - // another mechanism for disabling email authentication even when a secret is - // set, you should check that here. - return filled($this->email_authentication_secret); + return $this->has_email_authentication; } - public function getEmailAuthenticationSecret(): ?string + public function toggleEmailAuthentication(bool $condition): void { - // This method should return the user's saved email authentication secret. + // This method should save whether or not the user has enabled email authentication. - return $this->email_authentication_secret; - } - - public function saveEmailAuthenticationSecret(?string $secret): void - { - // This method should save the user's email authentication secret. - - $this->email_authentication_secret = $secret; + $this->has_email_authentication = $condition; $this->save(); } } ``` Finally, you should activate the email authentication feature in your panel. To do this, use the `multiFactorAuthentication()` method in the [configuration](../panel-configuration), and pass an `EmailAuthentication` instance to it: @@ -420,9 +401,9 @@ public function panel(Panel $panel): Panel ### Changing the email code expiration time -Email codes are issued using a time-based one-time password (TOTP) algorithm, which means that they are only valid for a short period of time before and after the time they are generated. The time is defined in a "window" of time. By default, Filament uses an expiration window of `8`, which allows the code to be valid for 4 minutes after it is generated. +Email codes are issued with an lifetime of 4 minutes, after which they expire. -To change the window, for example to only be valid for 2 minutes after it is generated, you can use the `codeWindow()` method on the `EmailAuthentication` instance, set to `4`: +To change the expiration period, for example to only be valid for 2 minutes after codes are generated, you can use the `codeExpiryMinutes()` method on the `EmailAuthentication` instance, set to `2`: ```php use Filament\Auth\MultiFactor\Email\EmailAuthentication; @@ -434,7 +415,7 @@ public function panel(Panel $panel): Panel // ... ->multiFactorAuthentication([ EmailAuthentication::make() - ->codeWindow(4), + ->codeExpiryMinutes(2), ]); } ``` diff --git a/packages/panels/src/Auth/MultiFactor/Email/Actions/DisableEmailAuthenticationAction.php b/packages/panels/src/Auth/MultiFactor/Email/Actions/DisableEmailAuthenticationAction.php index 7da6da6865..7ae3e4f94e 100644 --- a/packages/panels/src/Auth/MultiFactor/Email/Actions/DisableEmailAuthenticationAction.php +++ b/packages/panels/src/Auth/MultiFactor/Email/Actions/DisableEmailAuthenticationAction.php @@ -63,12 +63,12 @@ class DisableEmailAuthenticationAction ]) ->modalSubmitAction(fn (Action $action) => $action ->label(__('filament-panels::auth/multi-factor/email/actions/disable.modal.actions.submit.label'))) - ->action(function () use ($emailAuthentication): void { + ->action(function (): void { /** @var HasEmailAuthentication $user */ $user = Filament::auth()->user(); - DB::transaction(function () use ($emailAuthentication, $user): void { - $emailAuthentication->saveSecret($user, null); + DB::transaction(function () use ($user): void { + $user->toggleEmailAuthentication(false); }); Notification::make() diff --git a/packages/panels/src/Auth/MultiFactor/Email/Actions/SetUpEmailAuthenticationAction.php b/packages/panels/src/Auth/MultiFactor/Email/Actions/SetUpEmailAuthenticationAction.php index c1035a934f..e863d5dce5 100644 --- a/packages/panels/src/Auth/MultiFactor/Email/Actions/SetUpEmailAuthenticationAction.php +++ b/packages/panels/src/Auth/MultiFactor/Email/Actions/SetUpEmailAuthenticationAction.php @@ -4,7 +4,6 @@ namespace Filament\Auth\MultiFactor\Email\Actions; use Closure; use Filament\Actions\Action; -use Filament\Actions\Contracts\HasActions; use Filament\Auth\MultiFactor\Email\Contracts\HasEmailAuthentication; use Filament\Auth\MultiFactor\Email\EmailAuthentication; use Filament\Facades\Filament; @@ -24,35 +23,28 @@ class SetUpEmailAuthenticationAction ->color('primary') ->icon(Heroicon::LockClosed) ->link() - ->mountUsing(function (HasActions $livewire) use ($emailAuthentication): void { - $livewire->mergeMountedActionArguments([ - 'encrypted' => encrypt([ - 'secret' => $secret = $emailAuthentication->generateSecret(), - 'userId' => Filament::auth()->id(), - ]), - ]); - + ->mountUsing(function () use ($emailAuthentication): void { /** @var HasEmailAuthentication $user */ $user = Filament::auth()->user(); - $emailAuthentication->sendCode($user, $secret); + $emailAuthentication->sendCode($user); }) ->modalWidth(Width::Large) ->modalIcon(Heroicon::OutlinedLockClosed) ->modalIconColor('primary') ->modalHeading(__('filament-panels::auth/multi-factor/email/actions/set-up.modal.heading')) ->modalDescription(__('filament-panels::auth/multi-factor/email/actions/set-up.modal.description')) - ->schema(fn (array $arguments): array => [ + ->schema([ OneTimeCodeInput::make('code') ->label(__('filament-panels::auth/multi-factor/email/actions/set-up.modal.form.code.label')) ->belowContent(Action::make('resend') ->label(__('filament-panels::auth/multi-factor/email/actions/set-up.modal.form.code.actions.resend.label')) ->link() - ->action(function () use ($arguments, $emailAuthentication): void { + ->action(function () use ($emailAuthentication): void { /** @var HasEmailAuthentication $user */ $user = Filament::auth()->user(); - $emailAuthentication->sendCode($user, decrypt($arguments['encrypted'])['secret']); + $emailAuthentication->sendCode($user); Notification::make() ->title(__('filament-panels::auth/multi-factor/email/actions/set-up.modal.form.code.actions.resend.notifications.resent.title')) @@ -61,9 +53,9 @@ class SetUpEmailAuthenticationAction })) ->validationAttribute(__('filament-panels::auth/multi-factor/email/actions/set-up.modal.form.code.validation_attribute')) ->required() - ->rule(function () use ($arguments, $emailAuthentication): Closure { - return function (string $attribute, $value, Closure $fail) use ($arguments, $emailAuthentication): void { - if ($emailAuthentication->verifyCode($value, decrypt($arguments['encrypted'])['secret'])) { + ->rule(function () use ($emailAuthentication): Closure { + return function (string $attribute, $value, Closure $fail) use ($emailAuthentication): void { + if ($emailAuthentication->verifyCode($value)) { return; } @@ -73,20 +65,12 @@ class SetUpEmailAuthenticationAction ]) ->modalSubmitAction(fn (Action $action) => $action ->label(__('filament-panels::auth/multi-factor/email/actions/set-up.modal.actions.submit.label'))) - ->action(function (array $arguments) use ($emailAuthentication): void { + ->action(function (): void { /** @var Authenticatable&HasEmailAuthentication $user */ $user = Filament::auth()->user(); - $encrypted = decrypt($arguments['encrypted']); - - if ($user->getAuthIdentifier() !== $encrypted['userId']) { - // Avoid encrypted arguments being passed between users by verifying that the authenticated - // user is the same as the user that the encrypted arguments were issued for. - return; - } - - DB::transaction(function () use ($emailAuthentication, $encrypted, $user): void { - $emailAuthentication->saveSecret($user, $encrypted['secret']); + DB::transaction(function () use ($user): void { + $user->toggleEmailAuthentication(true); }); Notification::make() diff --git a/packages/panels/src/Auth/MultiFactor/Email/Contracts/HasEmailAuthentication.php b/packages/panels/src/Auth/MultiFactor/Email/Contracts/HasEmailAuthentication.php index 897d595e1e..b4627bf9d0 100644 --- a/packages/panels/src/Auth/MultiFactor/Email/Contracts/HasEmailAuthentication.php +++ b/packages/panels/src/Auth/MultiFactor/Email/Contracts/HasEmailAuthentication.php @@ -6,7 +6,5 @@ interface HasEmailAuthentication { public function hasEmailAuthentication(): bool; - public function getEmailAuthenticationSecret(): ?string; - - public function saveEmailAuthenticationSecret(?string $secret): void; + public function toggleEmailAuthentication(bool $condition): void; } diff --git a/packages/panels/src/Auth/MultiFactor/Email/EmailAuthentication.php b/packages/panels/src/Auth/MultiFactor/Email/EmailAuthentication.php index 4b1a719de2..e6fb6c1c2e 100644 --- a/packages/panels/src/Auth/MultiFactor/Email/EmailAuthentication.php +++ b/packages/panels/src/Auth/MultiFactor/Email/EmailAuthentication.php @@ -19,21 +19,17 @@ use Filament\Schemas\Components\Actions; use Filament\Schemas\Components\Component; use Filament\Schemas\Components\Text; use Illuminate\Contracts\Auth\Authenticatable; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\RateLimiter; -use PragmaRX\Google2FAQRCode\Google2FA; class EmailAuthentication implements HasBeforeChallengeHook, MultiFactorAuthenticationProvider { - /** - * 8 keys (respectively 4 minutes) past and future - */ - protected int $codeWindow = 8; + protected int $codeExpiryMinutes = 4; protected string $codeNotification = VerifyEmailAuthentication::class; - public function __construct( - protected Google2FA $google2FA, - ) {} + protected ?Closure $generateCodesUsing = null; public static function make(): static { @@ -59,60 +55,77 @@ class EmailAuthentication implements HasBeforeChallengeHook, MultiFactorAuthenti return $user->hasEmailAuthentication(); } - public function sendCode(HasEmailAuthentication $user, ?string $secret = null): void + public function sendCode(HasEmailAuthentication $user): void { + if (! ($user instanceof Model)) { + throw new Exception('The [' . $user::class . '] class must be an instance of [' . Model::class . '] to use email authentication.'); + } + if (! method_exists($user, 'notify')) { $userClass = $user::class; throw new Exception("Model [{$userClass}] does not have a [notify()] method."); } - $rateLimitingKey = 'filament_email_authentication.' . md5($secret ?? $this->getSecret($user)); + $rateLimitingKey = "filament_email_authentication.{$user->getKey()}"; - if (RateLimiter::tooManyAttempts($rateLimitingKey, maxAttempts: 1)) { + if (RateLimiter::tooManyAttempts($rateLimitingKey, maxAttempts: 2)) { return; } RateLimiter::hit($rateLimitingKey); + $code = $this->generateCode(); + $codeExpiryMinutes = $this->getCodeExpiryMinutes(); + + session()->put('filament_email_authentication_code', Hash::make($code)); + session()->put('filament_email_authentication_code_expires_at', now()->addMinutes($codeExpiryMinutes)); + $user->notify(app($this->getCodeNotification(), [ - 'code' => $this->getCurrentCode($user, $secret), - 'codeWindow' => $this->getCodeWindow(), + 'code' => $code, + 'codeExpiryMinutes' => $codeExpiryMinutes, ])); } - public function getCurrentCode(HasEmailAuthentication $user, ?string $secret = null): string + public function enableEmailAuthentication(HasEmailAuthentication $user): void { - return $this->google2FA->getCurrentOtp($secret ?? $this->getSecret($user)); + $user->toggleEmailAuthentication(true); } - public function getSecret(HasEmailAuthentication $user): string + public function generateCodesUsing(?Closure $callback): static { - $secret = $user->getEmailAuthenticationSecret(); + $this->generateCodesUsing = $callback; - if (blank($secret)) { - throw new Exception('The user does not have an email authentication secret.'); + return $this; + } + + public function generateCode(): string + { + if ($this->generateCodesUsing) { + return ($this->generateCodesUsing)(); } - return $secret; + return str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT); } - public function saveSecret(HasEmailAuthentication $user, ?string $secret): void + public function verifyCode(string $code): bool { - $user->saveEmailAuthenticationSecret($secret); - } + $codeHash = session('filament_email_authentication_code'); + $codeExpiresAt = session('filament_email_authentication_code_expires_at'); - public function generateSecret(): string - { - return $this->google2FA->generateSecretKey(); - } + if ( + blank($codeHash) + || blank($codeExpiresAt) + || (! Hash::check($code, $codeHash)) + || now()->greaterThan($codeExpiresAt) + ) { + return false; + } - public function verifyCode(string $code, ?string $secret = null): bool - { - /** @var HasEmailAuthentication $user */ - $user = Filament::auth()->user(); + session()->forget('filament_email_authentication_code'); + session()->forget('filament_email_authentication_code_expires_at'); - return $this->google2FA->verifyKey($secret ?? $this->getSecret($user), $code, $this->getCodeWindow()); + return true; } /** @@ -150,16 +163,16 @@ class EmailAuthentication implements HasBeforeChallengeHook, MultiFactorAuthenti ]; } - public function codeWindow(int $window): static + public function codeExpiryMinutes(int $minutes): static { - $this->codeWindow = $window; + $this->codeExpiryMinutes = $minutes; return $this; } - public function getCodeWindow(): int + public function getCodeExpiryMinutes(): int { - return $this->codeWindow; + return $this->codeExpiryMinutes; } public function beforeChallenge(Authenticatable $user): void @@ -192,9 +205,9 @@ class EmailAuthentication implements HasBeforeChallengeHook, MultiFactorAuthenti ->send(); })) ->required() - ->rule(function () use ($user): Closure { - return function (string $attribute, $value, Closure $fail) use ($user): void { - if ($this->verifyCode($value, $this->getSecret($user))) { + ->rule(function (): Closure { + return function (string $attribute, $value, Closure $fail): void { + if ($this->verifyCode($value)) { return; } diff --git a/packages/panels/src/Auth/MultiFactor/Email/Notifications/VerifyEmailAuthentication.php b/packages/panels/src/Auth/MultiFactor/Email/Notifications/VerifyEmailAuthentication.php index f0fa3220b2..2adbbfded6 100644 --- a/packages/panels/src/Auth/MultiFactor/Email/Notifications/VerifyEmailAuthentication.php +++ b/packages/panels/src/Auth/MultiFactor/Email/Notifications/VerifyEmailAuthentication.php @@ -15,7 +15,7 @@ class VerifyEmailAuthentication extends Notification implements ShouldQueue public function __construct( public string $code, - public int $codeWindow, + public int $codeExpiryMinutes, ) {} /** @@ -32,11 +32,9 @@ class VerifyEmailAuthentication extends Notification implements ShouldQueue throw new Exception('The user model must implement the [' . HasEmailAuthentication::class . '] interface to use email authentication.'); } - $expiryMinutes = ceil($this->codeWindow / 2); - return (new MailMessage) ->subject(__('filament-panels::auth/multi-factor/email/notifications/verify-email-authentication.subject')) - ->line(trans_choice('filament-panels::auth/multi-factor/email/notifications/verify-email-authentication.lines.0', $expiryMinutes, ['code' => $this->code, 'minutes' => $expiryMinutes])) - ->line(trans_choice('filament-panels::auth/multi-factor/email/notifications/verify-email-authentication.lines.1', $expiryMinutes, ['code' => $this->code, 'minutes' => $expiryMinutes])); + ->line(trans_choice('filament-panels::auth/multi-factor/email/notifications/verify-email-authentication.lines.0', $this->codeExpiryMinutes, ['code' => $this->code, 'minutes' => $this->codeExpiryMinutes])) + ->line(trans_choice('filament-panels::auth/multi-factor/email/notifications/verify-email-authentication.lines.1', $this->codeExpiryMinutes, ['code' => $this->code, 'minutes' => $this->codeExpiryMinutes])); } } diff --git a/tests/database/factories/UserFactory.php b/tests/database/factories/UserFactory.php index b235e8034a..658ca96895 100644 --- a/tests/database/factories/UserFactory.php +++ b/tests/database/factories/UserFactory.php @@ -3,7 +3,6 @@ namespace Filament\Tests\Database\Factories; use Filament\Auth\MultiFactor\App\AppAuthentication; -use Filament\Auth\MultiFactor\Email\EmailAuthentication; use Filament\Tests\Fixtures\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Facades\Hash; @@ -26,10 +25,8 @@ class UserFactory extends Factory public function hasEmailAuthentication(): self { - $emailAuthentication = EmailAuthentication::make(); - return $this->state(fn (): array => [ - 'email_authentication_secret' => $emailAuthentication->generateSecret(), + 'has_email_authentication' => true, ]); } diff --git a/tests/database/migrations/modify_users_table.php b/tests/database/migrations/modify_users_table.php index 63b11c7344..86f0a80342 100644 --- a/tests/database/migrations/modify_users_table.php +++ b/tests/database/migrations/modify_users_table.php @@ -14,7 +14,7 @@ return new class extends Migration Schema::table('users', function (Blueprint $table): void { $table->after('password', function (Blueprint $table): void { $table->json('json')->nullable(); - $table->string('email_authentication_secret')->nullable(); + $table->boolean('has_email_authentication')->default(false); $table->string('app_authentication_secret')->nullable(); $table->text('app_authentication_recovery_codes')->nullable(); }); @@ -28,7 +28,8 @@ return new class extends Migration { Schema::table('users', function (Blueprint $table): void { $table->dropColumn([ - 'email_authentication_secret', + 'json', + 'has_email_authentication', 'app_authentication_secret', 'app_authentication_recovery_codes', ]); diff --git a/tests/src/Fixtures/Models/User.php b/tests/src/Fixtures/Models/User.php index e000f4abb5..60a6a7c1d2 100644 --- a/tests/src/Fixtures/Models/User.php +++ b/tests/src/Fixtures/Models/User.php @@ -30,7 +30,6 @@ class User extends Authenticatable implements FilamentUser, HasAppAuthentication 'remember_token', 'app_authentication_secret', 'app_authentication_recovery_codes', - 'email_authentication_secret', ]; /** @@ -41,7 +40,7 @@ class User extends Authenticatable implements FilamentUser, HasAppAuthentication 'email_verified_at' => 'datetime', 'app_authentication_secret' => 'encrypted', 'app_authentication_recovery_codes' => 'encrypted:array', - 'email_authentication_secret' => 'encrypted', + 'has_email_authentication' => 'boolean', ]; public function canAccessPanel(Panel $panel): bool @@ -69,14 +68,9 @@ class User extends Authenticatable implements FilamentUser, HasAppAuthentication return Team::all(); } - public function hasAppAuthentication(): bool - { - return filled($this->app_authentication_secret); - } - public function getAppAuthenticationSecret(): ?string { - return $this->app_authentication_secret ?? ''; + return $this->app_authentication_secret; } public function saveAppAuthenticationSecret(?string $secret): void @@ -103,17 +97,12 @@ class User extends Authenticatable implements FilamentUser, HasAppAuthentication public function hasEmailAuthentication(): bool { - return filled($this->email_authentication_secret); + return (bool) $this->has_email_authentication; } - public function getEmailAuthenticationSecret(): ?string + public function toggleEmailAuthentication(bool $condition): void { - return $this->email_authentication_secret; - } - - public function saveEmailAuthenticationSecret(?string $secret): void - { - $this->email_authentication_secret = $secret; + $this->has_email_authentication = $condition; $this->save(); } diff --git a/tests/src/Panels/Auth/MultiFactor/Email/DisableEmailAuthenticationActionTest.php b/tests/src/Panels/Auth/MultiFactor/Email/DisableEmailAuthenticationActionTest.php index d42bdd3d96..16c92b94aa 100644 --- a/tests/src/Panels/Auth/MultiFactor/Email/DisableEmailAuthenticationActionTest.php +++ b/tests/src/Panels/Auth/MultiFactor/Email/DisableEmailAuthenticationActionTest.php @@ -1,6 +1,7 @@ getMultiFactorAuthenticationProviders()); $user = auth()->user(); @@ -33,31 +34,26 @@ it('can disable authentication when valid challenge code is used', function (): expect($user->hasEmailAuthentication()) ->toBeTrue(); - $originalSecret = $user->getEmailAuthenticationSecret(); - - expect($originalSecret) - ->not()->toBeNull(); + $code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT); + $emailAuthentication->generateCodesUsing(fn (): string => $code); livewire(EditProfile::class) ->callAction( TestAction::make('disableEmailAuthentication') ->schemaComponent('email_code', schema: 'content'), - ['code' => $emailAuthentication->getCurrentCode($user)], + ['code' => $code], ) ->assertHasNoFormErrors(); expect($user->hasEmailAuthentication()) ->toBeFalse(); - expect($user->getEmailAuthenticationSecret()) - ->toBeEmpty(); - - Notification::assertSentTo($user, VerifyEmailAuthentication::class, function (VerifyEmailAuthentication $notification) use ($emailAuthentication, $originalSecret, $user): bool { - if ($notification->codeWindow !== $emailAuthentication->getCodeWindow()) { + Notification::assertSentTo($user, VerifyEmailAuthentication::class, function (VerifyEmailAuthentication $notification) use ($code, $emailAuthentication): bool { + if ($notification->codeExpiryMinutes !== $emailAuthentication->getCodeExpiryMinutes()) { return false; } - return $notification->code === $emailAuthentication->getCurrentCode($user, $originalSecret); + return $notification->code === $code; }); }); @@ -79,7 +75,7 @@ it('can resend the code to the user', function (): void { Notification::assertSentTimes(VerifyEmailAuthentication::class, 2); }); -it('can resend the code to the user more than once per minute', function (): void { +it('can resend the code to the user more than twice per minute', function (): void { $this->travelTo(now()->subMinute()); $livewire = livewire(EditProfile::class) @@ -92,7 +88,13 @@ it('can resend the code to the user more than once per minute', function (): voi ->callAction(TestAction::make('resend') ->schemaComponent('code')); - Notification::assertSentTimes(VerifyEmailAuthentication::class, 1); + Notification::assertSentTimes(VerifyEmailAuthentication::class, 2); + + $livewire + ->callAction(TestAction::make('resend') + ->schemaComponent('code')); + + Notification::assertSentTimes(VerifyEmailAuthentication::class, 2); $this->travelBack(); @@ -100,33 +102,25 @@ it('can resend the code to the user more than once per minute', function (): voi ->callAction(TestAction::make('resend') ->schemaComponent('code')); - Notification::assertSentTimes(VerifyEmailAuthentication::class, 2); + Notification::assertSentTimes(VerifyEmailAuthentication::class, 3); }); it('will not disable authentication when an invalid code is used', function (): void { - $emailAuthentication = Arr::first(Filament::getCurrentOrDefaultPanel()->getMultiFactorAuthenticationProviders()); - $user = auth()->user(); expect($user->hasEmailAuthentication()) ->toBeTrue(); - expect($user->getEmailAuthenticationSecret()) - ->not()->toBeNull(); - livewire(EditProfile::class) ->callAction( TestAction::make('disableEmailAuthentication') ->schemaComponent('email_code', schema: 'content'), - ['code' => ($emailAuthentication->getCurrentCode($user) === '000000') ? '111111' : '000000'], + ['code' => str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT)], ) ->assertHasFormErrors(); expect($user->hasEmailAuthentication()) ->toBeTrue(); - - expect($user->getEmailAuthenticationSecret()) - ->not()->toBeNull(); }); test('codes are required', function (): void { @@ -135,9 +129,6 @@ test('codes are required', function (): void { expect($user->hasEmailAuthentication()) ->toBeTrue(); - expect($user->getEmailAuthenticationSecret()) - ->not()->toBeNull(); - livewire(EditProfile::class) ->callAction( TestAction::make('disableEmailAuthentication') @@ -150,27 +141,19 @@ test('codes are required', function (): void { expect($user->hasEmailAuthentication()) ->toBeTrue(); - - expect($user->getEmailAuthenticationSecret()) - ->not()->toBeNull(); }); test('codes must be 6 digits', function (): void { - $emailAuthentication = Arr::first(Filament::getCurrentOrDefaultPanel()->getMultiFactorAuthenticationProviders()); - $user = auth()->user(); expect($user->hasEmailAuthentication()) ->toBeTrue(); - expect($user->getEmailAuthenticationSecret()) - ->not()->toBeNull(); - livewire(EditProfile::class) ->callAction( TestAction::make('disableEmailAuthentication') ->schemaComponent('email_code', schema: 'content'), - ['code' => Str::limit($emailAuthentication->getCurrentCode($user), limit: 5, end: '')], + ['code' => str_pad((string) random_int(0, 99999), 5, '0', STR_PAD_LEFT)], ) ->assertHasFormErrors([ 'code' => 'digits', @@ -178,7 +161,4 @@ test('codes must be 6 digits', function (): void { expect($user->hasEmailAuthentication()) ->toBeTrue(); - - expect($user->getEmailAuthenticationSecret()) - ->not()->toBeNull(); }); diff --git a/tests/src/Panels/Auth/MultiFactor/Email/EmailAuthenticationChallengeTest.php b/tests/src/Panels/Auth/MultiFactor/Email/EmailAuthenticationChallengeTest.php index ca0798f57c..b40d4f5648 100644 --- a/tests/src/Panels/Auth/MultiFactor/Email/EmailAuthenticationChallengeTest.php +++ b/tests/src/Panels/Auth/MultiFactor/Email/EmailAuthenticationChallengeTest.php @@ -1,6 +1,7 @@ getMultiFactorAuthenticationProviders()); $userToAuthenticate = User::factory() ->hasEmailAuthentication() ->create(); + $code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT); + $emailAuthentication->generateCodesUsing(fn (): string => $code); + $livewire = livewire(Login::class) ->fillForm([ 'email' => $userToAuthenticate->email, @@ -42,22 +47,26 @@ it('can render the challenge form after valid login credentials are successfully $this->assertGuest(); - Notification::assertSentTo($userToAuthenticate, VerifyEmailAuthentication::class, function (VerifyEmailAuthentication $notification) use ($emailAuthentication, $userToAuthenticate): bool { - if ($notification->codeWindow !== $emailAuthentication->getCodeWindow()) { + Notification::assertSentTo($userToAuthenticate, VerifyEmailAuthentication::class, function (VerifyEmailAuthentication $notification) use ($code, $emailAuthentication): bool { + if ($notification->codeExpiryMinutes !== $emailAuthentication->getCodeExpiryMinutes()) { return false; } - return $notification->code === $emailAuthentication->getCurrentCode($userToAuthenticate); + return $notification->code === $code; }); }); it('will authenticate the user after a valid challenge code is used', function (): void { + /** @var EmailAuthentication $emailAuthentication */ $emailAuthentication = Arr::first(Filament::getCurrentOrDefaultPanel()->getMultiFactorAuthenticationProviders()); $userToAuthenticate = User::factory() ->hasEmailAuthentication() ->create(); + $code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT); + $emailAuthentication->generateCodesUsing(fn (): string => $code); + livewire(Login::class) ->fillForm([ 'email' => $userToAuthenticate->email, @@ -68,7 +77,7 @@ it('will authenticate the user after a valid challenge code is used', function ( ->assertNoRedirect() ->fillForm([ $emailAuthentication->getId() => [ - 'code' => $emailAuthentication->getCurrentCode($userToAuthenticate), + 'code' => $code, ], ], 'multiFactorChallengeForm') ->call('authenticate') @@ -105,7 +114,7 @@ it('can resend the code to the user', function (): void { Notification::assertSentTimes(VerifyEmailAuthentication::class, 2); }); -it('can not resend the code to the user more than once per minute', function (): void { +it('can not resend the code to the user more than twice per minute', function (): void { $this->travelTo(now()->subMinute()); $emailAuthentication = Arr::first(Filament::getCurrentOrDefaultPanel()->getMultiFactorAuthenticationProviders()); @@ -127,7 +136,13 @@ it('can not resend the code to the user more than once per minute', function (): ->callAction(TestAction::make('resend') ->schemaComponent("{$emailAuthentication->getId()}.code", schema: 'multiFactorChallengeForm')); - Notification::assertSentTimes(VerifyEmailAuthentication::class, 1); + Notification::assertSentTimes(VerifyEmailAuthentication::class, 2); + + $livewire + ->callAction(TestAction::make('resend') + ->schemaComponent("{$emailAuthentication->getId()}.code", schema: 'multiFactorChallengeForm')); + + Notification::assertSentTimes(VerifyEmailAuthentication::class, 2); $this->travelBack(); @@ -135,7 +150,7 @@ it('can not resend the code to the user more than once per minute', function (): ->callAction(TestAction::make('resend') ->schemaComponent("{$emailAuthentication->getId()}.code", schema: 'multiFactorChallengeForm')); - Notification::assertSentTimes(VerifyEmailAuthentication::class, 2); + Notification::assertSentTimes(VerifyEmailAuthentication::class, 3); }); it('will not render the challenge form after invalid login credentials are used', function (): void { @@ -193,9 +208,7 @@ it('will not authenticate the user when an invalid challenge code is used', func ->assertNoRedirect() ->fillForm([ $emailAuthentication->getId() => [ - 'code' => ($emailAuthentication->getCurrentCode($userToAuthenticate) === '000000') - ? '111111' - : '000000', + 'code' => str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT), ], ], 'multiFactorChallengeForm') ->call('authenticate') @@ -282,7 +295,7 @@ test('challenge codes must be 6 digits', function (): void { ->assertNoRedirect() ->fillForm([ $emailAuthentication->getId() => [ - 'code' => Str::limit($emailAuthentication->getCurrentCode($userToAuthenticate), limit: 5, end: ''), + 'code' => str_pad((string) random_int(0, 99999), 5, '0', STR_PAD_LEFT), ], ], 'multiFactorChallengeForm') ->call('authenticate') diff --git a/tests/src/Panels/Auth/MultiFactor/Email/SetUpEmailAuthenticationActionTest.php b/tests/src/Panels/Auth/MultiFactor/Email/SetUpEmailAuthenticationActionTest.php index a918f5982c..43a66fc4a1 100644 --- a/tests/src/Panels/Auth/MultiFactor/Email/SetUpEmailAuthenticationActionTest.php +++ b/tests/src/Panels/Auth/MultiFactor/Email/SetUpEmailAuthenticationActionTest.php @@ -1,6 +1,7 @@ getMultiFactorAuthenticationProviders()); - $livewire = livewire(EditProfile::class) + $code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT); + $emailAuthentication->generateCodesUsing(fn (): string => $code); + + livewire(EditProfile::class) ->mountAction(TestAction::make('setUpEmailAuthentication') - ->schemaComponent('email_code', schema: 'content')) - ->assertActionMounted(TestAction::make('setUpEmailAuthentication') - ->schemaComponent('email_code', schema: 'content') - ->arguments(function (array $actualArguments): bool { - $encrypted = decrypt($actualArguments['encrypted']); + ->schemaComponent('email_code', schema: 'content')); - if (blank($encrypted['secret'] ?? null)) { - return false; - } - - if (blank($encrypted['userId'] ?? null)) { - return false; - } - - return $encrypted['userId'] === auth()->id(); - })); - - $encryptedActionArguments = decrypt($livewire->instance()->mountedActions[0]['arguments']['encrypted']); - $secret = $encryptedActionArguments['secret']; - - Notification::assertSentTo(auth()->user(), VerifyEmailAuthentication::class, function (VerifyEmailAuthentication $notification) use ($emailAuthentication, $secret): bool { - if ($notification->codeWindow !== $emailAuthentication->getCodeWindow()) { + Notification::assertSentTo(auth()->user(), VerifyEmailAuthentication::class, function (VerifyEmailAuthentication $notification) use ($code, $emailAuthentication): bool { + if ($notification->codeExpiryMinutes !== $emailAuthentication->getCodeExpiryMinutes()) { return false; } - return $notification->code === $emailAuthentication->getCurrentCode(auth()->user(), $secret); + return $notification->code === $code; }); }); -it('can save the secret to the user when the action is submitted', function (): void { +it('can enable email authentication', function (): void { + /** @var EmailAuthentication $emailAuthentication */ $emailAuthentication = Arr::first(Filament::getCurrentOrDefaultPanel()->getMultiFactorAuthenticationProviders()); $user = auth()->user(); @@ -65,26 +52,20 @@ it('can save the secret to the user when the action is submitted', function (): expect($user->hasEmailAuthentication()) ->toBeFalse(); - expect($user->getEmailAuthenticationSecret()) - ->toBeEmpty(); + $code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT); + $emailAuthentication->generateCodesUsing(fn (): string => $code); $livewire = livewire(EditProfile::class) ->mountAction(TestAction::make('setUpEmailAuthentication') ->schemaComponent('email_code', schema: 'content')); - $encryptedActionArguments = decrypt($livewire->instance()->mountedActions[0]['arguments']['encrypted']); - $secret = $encryptedActionArguments['secret']; - $livewire - ->fillForm(['code' => $emailAuthentication->getCurrentCode($user, $secret)]) + ->fillForm(['code' => $code]) ->callMountedAction() ->assertHasNoFormErrors(); expect($user->hasEmailAuthentication()) ->toBeTrue(); - - expect($user->getEmailAuthenticationSecret()) - ->toBe($secret); }); it('can resend the code to the user', function (): void { @@ -105,7 +86,7 @@ it('can resend the code to the user', function (): void { Notification::assertSentTimes(VerifyEmailAuthentication::class, 2); }); -it('can resend the code to the user more than once per minute', function (): void { +it('can resend the code to the user more than twice per minute', function (): void { $this->travelTo(now()->subMinute()); $livewire = livewire(EditProfile::class) @@ -118,7 +99,13 @@ it('can resend the code to the user more than once per minute', function (): voi ->callAction(TestAction::make('resend') ->schemaComponent('code')); - Notification::assertSentTimes(VerifyEmailAuthentication::class, 1); + Notification::assertSentTimes(VerifyEmailAuthentication::class, 2); + + $livewire + ->callAction(TestAction::make('resend') + ->schemaComponent('code')); + + Notification::assertSentTimes(VerifyEmailAuthentication::class, 2); $this->travelBack(); @@ -126,39 +113,28 @@ it('can resend the code to the user more than once per minute', function (): voi ->callAction(TestAction::make('resend') ->schemaComponent('code')); - Notification::assertSentTimes(VerifyEmailAuthentication::class, 2); + Notification::assertSentTimes(VerifyEmailAuthentication::class, 3); }); it('will not set up authentication when an invalid code is used', function (): void { - $emailAuthentication = Arr::first(Filament::getCurrentOrDefaultPanel()->getMultiFactorAuthenticationProviders()); - $user = auth()->user(); expect($user->hasEmailAuthentication()) ->toBeFalse(); - expect($user->getEmailAuthenticationSecret()) - ->toBeEmpty(); - $livewire = livewire(EditProfile::class) ->mountAction(TestAction::make('setUpEmailAuthentication') ->schemaComponent('email_code', schema: 'content')); - $encryptedActionArguments = decrypt($livewire->instance()->mountedActions[0]['arguments']['encrypted']); - $secret = $encryptedActionArguments['secret']; - $livewire ->fillForm([ - 'code' => ($emailAuthentication->getCurrentCode($user, $secret) === '000000') ? '111111' : '000000', + 'code' => str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT), ]) ->callMountedAction() ->assertHasFormErrors(); expect($user->hasEmailAuthentication()) ->toBeFalse(); - - expect($user->getEmailAuthenticationSecret()) - ->toBeEmpty(); }); test('codes are required', function (): void { @@ -167,9 +143,6 @@ test('codes are required', function (): void { expect($user->hasEmailAuthentication()) ->toBeFalse(); - expect($user->getEmailAuthenticationSecret()) - ->toBeEmpty(); - livewire(EditProfile::class) ->mountAction(TestAction::make('setUpEmailAuthentication') ->schemaComponent('email_code', schema: 'content')) @@ -181,32 +154,21 @@ test('codes are required', function (): void { expect($user->hasEmailAuthentication()) ->toBeFalse(); - - expect($user->getEmailAuthenticationSecret()) - ->toBeEmpty(); }); test('codes must be 6 digits', function (): void { - $emailAuthentication = Arr::first(Filament::getCurrentOrDefaultPanel()->getMultiFactorAuthenticationProviders()); - $user = auth()->user(); expect($user->hasEmailAuthentication()) ->toBeFalse(); - expect($user->getEmailAuthenticationSecret()) - ->toBeEmpty(); - $livewire = livewire(EditProfile::class) ->mountAction(TestAction::make('setUpEmailAuthentication') ->schemaComponent('email_code', schema: 'content')); - $encryptedActionArguments = decrypt($livewire->instance()->mountedActions[0]['arguments']['encrypted']); - $secret = $encryptedActionArguments['secret']; - $livewire ->fillForm([ - 'code' => Str::limit($emailAuthentication->getCurrentCode($user, $secret), limit: 5, end: ''), + 'code' => str_pad((string) random_int(0, 99999), 5, '0', STR_PAD_LEFT), ]) ->callMountedAction() ->assertHasFormErrors([ @@ -215,7 +177,4 @@ test('codes must be 6 digits', function (): void { expect($user->hasEmailAuthentication()) ->toBeFalse(); - - expect($user->getEmailAuthenticationSecret()) - ->toBeEmpty(); });