From 81841a88a6fb82da9bf4799c91e57f0482e61a11 Mon Sep 17 00:00:00 2001 From: Craig Williams Date: Thu, 6 Nov 2025 11:43:18 +0000 Subject: [PATCH] Fixed resetting password bypasses account verification --- routes/routes.php | 2 + src/Controller/AccountController.php | 105 +++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/routes/routes.php b/routes/routes.php index 7a17f18..6998aea 100644 --- a/routes/routes.php +++ b/routes/routes.php @@ -16,4 +16,6 @@ $app->group('/account', function () { $this->get('/register', 'UserFrosting\Sprinkle\UFTweaks\Controller\AccountController:pageRegister') ->add('checkEnvironment') ->setName('register'); + + $this->post('/forgot-password', 'UserFrosting\Sprinkle\UFTweaks\Controller\AccountController:forgotPassword'); })->add(new NoCache()); diff --git a/src/Controller/AccountController.php b/src/Controller/AccountController.php index 7c8f574..4936466 100644 --- a/src/Controller/AccountController.php +++ b/src/Controller/AccountController.php @@ -29,6 +29,111 @@ use UserFrosting\Support\Exception\NotFoundException; */ class AccountController extends UFAccountController { + /** + * Processes a request to email a forgotten password reset link to the user. + * + * Processes the request from the form on the "forgot password" page, checking that: + * 1. The rate limit for this type of request is being observed. + * 2. The provided email address belongs to a registered account; + * 3. The submitted data is valid. + * Note that we have removed the requirement that a password reset request not already be in progress. + * This is because we need to allow users to re-request a reset, even if they lose the first reset email. + * This route is "public access". + * + * @todo require additional user information + * @todo prevent password reset requests for root account? + * + * AuthGuard: false + * Route: /account/forgot-password + * Route Name: {none} + * Request type: POST + * + * @param Request $request + * @param Response $response + * @param array $args + */ + public function forgotPassword(Request $request, Response $response, $args) + { + /** @var \UserFrosting\Sprinkle\Core\Alert\AlertStream $ms */ + $ms = $this->ci->alerts; + + /** @var \UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */ + $classMapper = $this->ci->classMapper; + + /** @var \UserFrosting\Support\Repository\Repository $config */ + $config = $this->ci->config; + + // Get POST parameters + $params = $request->getParsedBody(); + + // Load the request schema + $schema = new RequestSchema('schema://requests/forgot-password.yaml'); + + // Whitelist and set parameter defaults + $transformer = new RequestDataTransformer($schema); + $data = $transformer->transform($params); + + // Validate, and halt on validation errors. Failed validation attempts do not count towards throttling limit. + $validator = new ServerSideValidator($schema, $this->ci->translator); + if (!$validator->validate($data)) { + $ms->addValidationErrors($validator); + + return $response->withJson([], 400); + } + + // Throttle requests + /** @var \UserFrosting\Sprinkle\Core\Throttle\Throttler $throttler */ + $throttler = $this->ci->throttler; + + $throttleData = [ + 'email' => $data['email'], + ]; + $delay = $throttler->getDelay('password_reset_request', $throttleData); + + if ($delay > 0) { + $ms->addMessageTranslated('danger', 'RATE_LIMIT_EXCEEDED', ['delay' => $delay]); + + return $response->withJson([], 429); + } + + // Load the user, by email address + $user = $classMapper->getClassMapping('user')::where('email', $data['email'])->first(); + + if ($user) { + if (!$user->flag_verified) { + $ms->addMessageTranslated('danger', 'ACCOUNT.UNVERIFIED'); + return $response->withJson([], 400); + } + } + + // All checks passed! log events/activities, update user, and send email + // Begin transaction - DB will be rolled back if an exception occurs + Capsule::transaction(function () use ($classMapper, $data, $throttler, $throttleData, $config, $user) { + // Log throttleable event + $throttler->logEvent('password_reset_request', $throttleData); + + // Check that the email exists. + // If there is no user with that email address, we should still pretend like we succeeded, to prevent account enumeration + if ($user) { + // Try to generate a new password reset request. + // Use timeout for "reset password" + $passwordReset = $this->ci->repoPasswordReset->create($user, $config['password_reset.timeouts.reset']); + + // Create and send email + $message = new TwigMailMessage($this->ci->view, 'mail/password-reset.html.twig'); + $message->from($config['address_book.admin']) + ->addEmailRecipient(new EmailRecipient($user->email, $user->full_name)) + ->addParams([ + 'user' => $user, + 'token' => $passwordReset->getToken(), + 'request_date' => Carbon::now()->format('Y-m-d H:i:s'), + ]); + + $this->ci->mailer->send($message); + } + }); + } + /** * Account settings page. *