diff --git a/src/Auth/ResetsPasswords.php b/src/Auth/ResetsPasswords.php index 3716aecb789..3da2081e50f 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,25 @@ 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; + } + + if ($statamicUser + && ! config('statamic.webauthn.allow_password_login_with_passkey', true) + && $statamicUser->passkeys()->isNotEmpty()) { + return; + } + $this->guard()->login($user); } @@ -154,14 +176,34 @@ 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); } - 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'); + } + /** * 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..15fb28ca64b 100644 --- a/src/Http/Controllers/CP/Auth/ResetPasswordController.php +++ b/src/Http/Controllers/CP/Auth/ResetPasswordController.php @@ -29,4 +29,14 @@ 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 new file mode 100644 index 00000000000..7d02ca89ca9 --- /dev/null +++ b/tests/Auth/ResetPasswordTest.php @@ -0,0 +1,244 @@ + ['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); + } + + #[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(); + + $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; + } +}