Unverified Commit 5f043576 authored by Alex Pott's avatar Alex Pott
Browse files

Issue #1521996 by quietone, dww, Sophie.SK, Carsten Müller, Spokje,...

Issue #1521996 by quietone, dww, Sophie.SK, Carsten Müller, Spokje, kim.pepper, izus, mjpa, ashu1629, AlanHDev, mangy.fox, Vivek Panicker, robpowell, jozzy_a, tar_inet, ravi.shankar, sokru, zuhair_ak, cjgratacos, aerozeppelin, darrenwh, lomasr, luismagr, mohit1604, ronald.garcia, matteodem, webchuck, gordon, nginex, jain_deepak, Aunion, ankitgarg, Elendev, Kristen Pol, alexpott, YesCT, dpi, no2e, larowlan, longwave, markpavlitski, acbramley, C_Logemann, cilefen, claudiu.cristea, penyaskito, David_Rothstein, Leeteq, anagp, jlbellido, pdrake, klausi: Password reset form reveals whether an email or username is in use
parent d99b12fb
Loading
Loading
Loading
Loading
+80 −26
Original line number Diff line number Diff line
@@ -2,12 +2,15 @@

namespace Drupal\user\Form;

use Drupal\Component\Utility\EmailValidatorInterface;
use Drupal\Core\Config\ConfigFactory;
use Drupal\Core\Field\BaseFieldDefinition;
use Drupal\Core\Flood\FloodInterface;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Language\LanguageManagerInterface;
use Drupal\Core\Render\Element\Email;
use Drupal\Core\TypedData\TypedDataManagerInterface;
use Drupal\user\UserInterface;
use Drupal\user\UserStorageInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
@@ -42,6 +45,20 @@ class UserPasswordForm extends FormBase {
   */
  protected $flood;

  /**
   * The typed data manager.
   *
   * @var \Drupal\Core\TypedData\TypedDataManagerInterface
   */
  protected $typedDataManager;

  /**
   * The email validator service.
   *
   * @var \Drupal\Component\Utility\EmailValidatorInterface
   */
  protected $emailValidator;

  /**
   * Constructs a UserPasswordForm object.
   *
@@ -53,12 +70,26 @@ class UserPasswordForm extends FormBase {
   *   The config factory.
   * @param \Drupal\Core\Flood\FloodInterface $flood
   *   The flood service.
   * @param \Drupal\Core\TypedData\TypedDataManagerInterface $typed_data_manager
   *   The typed data manager.
   * @param \Drupal\Component\Utility\EmailValidatorInterface $email_validator
   *   The email validator service.
   */
  public function __construct(UserStorageInterface $user_storage, LanguageManagerInterface $language_manager, ConfigFactory $config_factory, FloodInterface $flood) {
  public function __construct(UserStorageInterface $user_storage, LanguageManagerInterface $language_manager, ConfigFactory $config_factory, FloodInterface $flood, TypedDataManagerInterface $typed_data_manager = NULL, EmailValidatorInterface $email_validator = NULL) {
    $this->userStorage = $user_storage;
    $this->languageManager = $language_manager;
    $this->configFactory = $config_factory;
    $this->flood = $flood;
    if (is_null($typed_data_manager)) {
      @trigger_error('Calling ' . __METHOD__ . ' without the $typed_data_manager argument is deprecated in drupal:9.2.0 and will be required in drupal:10.0.0. See https://www.drupal.org/node/3189310', E_USER_DEPRECATED);
      $typed_data_manager = \Drupal::typedDataManager();
    }
    $this->typedDataManager = $typed_data_manager;
    if (is_null($email_validator)) {
      @trigger_error('Calling ' . __METHOD__ . ' without the $email_validator argument is deprecated in drupal:9.2.0 and will be required in drupal:10.0.0. See https://www.drupal.org/node/3189310', E_USER_DEPRECATED);
      $email_validator = \Drupal::service('email.validator');
    }
    $this->emailValidator = $email_validator;
  }

  /**
@@ -69,7 +100,9 @@ public static function create(ContainerInterface $container) {
      $container->get('entity_type.manager')->getStorage('user'),
      $container->get('language_manager'),
      $container->get('config.factory'),
      $container->get('flood')
      $container->get('flood'),
      $container->get('typed_data_manager'),
      $container->get('email.validator')
    );
  }

@@ -133,7 +166,20 @@ public function validateForm(array &$form, FormStateInterface $form_state) {
      return;
    }
    $this->flood->register('user.password_request_ip', $flood_config->get('ip_window'));
    // First, see if the input is possibly valid as a username.
    $name = trim($form_state->getValue('name'));
    $definition = BaseFieldDefinition::create('string')
      ->addConstraint('UserName', []);
    $data = $this->typedDataManager->create($definition);
    $data->setValue($name);
    $violations = $data->validate();
    // Usernames have a maximum length shorter than email addresses. Only print
    // this error if the input is not valid as a username or email address.
    if ($violations->count() > 0 && !$this->emailValidator->isValid($name)) {
      $form_state->setErrorByName('name', $this->t("The username or email address is invalid."));
      return;
    }

    // Try to load by email.
    $users = $this->userStorage->loadByProperties(['mail' => $name]);
    if (empty($users)) {
@@ -141,40 +187,48 @@ public function validateForm(array &$form, FormStateInterface $form_state) {
      $users = $this->userStorage->loadByProperties(['name' => $name]);
    }
    $account = reset($users);
    if ($account && $account->id()) {
    // Blocked accounts cannot request a new password.
      if (!$account->isActive()) {
        $form_state->setErrorByName('name', $this->t('%name is blocked or has not been activated yet.', ['%name' => $name]));
      }
      else {
    if ($account && $account->id() && $account->isActive()) {
      // Register flood events based on the uid only, so they apply for any
      // IP address. This allows them to be cleared on successful reset (from
      // any IP).
      $identifier = $account->id();
      if (!$this->flood->isAllowed('user.password_request_user', $flood_config->get('user_limit'), $flood_config->get('user_window'), $identifier)) {
          $form_state->setErrorByName('name', $this->t('Too many password recovery requests for this account. It is temporarily blocked. Try again later or contact the site administrator.'));
        return;
      }
      $this->flood->register('user.password_request_user', $flood_config->get('user_window'), $identifier);
      $form_state->setValueForElement(['#parents' => ['account']], $account);
    }
  }
    else {
      $form_state->setErrorByName('name', $this->t('%name is not recognized as a username or an email address.', ['%name' => $name]));
    }
  }

  /**
   * {@inheritdoc}
   */
  public function submitForm(array &$form, FormStateInterface $form_state) {
    $account = $form_state->getValue('account');
    if ($account) {
      // Mail one time login URL and instructions using current language.
      $mail = _user_mail_notify('password_reset', $account);
      if (!empty($mail)) {
      $this->logger('user')->notice('Password reset instructions mailed to %name at %email.', ['%name' => $account->getAccountName(), '%email' => $account->getEmail()]);
      $this->messenger()->addStatus($this->t('Further instructions have been sent to your email address.'));
        $this->logger('user')
          ->notice('Password reset instructions mailed to %name at %email.', [
            '%name' => $account->getAccountName(),
            '%email' => $account->getEmail(),
          ]);
      }
    }
    else {
      $this->logger('user')
        ->notice('Password reset form was submitted with an unknown or inactive account: %name.', [
          '%name' => $form_state->getValue('name'),
        ]);
    }
    // Make sure the status text is displayed even if no email was sent. This
    // message is deliberately the same as the success message for privacy.
    $this->messenger()
      ->addStatus($this->t('If %identifier is a valid account, an email will be sent with instructions to reset your password.', [
        '%identifier' => $form_state->getValue('name'),
      ]));

    $form_state->setRedirect('<front>');
  }
+48 −27
Original line number Diff line number Diff line
@@ -9,6 +9,7 @@
use Drupal\language\Entity\ConfigurableLanguage;
use Drupal\Tests\BrowserTestBase;
use Drupal\user\Entity\User;
use Drupal\user\UserInterface;

/**
 * Ensure that password reset methods work as expected.
@@ -87,11 +88,29 @@ public function testUserPasswordReset() {
    $this->drupalGet(Url::fromRoute('user.reset.form', ['uid' => $this->account->id()]));
    $this->assertSession()->statusCodeEquals(403);

    // Try to reset the password for a completely invalid username.
    $this->drupalGet('user/password');
    $long_name = $this->randomMachineName(UserInterface::USERNAME_MAX_LENGTH + 10);
    $edit = ['name' => $long_name];
    $this->submitForm($edit, 'Submit');
    $this->assertCount(0, $this->drupalGetMails(['id' => 'user_password_reset']), 'No e-mail was sent when requesting a password for an invalid user name.');
    $this->assertSession()->pageTextContains("The username or email address is invalid.");

    // Try to reset the password for an invalid account.
    $this->drupalGet('user/password');
    $edit = ['name' => $this->randomMachineName()];
    $random_name = $this->randomMachineName();
    $edit = ['name' => $random_name];
    $this->submitForm($edit, 'Submit');
    $this->assertNoValidPasswordReset($random_name);

    // Try to reset the password for a valid email address longer than
    // UserInterface::USERNAME_MAX_LENGTH (invalid username, valid email).
    // This should pass validation and print the generic message.
    $this->drupalGet('user/password');
    $long_name = $this->randomMachineName(UserInterface::USERNAME_MAX_LENGTH) . '@example.com';
    $edit = ['name' => $long_name];
    $this->submitForm($edit, 'Submit');
    $this->assertNoValidPasswordReset($edit['name']);
    $this->assertNoValidPasswordReset($long_name);

    // Reset the password by username via the password reset page.
    $this->drupalGet('user/password');
@@ -175,7 +194,6 @@ public function testUserPasswordReset() {
    $before = count($this->drupalGetMails(['id' => 'user_password_reset']));
    $edit = ['name' => $blocked_account->getAccountName()];
    $this->submitForm($edit, 'Submit');
    $this->assertRaw(t('%name is blocked or has not been activated yet.', ['%name' => $blocked_account->getAccountName()]));
    $this->assertCount($before, $this->drupalGetMails(['id' => 'user_password_reset']), 'No email was sent when requesting password reset for a blocked account');

    // Verify a password reset link is invalidated when the user's email address changes.
@@ -379,18 +397,25 @@ public function testUserResetPasswordUserFloodControl() {

    $edit = ['name' => $this->account->getAccountName()];

    // Count email messages before to compare with after.
    $before = count($this->drupalGetMails(['id' => 'user_password_reset']));

    // Try 3 requests that should not trigger flood control.
    for ($i = 0; $i < 3; $i++) {
      $this->drupalGet('user/password');
      $this->submitForm($edit, 'Submit');
      $this->assertValidPasswordReset($edit['name']);
      $this->assertNoPasswordUserFlood();
    }

    // Ensure 3 emails were sent.
    $this->assertCount($before + 3, $this->drupalGetMails(['id' => 'user_password_reset']), '3 emails sent without triggering flood control.');

    // The next request should trigger flood control.
    $this->drupalGet('user/password');
    $this->submitForm($edit, 'Submit');
    $this->assertPasswordUserFlood();

    // Ensure no further emails were sent.
    $this->assertCount($before + 3, $this->drupalGetMails(['id' => 'user_password_reset']), 'No further email was sent after triggering flood control.');
  }

  /**
@@ -404,10 +429,11 @@ public function testUserResetPasswordIpFloodControl() {
    // Try 3 requests that should not trigger flood control.
    for ($i = 0; $i < 3; $i++) {
      $this->drupalGet('user/password');
      $edit = ['name' => $this->randomMachineName()];
      $random_name = $this->randomMachineName();
      $edit = ['name' => $random_name];
      $this->submitForm($edit, 'Submit');
      // Because we're testing with a random name, the password reset will not be valid.
      $this->assertNoValidPasswordReset($edit['name']);
      $this->assertNoValidPasswordReset($random_name);
      $this->assertNoPasswordIpFlood();
    }

@@ -428,14 +454,19 @@ public function testUserResetPasswordUserFloodControlIsCleared() {

    $edit = ['name' => $this->account->getAccountName()];

    // Count email messages before to compare with after.
    $before = count($this->drupalGetMails(['id' => 'user_password_reset']));

    // Try 3 requests that should not trigger flood control.
    for ($i = 0; $i < 3; $i++) {
      $this->drupalGet('user/password');
      $this->submitForm($edit, 'Submit');
      $this->assertValidPasswordReset($edit['name']);
      $this->assertNoPasswordUserFlood();
    }

    // Ensure 3 emails were sent.
    $this->assertCount($before + 3, $this->drupalGetMails(['id' => 'user_password_reset']), '3 emails sent without triggering flood control.');

    // Use the last password reset URL which was generated.
    $reset_url = $this->getResetURL();
    $this->drupalGet($reset_url . '/login');
@@ -448,15 +479,16 @@ public function testUserResetPasswordUserFloodControlIsCleared() {
    $this->drupalGet('user/password');
    $this->submitForm($edit, 'Submit');
    $this->assertValidPasswordReset($edit['name']);
    $this->assertNoPasswordUserFlood();

    // Ensure another email was sent.
    $this->assertCount($before + 4, $this->drupalGetMails(['id' => 'user_password_reset']), 'Another email was sent after clearing flood control.');
  }

  /**
   * Helper function to make assertions about a valid password reset.
   */
  public function assertValidPasswordReset($name) {
    // Make sure the error text is not displayed and email sent.
    $this->assertNoText("Sorry, $name is not recognized as a username or an e-mail address.");
    $this->assertSession()->pageTextContains("If $name is a valid account, an email will be sent with instructions to reset your password.");
    $this->assertMail('to', $this->account->getEmail(), 'Password e-mail sent to user.');
    $subject = t('Replacement login information for @username at @site', ['@username' => $this->account->getAccountName(), '@site' => \Drupal::config('system.site')->get('name')]);
    $this->assertMail('subject', $subject, 'Password reset e-mail subject is correct.');
@@ -464,27 +496,16 @@ public function assertValidPasswordReset($name) {

  /**
   * Helper function to make assertions about an invalid password reset.
   *
   * @param string $name
   */
  public function assertNoValidPasswordReset($name) {
    // Make sure the error text is displayed and no email sent.
    $this->assertText($name . ' is not recognized as a username or an email address.');
    // This message is the same as the valid reset for privacy reasons.
    $this->assertSession()->pageTextContains("If $name is a valid account, an email will be sent with instructions to reset your password.");
    // The difference is that no email is sent.
    $this->assertCount(0, $this->drupalGetMails(['id' => 'user_password_reset']), 'No e-mail was sent when requesting a password for an invalid account.');
  }

  /**
   * Makes assertions about a password reset triggering user flood control.
   */
  public function assertPasswordUserFlood() {
    $this->assertText('Too many password recovery requests for this account. It is temporarily blocked. Try again later or contact the site administrator.');
  }

  /**
   * Makes assertions about a password reset not triggering user flood control.
   */
  public function assertNoPasswordUserFlood() {
    $this->assertNoText('Too many password recovery requests for this account. It is temporarily blocked. Try again later or contact the site administrator.');
  }

  /**
   * Makes assertions about a password reset triggering IP flood control.
   */