diff --git a/framework/core/locale/core.yml b/framework/core/locale/core.yml index 2dd6e52192..c0e43a8499 100644 --- a/framework/core/locale/core.yml +++ b/framework/core/locale/core.yml @@ -350,7 +350,7 @@ core: forgot_password: dismiss_button: => core.ref.okay email_placeholder: => core.ref.email - email_sent_message: We've sent you an email containing a link to reset your password. Check your spam folder if you don't receive it within the next minute or two. + email_sent_message: If the email you entered is registered with this site, we'll send you an email containing a link to reset your password. Check your spam folder if you don't receive it within the next minute or two. not_found_message: There is no user registered with that email address. submit_button: Recover Password text: Enter your email address and we will send you a link to reset your password. diff --git a/framework/core/src/Api/Controller/ForgotPasswordController.php b/framework/core/src/Api/Controller/ForgotPasswordController.php index e6aba95f75..43c5a15c4a 100644 --- a/framework/core/src/Api/Controller/ForgotPasswordController.php +++ b/framework/core/src/Api/Controller/ForgotPasswordController.php @@ -9,10 +9,11 @@ namespace Flarum\Api\Controller; -use Flarum\User\Command\RequestPasswordReset; -use Flarum\User\UserRepository; -use Illuminate\Contracts\Bus\Dispatcher; +use Flarum\User\Job\RequestPasswordResetJob; +use Illuminate\Contracts\Queue\Queue; +use Illuminate\Contracts\Validation\Factory; use Illuminate\Support\Arr; +use Illuminate\Validation\ValidationException; use Laminas\Diactoros\Response\EmptyResponse; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; @@ -21,23 +22,19 @@ class ForgotPasswordController implements RequestHandlerInterface { /** - * @var \Flarum\User\UserRepository + * @var Queue */ - protected $users; + protected $queue; /** - * @var Dispatcher + * @var Factory */ - protected $bus; + protected $validatorFactory; - /** - * @param \Flarum\User\UserRepository $users - * @param Dispatcher $bus - */ - public function __construct(UserRepository $users, Dispatcher $bus) + public function __construct(Queue $queue, Factory $validatorFactory) { - $this->users = $users; - $this->bus = $bus; + $this->queue = $queue; + $this->validatorFactory = $validatorFactory; } /** @@ -47,10 +44,19 @@ public function handle(ServerRequestInterface $request): ResponseInterface { $email = Arr::get($request->getParsedBody(), 'email'); - $this->bus->dispatch( - new RequestPasswordReset($email) + $validation = $this->validatorFactory->make( + compact('email'), + ['email' => 'required|email'] ); + if ($validation->fails()) { + throw new ValidationException($validation); + } + + // Prevents leaking user existence by not throwing an error. + // Prevents leaking user existence by duration by using a queued job. + $this->queue->push(new RequestPasswordResetJob($email)); + return new EmptyResponse; } } diff --git a/framework/core/src/User/Command/RequestPasswordReset.php b/framework/core/src/User/Command/RequestPasswordReset.php deleted file mode 100644 index a884b74429..0000000000 --- a/framework/core/src/User/Command/RequestPasswordReset.php +++ /dev/null @@ -1,28 +0,0 @@ -email = $email; - } -} diff --git a/framework/core/src/User/Command/RequestPasswordResetHandler.php b/framework/core/src/User/Command/RequestPasswordResetHandler.php deleted file mode 100644 index ddaa71bad3..0000000000 --- a/framework/core/src/User/Command/RequestPasswordResetHandler.php +++ /dev/null @@ -1,119 +0,0 @@ -users = $users; - $this->settings = $settings; - $this->queue = $queue; - $this->url = $url; - $this->translator = $translator; - $this->validatorFactory = $validatorFactory; - } - - /** - * @param RequestPasswordReset $command - * @return \Flarum\User\User - * @throws ModelNotFoundException - */ - public function handle(RequestPasswordReset $command) - { - $email = $command->email; - - $validation = $this->validatorFactory->make( - compact('email'), - ['email' => 'required|email'] - ); - - if ($validation->fails()) { - throw new ValidationException($validation); - } - - $user = $this->users->findByEmail($email); - - if (! $user) { - throw new ModelNotFoundException; - } - - $token = PasswordToken::generate($user->id); - $token->save(); - - $data = [ - 'username' => $user->display_name, - 'url' => $this->url->to('forum')->route('resetPassword', ['token' => $token->token]), - 'forum' => $this->settings->get('forum_title'), - ]; - - $body = $this->translator->trans('core.email.reset_password.body', $data); - $subject = $this->translator->trans('core.email.reset_password.subject'); - - $this->queue->push(new SendRawEmailJob($user->email, $subject, $body)); - - return $user; - } -} diff --git a/framework/core/src/User/Job/RequestPasswordResetJob.php b/framework/core/src/User/Job/RequestPasswordResetJob.php new file mode 100644 index 0000000000..5399532d75 --- /dev/null +++ b/framework/core/src/User/Job/RequestPasswordResetJob.php @@ -0,0 +1,60 @@ +email = $email; + } + + public function handle( + SettingsRepositoryInterface $settings, + UrlGenerator $url, + TranslatorInterface $translator, + UserRepository $users, + Queue $queue + ) { + $user = $users->findByEmail($this->email); + + if (! $user) { + return; + } + + $token = PasswordToken::generate($user->id); + $token->save(); + + $data = [ + 'username' => $user->display_name, + 'url' => $url->to('forum')->route('resetPassword', ['token' => $token->token]), + 'forum' => $settings->get('forum_title'), + ]; + + $body = $translator->trans('core.email.reset_password.body', $data); + $subject = $translator->trans('core.email.reset_password.subject'); + + $queue->push(new SendRawEmailJob($user->email, $subject, $body)); + } +} diff --git a/framework/core/tests/integration/api/users/SendPasswordResetEmailTest.php b/framework/core/tests/integration/api/users/SendPasswordResetEmailTest.php index 09494edb7e..ac1f3bc0b8 100644 --- a/framework/core/tests/integration/api/users/SendPasswordResetEmailTest.php +++ b/framework/core/tests/integration/api/users/SendPasswordResetEmailTest.php @@ -70,4 +70,19 @@ public function users_cant_send_confirmation_emails_too_fast() $this->assertEquals(429, $response->getStatusCode()); } + + /** @test */ + public function request_password_reset_does_not_leak_user_existence() + { + $response = $this->send( + $this->request('POST', '/api/forgot', [ + 'authenticatedAs' => 3, + 'json' => [ + 'email' => 'missing_user@machine.local' + ] + ]) + ); + + $this->assertEquals(204, $response->getStatusCode()); + } }