From 8a169e721efed2445eb2d93adf5db10433f682a8 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Tue, 7 Apr 2026 10:35:25 -0400 Subject: [PATCH 1/3] Require 2FA challenge after password reset for users with 2FA enabled Previously, the password reset flow would auto-login the user without requiring a 2FA code, allowing the second factor to be bypassed entirely. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Auth/ResetsPasswords.php | 27 +++ .../CP/Auth/ResetPasswordController.php | 5 + tests/Auth/ResetPasswordTest.php | 162 ++++++++++++++++++ 3 files changed, 194 insertions(+) create mode 100644 tests/Auth/ResetPasswordTest.php diff --git a/src/Auth/ResetsPasswords.php b/src/Auth/ResetsPasswords.php index 3716aecb789..5b9d778a741 100644 --- a/src/Auth/ResetsPasswords.php +++ b/src/Auth/ResetsPasswords.php @@ -12,7 +12,10 @@ use Illuminate\Support\Str; use Illuminate\Validation\Rules\Password as PasswordRules; use Illuminate\Validation\ValidationException; +use Statamic\Events\TwoFactorAuthenticationChallenged; +use Statamic\Facades\TwoFactor; use Statamic\Facades\URL; +use Statamic\Facades\User; /** * A copy of Illuminate\Auth\ResetsPasswords. @@ -131,6 +134,19 @@ protected function resetPassword($user, $password) event(new PasswordReset($user)); + $statamicUser = User::fromUser($user); + + if ($statamicUser && TwoFactor::enabled() && $statamicUser->hasEnabledTwoFactorAuthentication()) { + request()->session()->put([ + 'login.id' => $statamicUser->getKey(), + 'login.remember' => false, + ]); + + TwoFactorAuthenticationChallenged::dispatch($statamicUser); + + return; + } + $this->guard()->login($user); } @@ -154,6 +170,12 @@ protected function setUserPassword($user, $password) */ protected function sendResetResponse(Request $request, $response) { + if ($request->session()->has('login.id')) { + return $request->wantsJson() + ? new JsonResponse(['two_factor' => true], 200) + : redirect($this->twoFactorChallengeRedirect()); + } + if ($request->wantsJson()) { return new JsonResponse(['message' => trans($response)], 200); } @@ -162,6 +184,11 @@ protected function sendResetResponse(Request $request, $response) ->with('status', trans($response)); } + protected function twoFactorChallengeRedirect(): string + { + return route('statamic.two-factor-challenge'); + } + /** * Get the response for a failed password reset. * diff --git a/src/Http/Controllers/CP/Auth/ResetPasswordController.php b/src/Http/Controllers/CP/Auth/ResetPasswordController.php index 338a5e501d7..3fe1ddfc059 100644 --- a/src/Http/Controllers/CP/Auth/ResetPasswordController.php +++ b/src/Http/Controllers/CP/Auth/ResetPasswordController.php @@ -29,4 +29,9 @@ protected function resetFormAction() { return route('statamic.cp.password.reset.action'); } + + protected function twoFactorChallengeRedirect(): string + { + return cp_route('two-factor-challenge'); + } } diff --git a/tests/Auth/ResetPasswordTest.php b/tests/Auth/ResetPasswordTest.php new file mode 100644 index 00000000000..0e8119ff31d --- /dev/null +++ b/tests/Auth/ResetPasswordTest.php @@ -0,0 +1,162 @@ + ['cp'], + 'web' => ['web'], + ]; + } + + private function resetUrl(string $type): string + { + return match ($type) { + 'cp' => cp_route('password.reset.action'), + 'web' => route('statamic.password.reset.action'), + }; + } + + private function twoFactorChallengeUrl(string $type): string + { + return match ($type) { + 'cp' => cp_route('two-factor-challenge'), + 'web' => route('statamic.two-factor-challenge'), + }; + } + + private function broker(string $type) + { + $broker = config('statamic.users.passwords.'.PasswordReset::BROKER_RESETS); + + if (is_array($broker)) { + $broker = $broker[$type]; + } + + return Password::broker($broker); + } + + #[Test] + #[DataProvider('resetPasswordProvider')] + public function it_resets_password_and_logs_in(string $type) + { + $user = $this->user(); + $token = $this->broker($type)->createToken($user); + + $this + ->assertGuest() + ->post($this->resetUrl($type), [ + 'token' => $token, + 'email' => $user->email(), + 'password' => 'newpassword', + 'password_confirmation' => 'newpassword', + ]) + ->assertRedirect('/'); + + $this->assertAuthenticatedAs($user); + $this->assertTrue(Hash::check('newpassword', $user->fresh()->password())); + } + + #[Test] + #[Group('2fa')] + #[DataProvider('resetPasswordProvider')] + public function it_redirects_to_two_factor_challenge_when_user_has_two_factor_enabled(string $type) + { + Event::fake(); + + $user = $this->userWithTwoFactorEnabled(); + $token = $this->broker($type)->createToken($user); + + $this + ->assertGuest() + ->post($this->resetUrl($type), [ + 'token' => $token, + 'email' => $user->email(), + 'password' => 'newpassword', + 'password_confirmation' => 'newpassword', + ]) + ->assertRedirect($this->twoFactorChallengeUrl($type)) + ->assertSessionHas('login.id', $user->id()) + ->assertSessionHas('login.remember', false); + + $this->assertGuest(); + $this->assertTrue(Hash::check('newpassword', $user->fresh()->password())); + + Event::assertDispatched(TwoFactorAuthenticationChallenged::class, fn ($event) => $event->user->id === $user->id); + } + + #[Test] + #[Group('2fa')] + #[DefineEnvironment('disableTwoFactor')] + #[DataProvider('resetPasswordProvider')] + public function it_skips_two_factor_challenge_when_two_factor_is_disabled(string $type) + { + Event::fake(); + + $user = $this->userWithTwoFactorEnabled(); + $token = $this->broker($type)->createToken($user); + + $this + ->assertGuest() + ->post($this->resetUrl($type), [ + 'token' => $token, + 'email' => $user->email(), + 'password' => 'newpassword', + 'password_confirmation' => 'newpassword', + ]) + ->assertRedirect('/'); + + $this->assertAuthenticatedAs($user); + $this->assertTrue(Hash::check('newpassword', $user->fresh()->password())); + + Event::assertNotDispatched(TwoFactorAuthenticationChallenged::class); + } + + protected function disableTwoFactor($app) + { + $app['config']->set('statamic.users.two_factor_enabled', false); + } + + private function user() + { + return tap(User::make()->makeSuper()->email('david@hasselhoff.com')->password('secret'))->save(); + } + + private function userWithTwoFactorEnabled() + { + $user = $this->user(); + + $user->merge([ + 'two_factor_confirmed_at' => now()->timestamp, + 'two_factor_secret' => encrypt(app(TwoFactorAuthenticationProvider::class)->generateSecretKey()), + 'two_factor_recovery_codes' => encrypt(json_encode(Collection::times(8, function () { + return RecoveryCode::generate(); + })->all())), + ]); + + $user->save(); + + return $user; + } +} From 8a3430d254c7ae097796d1046b53b8b6c7e5fd80 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Tue, 7 Apr 2026 11:10:52 -0400 Subject: [PATCH 2/3] Prevent auto-login after password reset when passkey login is enforced When allow_password_login_with_passkey is false and the user has passkeys, the password reset flow should not auto-login the user, matching the enforcement applied during normal login. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Auth/ResetsPasswords.php | 17 +++- .../CP/Auth/ResetPasswordController.php | 5 ++ tests/Auth/ResetPasswordTest.php | 82 +++++++++++++++++++ 3 files changed, 103 insertions(+), 1 deletion(-) diff --git a/src/Auth/ResetsPasswords.php b/src/Auth/ResetsPasswords.php index 5b9d778a741..3da2081e50f 100644 --- a/src/Auth/ResetsPasswords.php +++ b/src/Auth/ResetsPasswords.php @@ -147,6 +147,12 @@ protected function resetPassword($user, $password) return; } + if ($statamicUser + && ! config('statamic.webauthn.allow_password_login_with_passkey', true) + && $statamicUser->passkeys()->isNotEmpty()) { + return; + } + $this->guard()->login($user); } @@ -180,10 +186,19 @@ protected function sendResetResponse(Request $request, $response) return new JsonResponse(['message' => trans($response)], 200); } - return redirect($this->redirectPath()) + $redirect = $this->guard()->check() + ? $this->redirectPath() + : $this->loginPath(); + + return redirect($redirect) ->with('status', trans($response)); } + protected function loginPath(): string + { + return route('statamic.site'); + } + protected function twoFactorChallengeRedirect(): string { return route('statamic.two-factor-challenge'); diff --git a/src/Http/Controllers/CP/Auth/ResetPasswordController.php b/src/Http/Controllers/CP/Auth/ResetPasswordController.php index 3fe1ddfc059..15fb28ca64b 100644 --- a/src/Http/Controllers/CP/Auth/ResetPasswordController.php +++ b/src/Http/Controllers/CP/Auth/ResetPasswordController.php @@ -30,6 +30,11 @@ protected function resetFormAction() return route('statamic.cp.password.reset.action'); } + protected function loginPath(): string + { + return cp_route('login'); + } + protected function twoFactorChallengeRedirect(): string { return cp_route('two-factor-challenge'); diff --git a/tests/Auth/ResetPasswordTest.php b/tests/Auth/ResetPasswordTest.php index 0e8119ff31d..702768cc857 100644 --- a/tests/Auth/ResetPasswordTest.php +++ b/tests/Auth/ResetPasswordTest.php @@ -13,7 +13,11 @@ use Statamic\Auth\Passwords\PasswordReset; use Statamic\Auth\TwoFactor\RecoveryCode; use Statamic\Contracts\Auth\TwoFactor\TwoFactorAuthenticationProvider; +use Statamic\Auth\File\Passkey; use Statamic\Events\TwoFactorAuthenticationChallenged; +use Symfony\Component\Uid\Uuid; +use Webauthn\PublicKeyCredentialSource; +use Webauthn\TrustPath\EmptyTrustPath; use Statamic\Facades\User; use Tests\PreventSavingStacheItemsToDisk; use Tests\TestCase; @@ -133,16 +137,94 @@ public function it_skips_two_factor_challenge_when_two_factor_is_disabled(string Event::assertNotDispatched(TwoFactorAuthenticationChallenged::class); } + #[Test] + #[DefineEnvironment('disallowPasswordLoginWithPasskey')] + #[DataProvider('resetPasswordProvider')] + public function it_does_not_log_in_when_user_has_passkeys_and_password_login_is_disallowed(string $type) + { + $user = $this->userWithPasskey(); + $token = $this->broker($type)->createToken($user); + + $this + ->assertGuest() + ->post($this->resetUrl($type), [ + 'token' => $token, + 'email' => $user->email(), + 'password' => 'newpassword', + 'password_confirmation' => 'newpassword', + ]) + ->assertRedirect($this->loginUrl($type)); + + $this->assertGuest(); + $this->assertTrue(Hash::check('newpassword', $user->fresh()->password())); + } + + #[Test] + #[DataProvider('resetPasswordProvider')] + public function it_logs_in_when_user_has_passkeys_and_password_login_is_allowed(string $type) + { + $user = $this->userWithPasskey(); + $token = $this->broker($type)->createToken($user); + + $this + ->assertGuest() + ->post($this->resetUrl($type), [ + 'token' => $token, + 'email' => $user->email(), + 'password' => 'newpassword', + 'password_confirmation' => 'newpassword', + ]) + ->assertRedirect('/'); + + $this->assertAuthenticatedAs($user); + $this->assertTrue(Hash::check('newpassword', $user->fresh()->password())); + } + protected function disableTwoFactor($app) { $app['config']->set('statamic.users.two_factor_enabled', false); } + protected function disallowPasswordLoginWithPasskey($app) + { + $app['config']->set('statamic.webauthn.allow_password_login_with_passkey', false); + } + + private function loginUrl(string $type): string + { + return match ($type) { + 'cp' => cp_route('login'), + 'web' => route('statamic.site'), + }; + } + private function user() { return tap(User::make()->makeSuper()->email('david@hasselhoff.com')->password('secret'))->save(); } + private function userWithPasskey() + { + $user = $this->user(); + + $credential = PublicKeyCredentialSource::create( + publicKeyCredentialId: 'test-credential-id', + type: 'public-key', + transports: ['usb'], + attestationType: 'none', + trustPath: new EmptyTrustPath(), + aaguid: Uuid::fromString('00000000-0000-0000-0000-000000000000'), + credentialPublicKey: 'test-public-key', + userHandle: $user->id(), + counter: 0 + ); + + $passkey = (new Passkey)->setUser($user)->setName('Test Key')->setCredential($credential); + $passkey->save(); + + return $user->fresh(); + } + private function userWithTwoFactorEnabled() { $user = $this->user(); From 0649a3e6a649ad01a0e146c3b7b38463ec8aca6b Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Tue, 7 Apr 2026 11:29:27 -0400 Subject: [PATCH 3/3] Sort imports in ResetPasswordTest Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/Auth/ResetPasswordTest.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Auth/ResetPasswordTest.php b/tests/Auth/ResetPasswordTest.php index 702768cc857..7d02ca89ca9 100644 --- a/tests/Auth/ResetPasswordTest.php +++ b/tests/Auth/ResetPasswordTest.php @@ -10,17 +10,17 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\Test; +use Statamic\Auth\File\Passkey; use Statamic\Auth\Passwords\PasswordReset; use Statamic\Auth\TwoFactor\RecoveryCode; use Statamic\Contracts\Auth\TwoFactor\TwoFactorAuthenticationProvider; -use Statamic\Auth\File\Passkey; use Statamic\Events\TwoFactorAuthenticationChallenged; -use Symfony\Component\Uid\Uuid; -use Webauthn\PublicKeyCredentialSource; -use Webauthn\TrustPath\EmptyTrustPath; use Statamic\Facades\User; +use Symfony\Component\Uid\Uuid; use Tests\PreventSavingStacheItemsToDisk; use Tests\TestCase; +use Webauthn\PublicKeyCredentialSource; +use Webauthn\TrustPath\EmptyTrustPath; class ResetPasswordTest extends TestCase {