UserPasswordResetTest.php 19.5 KB
Newer Older
1 2
<?php

3
namespace Drupal\Tests\user\Functional;
4

5
use Drupal\Component\Render\FormattableMarkup;
6
use Drupal\Core\Database\Database;
7
use Drupal\Core\Test\AssertMailTrait;
8
use Drupal\Core\Url;
9
use Drupal\Tests\BrowserTestBase;
10
use Drupal\user\Entity\User;
11 12

/**
13 14 15
 * Ensure that password reset methods work as expected.
 *
 * @group user
16
 */
17
class UserPasswordResetTest extends BrowserTestBase {
18

19 20 21
  use AssertMailTrait {
    getMails as drupalGetMails;
  }
22

23 24 25
  /**
   * The user object to test password resetting.
   *
26
   * @var \Drupal\user\UserInterface
27 28
   */
  protected $account;
29

30 31 32 33 34
  /**
   * Modules to enable.
   *
   * @var array
   */
35
  protected static $modules = ['block'];
36

37 38 39 40 41
  /**
   * {@inheritdoc}
   */
  protected $defaultTheme = 'stark';

42 43 44
  /**
   * {@inheritdoc}
   */
45
  protected function setUp(): void {
46 47
    parent::setUp();

48 49 50 51
    // Enable page caching.
    $config = $this->config('system.performance');
    $config->set('cache.page.max_age', 3600);
    $config->save();
52 53
    $this->drupalPlaceBlock('system_menu_block:account');

54 55
    // Create a user.
    $account = $this->drupalCreateUser();
56 57

    // Activate user by logging in.
58
    $this->drupalLogin($account);
59

60
    $this->account = User::load($account->id());
61
    $this->account->passRaw = $account->passRaw;
62
    $this->drupalLogout();
63 64 65 66

    // Set the last login time that is used to generate the one-time link so
    // that it is definitely over a second ago.
    $account->login = REQUEST_TIME - mt_rand(10, 100000);
67
    Database::getConnection()->update('users_field_data')
68
      ->fields(['login' => $account->getLastLoginTime()])
69
      ->condition('uid', $account->id())
70
      ->execute();
71 72 73
  }

  /**
74
   * Tests password reset functionality.
75
   */
76
  public function testUserPasswordReset() {
77 78 79
    // Verify that accessing the password reset form without having the session
    // variables set results in an access denied message.
    $this->drupalGet(Url::fromRoute('user.reset.form', ['uid' => $this->account->id()]));
80
    $this->assertSession()->statusCodeEquals(403);
81

82 83
    // Try to reset the password for an invalid account.
    $this->drupalGet('user/password');
84
    $edit = ['name' => $this->randomMachineName()];
85
    $this->drupalPostForm(NULL, $edit, t('Submit'));
86
    $this->assertNoValidPasswordReset($edit['name']);
87 88

    // Reset the password by username via the password reset page.
89 90
    $this->drupalGet('user/password');
    $edit = ['name' => $this->account->getAccountName()];
91
    $this->drupalPostForm(NULL, $edit, t('Submit'));
92
    $this->assertValidPasswordReset($edit['name']);
93 94 95

    $resetURL = $this->getResetURL();
    $this->drupalGet($resetURL);
96 97 98
    // Ensure that the current url does not contain the hash and timestamp.
    $this->assertUrl(Url::fromRoute('user.reset.form', ['uid' => $this->account->id()]));

99
    $this->assertNull($this->drupalGetHeader('X-Drupal-Cache'));
100 101 102

    // Ensure the password reset URL is not cached.
    $this->drupalGet($resetURL);
103
    $this->assertNull($this->drupalGetHeader('X-Drupal-Cache'));
104 105

    // Check the one-time login page.
106
    $this->assertText($this->account->getAccountName(), 'One-time login page contains the correct username.');
107
    $this->assertText(t('This login can be used only once.'), 'Found warning about one-time login.');
108
    $this->assertTitle('Reset password | Drupal');
109 110

    // Check successful login.
111
    $this->drupalPostForm(NULL, NULL, t('Log in'));
112
    $this->assertLink(t('Log out'));
113
    $this->assertTitle($this->account->getAccountName() . ' | Drupal');
114

115 116
    // Change the forgotten password.
    $password = user_password();
117
    $edit = ['pass[pass1]' => $password, 'pass[pass2]' => $password];
118 119 120 121 122
    $this->drupalPostForm(NULL, $edit, t('Save'));
    $this->assertText(t('The changes have been saved.'), 'Forgotten password changed.');

    // Verify that the password reset session has been destroyed.
    $this->drupalPostForm(NULL, $edit, t('Save'));
123
    $this->assertText(t("Your current password is missing or incorrect; it's required to change the Password."), 'Password needed to make profile changes.');
124

125
    // Log out, and try to log in again using the same one-time link.
126
    $this->drupalLogout();
127
    $this->drupalGet($resetURL);
128
    $this->drupalPostForm(NULL, NULL, t('Log in'));
129 130
    $this->assertText(t('You have tried to use a one-time login link that has either been used or is no longer valid. Please request a new one using the form below.'), 'One-time link is no longer valid.');

131
    // Request a new password again, this time using the email address.
132
    // Count email messages before to compare with after.
133
    $before = count($this->drupalGetMails(['id' => 'user_password_reset']));
134
    $this->drupalGet('user/password');
135
    $edit = ['name' => $this->account->getEmail()];
136
    $this->drupalPostForm(NULL, $edit, t('Submit'));
137
    $this->assertValidPasswordReset($edit['name']);
138
    $this->assertCount($before + 1, $this->drupalGetMails(['id' => 'user_password_reset']), 'Email sent when requesting password reset using email address.');
139

140 141 142 143 144 145 146 147 148
    // Visit the user edit page without pass-reset-token and make sure it does
    // not cause an error.
    $resetURL = $this->getResetURL();
    $this->drupalGet($resetURL);
    $this->drupalPostForm(NULL, NULL, t('Log in'));
    $this->drupalGet('user/' . $this->account->id() . '/edit');
    $this->assertNoText('Expected user_string to be a string, NULL given');
    $this->drupalLogout();

149
    // Create a password reset link as if the request time was 60 seconds older than the allowed limit.
150
    $timeout = $this->config('user.settings')->get('password_reset_timeout');
151
    $bogus_timestamp = REQUEST_TIME - $timeout - 60;
152
    $_uid = $this->account->id();
153
    $this->drupalGet("user/reset/$_uid/$bogus_timestamp/" . user_pass_rehash($this->account, $bogus_timestamp));
154
    $this->drupalPostForm(NULL, NULL, t('Log in'));
155
    $this->assertText(t('You have tried to use a one-time login link that has expired. Please request a new one using the form below.'), 'Expired password reset request rejected.');
156 157 158 159 160

    // Create a user, block the account, and verify that a login link is denied.
    $timestamp = REQUEST_TIME - 1;
    $blocked_account = $this->drupalCreateUser()->block();
    $blocked_account->save();
161
    $this->drupalGet("user/reset/" . $blocked_account->id() . "/$timestamp/" . user_pass_rehash($blocked_account, $timestamp));
162
    $this->assertSession()->statusCodeEquals(403);
163 164 165 166

    // Verify a blocked user can not request a new password.
    $this->drupalGet('user/password');
    // Count email messages before to compare with after.
167
    $before = count($this->drupalGetMails(['id' => 'user_password_reset']));
168
    $edit = ['name' => $blocked_account->getAccountName()];
169
    $this->drupalPostForm(NULL, $edit, t('Submit'));
170
    $this->assertRaw(t('%name is blocked or has not been activated yet.', ['%name' => $blocked_account->getAccountName()]), 'Notified user blocked accounts can not request a new password');
171
    $this->assertCount($before, $this->drupalGetMails(['id' => 'user_password_reset']), 'No email was sent when requesting password reset for a blocked account');
172 173 174

    // Verify a password reset link is invalidated when the user's email address changes.
    $this->drupalGet('user/password');
175
    $edit = ['name' => $this->account->getAccountName()];
176 177 178 179 180
    $this->drupalPostForm(NULL, $edit, t('Submit'));
    $old_email_reset_link = $this->getResetURL();
    $this->account->setEmail("1" . $this->account->getEmail());
    $this->account->save();
    $this->drupalGet($old_email_reset_link);
181
    $this->drupalPostForm(NULL, NULL, t('Log in'));
182
    $this->assertText(t('You have tried to use a one-time login link that has either been used or is no longer valid. Please request a new one using the form below.'), 'One-time link is no longer valid.');
183 184 185 186

    // Verify a password reset link will automatically log a user when /login is
    // appended.
    $this->drupalGet('user/password');
187
    $edit = ['name' => $this->account->getAccountName()];
188 189 190 191
    $this->drupalPostForm(NULL, $edit, t('Submit'));
    $reset_url = $this->getResetURL();
    $this->drupalGet($reset_url . '/login');
    $this->assertLink(t('Log out'));
192
    $this->assertTitle($this->account->getAccountName() . ' | Drupal');
193 194 195 196 197 198 199 200

    // Ensure blocked and deleted accounts can't access the user.reset.login
    // route.
    $this->drupalLogout();
    $timestamp = REQUEST_TIME - 1;
    $blocked_account = $this->drupalCreateUser()->block();
    $blocked_account->save();
    $this->drupalGet("user/reset/" . $blocked_account->id() . "/$timestamp/" . user_pass_rehash($blocked_account, $timestamp) . '/login');
201
    $this->assertSession()->statusCodeEquals(403);
202 203 204

    $blocked_account->delete();
    $this->drupalGet("user/reset/" . $blocked_account->id() . "/$timestamp/" . user_pass_rehash($blocked_account, $timestamp) . '/login');
205
    $this->assertSession()->statusCodeEquals(403);
206
  }
207 208

  /**
209
   * Retrieves password reset email and extracts the login link.
210 211 212 213 214
   */
  public function getResetURL() {
    // Assume the most recent email.
    $_emails = $this->drupalGetMails();
    $email = end($_emails);
215
    $urls = [];
216 217 218 219
    preg_match('#.+user/reset/.+#', $email['body'], $urls);

    return $urls[0];
  }
220

221 222 223 224
  /**
   * Test user password reset while logged in.
   */
  public function testUserPasswordResetLoggedIn() {
225 226 227 228 229 230 231 232 233 234 235 236
    $another_account = $this->drupalCreateUser();
    $this->drupalLogin($another_account);
    $this->drupalGet('user/password');
    $this->drupalPostForm(NULL, NULL, t('Submit'));

    // Click the reset URL while logged and change our password.
    $resetURL = $this->getResetURL();
    // Log in as a different user.
    $this->drupalLogin($this->account);
    $this->drupalGet($resetURL);
    $this->assertRaw(new FormattableMarkup(
      'Another user (%other_user) is already logged into the site on this computer, but you tried to use a one-time link for user %resetting_user. Please <a href=":logout">log out</a> and try using the link again.',
237
      ['%other_user' => $this->account->getAccountName(), '%resetting_user' => $another_account->getAccountName(), ':logout' => Url::fromRoute('user.logout')->toString()]
238 239 240 241 242 243
    ));

    $another_account->delete();
    $this->drupalGet($resetURL);
    $this->assertText('The one-time login link you clicked is invalid.');

244 245 246 247 248 249 250 251 252 253 254 255 256 257
    // Log in.
    $this->drupalLogin($this->account);

    // Reset the password by username via the password reset page.
    $this->drupalGet('user/password');
    $this->drupalPostForm(NULL, NULL, t('Submit'));

    // Click the reset URL while logged and change our password.
    $resetURL = $this->getResetURL();
    $this->drupalGet($resetURL);
    $this->drupalPostForm(NULL, NULL, t('Log in'));

    // Change the password.
    $password = user_password();
258
    $edit = ['pass[pass1]' => $password, 'pass[pass2]' => $password];
259 260
    $this->drupalPostForm(NULL, $edit, t('Save'));
    $this->assertText(t('The changes have been saved.'), 'Password changed.');
261 262 263 264 265

    // Logged in users should not be able to access the user.reset.login or the
    // user.reset.form routes.
    $timestamp = REQUEST_TIME - 1;
    $this->drupalGet("user/reset/" . $this->account->id() . "/$timestamp/" . user_pass_rehash($this->account, $timestamp) . '/login');
266
    $this->assertSession()->statusCodeEquals(403);
267
    $this->drupalGet("user/reset/" . $this->account->id());
268
    $this->assertSession()->statusCodeEquals(403);
269 270
  }

271 272 273
  /**
   * Prefill the text box on incorrect login via link to password reset page.
   */
274 275
  public function testUserResetPasswordTextboxFilled() {
    $this->drupalGet('user/login');
276
    $edit = [
277 278
      'name' => $this->randomMachineName(),
      'pass' => $this->randomMachineName(),
279
    ];
280
    $this->drupalPostForm('user/login', $edit, t('Log in'));
281
    $this->assertRaw(t('Unrecognized username or password. <a href=":password">Forgot your password?</a>',
282
      [':password' => Url::fromRoute('user.pass', [], ['query' => ['name' => $edit['name']]])->toString()]));
283
    unset($edit['pass']);
284
    $this->drupalGet('user/password', ['query' => ['name' => $edit['name']]]);
285
    $this->assertFieldByName('name', $edit['name'], 'User name found.');
286 287 288
    // Ensure the name field value is not cached.
    $this->drupalGet('user/password');
    $this->assertNoFieldByName('name', $edit['name'], 'User name not found.');
289
  }
290

291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361
  /**
   * Tests password reset flood control for one user.
   */
  public function testUserResetPasswordUserFloodControl() {
    \Drupal::configFactory()->getEditable('user.flood')
      ->set('user_limit', 3)
      ->save();

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

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

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

  /**
   * Tests password reset flood control for one IP.
   */
  public function testUserResetPasswordIpFloodControl() {
    \Drupal::configFactory()->getEditable('user.flood')
      ->set('ip_limit', 3)
      ->save();

    // Try 3 requests that should not trigger flood control.
    for ($i = 0; $i < 3; $i++) {
      $this->drupalGet('user/password');
      $edit = ['name' => $this->randomMachineName()];
      $this->drupalPostForm(NULL, $edit, t('Submit'));
      // Because we're testing with a random name, the password reset will not be valid.
      $this->assertNoValidPasswordReset($edit['name']);
      $this->assertNoPasswordIpFlood();
    }

    // The next request should trigger flood control.
    $this->drupalGet('user/password');
    $edit = ['name' => $this->randomMachineName()];
    $this->drupalPostForm(NULL, $edit, t('Submit'));
    $this->assertPasswordIpFlood();
  }

  /**
   * Tests user password reset flood control is cleared on successful reset.
   */
  public function testUserResetPasswordUserFloodControlIsCleared() {
    \Drupal::configFactory()->getEditable('user.flood')
      ->set('user_limit', 3)
      ->save();

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

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

    // Use the last password reset URL which was generated.
    $reset_url = $this->getResetURL();
    $this->drupalGet($reset_url . '/login');
    $this->assertLink(t('Log out'));
362
    $this->assertTitle($this->account->getAccountName() . ' | Drupal');
363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389
    $this->drupalLogout();

    // The next request should *not* trigger flood control, since a successful
    // password reset should have cleared flood events for this user.
    $this->drupalGet('user/password');
    $this->drupalPostForm(NULL, $edit, t('Submit'));
    $this->assertValidPasswordReset($edit['name']);
    $this->assertNoPasswordUserFlood();
  }

  /**
   * 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(t('Sorry, @name is not recognized as a username or an e-mail address.', ['@name' => $name]), 'Validation error message shown when trying to request password for invalid account.');
    $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.');
  }

  /**
   * Helper function to make assertions about an invalid password reset.
   */
  public function assertNoValidPasswordReset($name) {
    // Make sure the error text is displayed and no email sent.
    $this->assertText(t('@name is not recognized as a username or an email address.', ['@name' => $name]), 'Validation error message shown when trying to request password for invalid account.');
390
    $this->assertCount(0, $this->drupalGetMails(['id' => 'user_password_reset']), 'No e-mail was sent when requesting a password for an invalid account.');
391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420
  }

  /**
   * Helper function to make assertions about a password reset triggering user flood cotrol.
   */
  public function assertPasswordUserFlood() {
    $this->assertText(t('Too many password recovery requests for this account. It is temporarily blocked. Try again later or contact the site administrator.'), 'User password reset flood error message shown.');
  }

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

  /**
   * Helper function to make assertions about a password reset triggering IP flood cotrol.
   */
  public function assertPasswordIpFlood() {
    $this->assertText(t('Too many password recovery requests from your IP address. It is temporarily blocked. Try again later or contact the site administrator.'), 'IP password reset flood error message shown.');
  }

  /**
   * Helper function to make assertions about a password reset not triggering IP flood control.
   */
  public function assertNoPasswordIpFlood() {
    $this->assertNoText(t('Too many password recovery requests from your IP address. It is temporarily blocked. Try again later or contact the site administrator.'), 'IP password reset flood error message not shown.');
  }

421 422 423
  /**
   * Make sure that users cannot forge password reset URLs of other users.
   */
424
  public function testResetImpersonation() {
425 426
    // Create two identical user accounts except for the user name. They must
    // have the same empty password, so we can't use $this->drupalCreateUser().
427
    $edit = [];
428 429 430 431 432 433 434 435 436 437 438 439
    $edit['name'] = $this->randomMachineName();
    $edit['mail'] = $edit['name'] . '@example.com';
    $edit['status'] = 1;
    $user1 = User::create($edit);
    $user1->save();

    $edit['name'] = $this->randomMachineName();
    $user2 = User::create($edit);
    $user2->save();

    // Unique password hashes are automatically generated, the only way to
    // change that is to update it directly in the database.
440
    Database::getConnection()->update('users_field_data')
441 442 443
      ->fields(['pass' => NULL])
      ->condition('uid', [$user1->id(), $user2->id()], 'IN')
      ->execute();
444
    \Drupal::entityTypeManager()->getStorage('user')->resetCache();
445 446 447 448 449 450 451 452 453 454
    $user1 = User::load($user1->id());
    $user2 = User::load($user2->id());

    $this->assertEqual($user1->getPassword(), $user2->getPassword(), 'Both users have the same password hash.');

    // The password reset URL must not be valid for the second user when only
    // the user ID is changed in the URL.
    $reset_url = user_pass_reset_url($user1);
    $attack_reset_url = str_replace("user/reset/{$user1->id()}", "user/reset/{$user2->id()}", $reset_url);
    $this->drupalGet($attack_reset_url);
455
    $this->drupalPostForm(NULL, NULL, t('Log in'));
456
    $this->assertNoText($user2->getAccountName(), 'The invalid password reset page does not show the user name.');
457
    $this->assertUrl('user/password', [], 'The user is redirected to the password reset request page.');
458
    $this->assertText('You have tried to use a one-time login link that has either been used or is no longer valid. Please request a new one using the form below.');
459
  }
460

461
}