UserPasswordResetTest.php 22.2 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\language\Entity\ConfigurableLanguage;
10
use Drupal\Tests\BrowserTestBase;
11
use Drupal\user\Entity\User;
12 13

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

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

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

31 32 33 34 35 36 37
  /**
   * Language manager object.
   *
   * @var \Drupal\language\LanguageManagerInterface
   */
  protected $languageManager;

38 39 40 41 42
  /**
   * Modules to enable.
   *
   * @var array
   */
43
  protected static $modules = ['block', 'language'];
44

45 46 47 48 49
  /**
   * {@inheritdoc}
   */
  protected $defaultTheme = 'stark';

50 51 52
  /**
   * {@inheritdoc}
   */
53
  protected function setUp(): void {
54 55
    parent::setUp();

56 57 58 59
    // Enable page caching.
    $config = $this->config('system.performance');
    $config->set('cache.page.max_age', 3600);
    $config->save();
60 61
    $this->drupalPlaceBlock('system_menu_block:account');

62 63
    // Create a user.
    $account = $this->drupalCreateUser();
64 65

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

68
    $this->account = User::load($account->id());
69
    $this->account->passRaw = $account->passRaw;
70
    $this->drupalLogout();
71 72 73 74

    // 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);
75
    Database::getConnection()->update('users_field_data')
76
      ->fields(['login' => $account->getLastLoginTime()])
77
      ->condition('uid', $account->id())
78
      ->execute();
79 80 81
  }

  /**
82
   * Tests password reset functionality.
83
   */
84
  public function testUserPasswordReset() {
85 86 87
    // 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()]));
88
    $this->assertSession()->statusCodeEquals(403);
89

90 91
    // Try to reset the password for an invalid account.
    $this->drupalGet('user/password');
92
    $edit = ['name' => $this->randomMachineName()];
93
    $this->submitForm($edit, 'Submit');
94
    $this->assertNoValidPasswordReset($edit['name']);
95 96

    // Reset the password by username via the password reset page.
97 98
    $this->drupalGet('user/password');
    $edit = ['name' => $this->account->getAccountName()];
99
    $this->submitForm($edit, 'Submit');
100
    $this->assertValidPasswordReset($edit['name']);
101 102 103

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

107
    $this->assertSession()->responseHeaderDoesNotExist('X-Drupal-Cache');
108 109 110

    // Ensure the password reset URL is not cached.
    $this->drupalGet($resetURL);
111
    $this->assertSession()->responseHeaderDoesNotExist('X-Drupal-Cache');
112 113

    // Check the one-time login page.
114
    $this->assertText($this->account->getAccountName(), 'One-time login page contains the correct username.');
115
    $this->assertText('This login can be used only once.', 'Found warning about one-time login.');
116
    $this->assertSession()->titleEquals('Reset password | Drupal');
117 118

    // Check successful login.
119
    $this->submitForm([], 'Log in');
120
    $this->assertSession()->linkExists('Log out');
121
    $this->assertSession()->titleEquals($this->account->getAccountName() . ' | Drupal');
122

123
    // Change the forgotten password.
124
    $password = \Drupal::service('password_generator')->generate();
125
    $edit = ['pass[pass1]' => $password, 'pass[pass2]' => $password];
126
    $this->submitForm($edit, 'Save');
127
    $this->assertText('The changes have been saved.', 'Forgotten password changed.');
128 129

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

133
    // Log out, and try to log in again using the same one-time link.
134
    $this->drupalLogout();
135
    $this->drupalGet($resetURL);
136
    $this->submitForm([], 'Log in');
137
    $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.', 'One-time link is no longer valid.');
138

139
    // Request a new password again, this time using the email address.
140
    // Count email messages before to compare with after.
141
    $before = count($this->drupalGetMails(['id' => 'user_password_reset']));
142
    $this->drupalGet('user/password');
143
    $edit = ['name' => $this->account->getEmail()];
144
    $this->submitForm($edit, 'Submit');
145
    $this->assertValidPasswordReset($edit['name']);
146
    $this->assertCount($before + 1, $this->drupalGetMails(['id' => 'user_password_reset']), 'Email sent when requesting password reset using email address.');
147

148 149 150 151
    // Visit the user edit page without pass-reset-token and make sure it does
    // not cause an error.
    $resetURL = $this->getResetURL();
    $this->drupalGet($resetURL);
152
    $this->submitForm([], 'Log in');
153 154 155 156
    $this->drupalGet('user/' . $this->account->id() . '/edit');
    $this->assertNoText('Expected user_string to be a string, NULL given');
    $this->drupalLogout();

157
    // Create a password reset link as if the request time was 60 seconds older than the allowed limit.
158
    $timeout = $this->config('user.settings')->get('password_reset_timeout');
159
    $bogus_timestamp = REQUEST_TIME - $timeout - 60;
160
    $_uid = $this->account->id();
161
    $this->drupalGet("user/reset/$_uid/$bogus_timestamp/" . user_pass_rehash($this->account, $bogus_timestamp));
162
    $this->submitForm([], 'Log in');
163
    $this->assertText('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.');
164 165 166 167 168

    // 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();
169
    $this->drupalGet("user/reset/" . $blocked_account->id() . "/$timestamp/" . user_pass_rehash($blocked_account, $timestamp));
170
    $this->assertSession()->statusCodeEquals(403);
171 172 173 174

    // Verify a blocked user can not request a new password.
    $this->drupalGet('user/password');
    // Count email messages before to compare with after.
175
    $before = count($this->drupalGetMails(['id' => 'user_password_reset']));
176
    $edit = ['name' => $blocked_account->getAccountName()];
177
    $this->submitForm($edit, 'Submit');
178
    $this->assertRaw(t('%name is blocked or has not been activated yet.', ['%name' => $blocked_account->getAccountName()]));
179
    $this->assertCount($before, $this->drupalGetMails(['id' => 'user_password_reset']), 'No email was sent when requesting password reset for a blocked account');
180 181 182

    // Verify a password reset link is invalidated when the user's email address changes.
    $this->drupalGet('user/password');
183
    $edit = ['name' => $this->account->getAccountName()];
184
    $this->submitForm($edit, 'Submit');
185 186 187 188
    $old_email_reset_link = $this->getResetURL();
    $this->account->setEmail("1" . $this->account->getEmail());
    $this->account->save();
    $this->drupalGet($old_email_reset_link);
189
    $this->submitForm([], 'Log in');
190
    $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.', 'One-time link is no longer valid.');
191 192 193 194

    // Verify a password reset link will automatically log a user when /login is
    // appended.
    $this->drupalGet('user/password');
195
    $edit = ['name' => $this->account->getAccountName()];
196
    $this->submitForm($edit, 'Submit');
197 198
    $reset_url = $this->getResetURL();
    $this->drupalGet($reset_url . '/login');
199
    $this->assertSession()->linkExists('Log out');
200
    $this->assertSession()->titleEquals($this->account->getAccountName() . ' | Drupal');
201 202 203 204 205 206 207 208

    // 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');
209
    $this->assertSession()->statusCodeEquals(403);
210 211 212

    $blocked_account->delete();
    $this->drupalGet("user/reset/" . $blocked_account->id() . "/$timestamp/" . user_pass_rehash($blocked_account, $timestamp) . '/login');
213
    $this->assertSession()->statusCodeEquals(403);
214
  }
215

216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288
  /**
   * Tests password reset functionality when user has set preferred language.
   *
   * @dataProvider languagePrefixTestProvider
   */
  public function testUserPasswordResetPreferredLanguage($setPreferredLangcode, $activeLangcode, $prefix, $visitingUrl, $expectedResetUrl, $unexpectedResetUrl) {
    // Set two new languages.
    ConfigurableLanguage::createFromLangcode('fr')->save();
    ConfigurableLanguage::createFromLangcode('zh-hant')->save();

    $this->languageManager = \Drupal::languageManager();

    // Set language prefixes.
    $config = $this->config('language.negotiation');
    $config->set('url.prefixes', ['en' => '', 'fr' => 'fr', 'zh-hant' => 'zh'])->save();
    $this->rebuildContainer();

    $this->account->preferred_langcode = $setPreferredLangcode;
    $this->account->save();
    $this->assertSame($setPreferredLangcode, $this->account->getPreferredLangcode(FALSE));

    // Test Default langcode is different from active langcode when visiting different.
    if ($setPreferredLangcode !== 'en') {
      $this->drupalGet($prefix . '/user/password');
      $this->assertSame($activeLangcode, $this->getSession()->getResponseHeader('Content-language'));
      $this->assertSame('en', $this->languageManager->getDefaultLanguage()->getId());
    }

    // Test password reset with language prefixes.
    $this->drupalGet($visitingUrl);
    $edit = ['name' => $this->account->getAccountName()];
    $this->submitForm($edit, t('Submit'));
    $this->assertValidPasswordReset($edit['name']);

    $resetURL = $this->getResetURL();
    $this->assertStringContainsString($expectedResetUrl, $resetURL);
    $this->assertStringNotContainsString($unexpectedResetUrl, $resetURL);
  }

  /**
   * Data provider for testUserPasswordResetPreferredLanguage().
   *
   * @return array
   */
  public function languagePrefixTestProvider() {
    return [
      'Test language prefix set as \'\', visiting default with preferred language as en' => [
        'setPreferredLangcode' => 'en',
        'activeLangcode' => 'en',
        'prefix' => '',
        'visitingUrl' => 'user/password',
        'expectedResetUrl' => 'user/reset',
        'unexpectedResetUrl' => 'en/user/reset',
      ],
      'Test language prefix set as fr, visiting zh with preferred language as fr' => [
        'setPreferredLangcode' => 'fr',
        'activeLangcode' => 'fr',
        'prefix' => 'fr',
        'visitingUrl' => 'zh/user/password',
        'expectedResetUrl' => 'fr/user/reset',
        'unexpectedResetUrl' => 'zh/user/reset',
      ],
      'Test language prefix set as zh, visiting zh with preferred language as \'\'' => [
        'setPreferredLangcode' => '',
        'activeLangcode' => 'zh-hant',
        'prefix' => 'zh',
        'visitingUrl' => 'zh/user/password',
        'expectedResetUrl' => 'user/reset',
        'unexpectedResetUrl' => 'zh/user/reset',
      ],
    ];
  }

289
  /**
290
   * Retrieves password reset email and extracts the login link.
291 292 293 294 295
   */
  public function getResetURL() {
    // Assume the most recent email.
    $_emails = $this->drupalGetMails();
    $email = end($_emails);
296
    $urls = [];
297 298 299 300
    preg_match('#.+user/reset/.+#', $email['body'], $urls);

    return $urls[0];
  }
301

302 303 304 305
  /**
   * Test user password reset while logged in.
   */
  public function testUserPasswordResetLoggedIn() {
306 307 308
    $another_account = $this->drupalCreateUser();
    $this->drupalLogin($another_account);
    $this->drupalGet('user/password');
309
    $this->submitForm([], 'Submit');
310 311 312 313 314 315 316 317

    // 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.',
318
      ['%other_user' => $this->account->getAccountName(), '%resetting_user' => $another_account->getAccountName(), ':logout' => Url::fromRoute('user.logout')->toString()]
319 320 321 322 323 324
    ));

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

325 326 327 328 329
    // Log in.
    $this->drupalLogin($this->account);

    // Reset the password by username via the password reset page.
    $this->drupalGet('user/password');
330
    $this->submitForm([], 'Submit');
331 332 333 334

    // Click the reset URL while logged and change our password.
    $resetURL = $this->getResetURL();
    $this->drupalGet($resetURL);
335
    $this->submitForm([], 'Log in');
336 337

    // Change the password.
338
    $password = \Drupal::service('password_generator')->generate();
339
    $edit = ['pass[pass1]' => $password, 'pass[pass2]' => $password];
340
    $this->submitForm($edit, 'Save');
341
    $this->assertText('The changes have been saved.', 'Password changed.');
342 343 344 345 346

    // 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');
347
    $this->assertSession()->statusCodeEquals(403);
348
    $this->drupalGet("user/reset/" . $this->account->id());
349
    $this->assertSession()->statusCodeEquals(403);
350 351
  }

352 353 354
  /**
   * Prefill the text box on incorrect login via link to password reset page.
   */
355 356
  public function testUserResetPasswordTextboxFilled() {
    $this->drupalGet('user/login');
357
    $edit = [
358 359
      'name' => $this->randomMachineName(),
      'pass' => $this->randomMachineName(),
360
    ];
361
    $this->drupalPostForm('user/login', $edit, 'Log in');
362
    $this->assertRaw(t('Unrecognized username or password. <a href=":password">Forgot your password?</a>',
363
      [':password' => Url::fromRoute('user.pass', [], ['query' => ['name' => $edit['name']]])->toString()]));
364
    unset($edit['pass']);
365
    $this->drupalGet('user/password', ['query' => ['name' => $edit['name']]]);
366
    $this->assertSession()->fieldValueEquals('name', $edit['name']);
367 368
    // Ensure the name field value is not cached.
    $this->drupalGet('user/password');
369
    $this->assertSession()->fieldValueNotEquals('name', $edit['name']);
370
  }
371

372 373 374 375 376 377 378 379 380 381 382 383 384
  /**
   * 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');
385
      $this->submitForm($edit, 'Submit');
386 387 388 389 390 391
      $this->assertValidPasswordReset($edit['name']);
      $this->assertNoPasswordUserFlood();
    }

    // The next request should trigger flood control.
    $this->drupalGet('user/password');
392
    $this->submitForm($edit, 'Submit');
393 394 395 396 397 398 399 400 401 402 403 404 405 406 407
    $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()];
408
      $this->submitForm($edit, 'Submit');
409 410 411 412 413 414 415 416
      // 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()];
417
    $this->submitForm($edit, 'Submit');
418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433
    $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');
434
      $this->submitForm($edit, 'Submit');
435 436 437 438 439 440 441
      $this->assertValidPasswordReset($edit['name']);
      $this->assertNoPasswordUserFlood();
    }

    // Use the last password reset URL which was generated.
    $reset_url = $this->getResetURL();
    $this->drupalGet($reset_url . '/login');
442
    $this->assertSession()->linkExists('Log out');
443
    $this->assertSession()->titleEquals($this->account->getAccountName() . ' | Drupal');
444 445 446 447 448
    $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');
449
    $this->submitForm($edit, 'Submit');
450 451 452 453 454 455 456 457 458
    $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.
459
    $this->assertNoText("Sorry, $name is not recognized as a username or an e-mail address.", 'Validation error message shown when trying to request password for invalid account.');
460 461 462 463 464 465 466 467 468 469
    $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.
470
    $this->assertText($name . ' is not recognized as a username or an email address.', 'Validation error message shown when trying to request password for invalid account.');
471
    $this->assertCount(0, $this->drupalGetMails(['id' => 'user_password_reset']), 'No e-mail was sent when requesting a password for an invalid account.');
472 473 474
  }

  /**
475
   * Makes assertions about a password reset triggering user flood control.
476 477
   */
  public function assertPasswordUserFlood() {
478
    $this->assertText('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.');
479 480 481
  }

  /**
482
   * Makes assertions about a password reset not triggering user flood control.
483 484
   */
  public function assertNoPasswordUserFlood() {
485
    $this->assertNoText('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.');
486 487 488
  }

  /**
489
   * Makes assertions about a password reset triggering IP flood control.
490 491
   */
  public function assertPasswordIpFlood() {
492
    $this->assertText('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.');
493 494 495
  }

  /**
496
   * Makes assertions about a password reset not triggering IP flood control.
497 498
   */
  public function assertNoPasswordIpFlood() {
499
    $this->assertNoText('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.');
500 501
  }

502 503 504
  /**
   * Make sure that users cannot forge password reset URLs of other users.
   */
505
  public function testResetImpersonation() {
506 507
    // Create two identical user accounts except for the user name. They must
    // have the same empty password, so we can't use $this->drupalCreateUser().
508
    $edit = [];
509 510 511 512 513 514 515 516 517 518 519 520
    $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.
521
    Database::getConnection()->update('users_field_data')
522 523 524
      ->fields(['pass' => NULL])
      ->condition('uid', [$user1->id(), $user2->id()], 'IN')
      ->execute();
525
    \Drupal::entityTypeManager()->getStorage('user')->resetCache();
526 527 528 529 530 531 532 533 534 535
    $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);
536
    $this->submitForm([], 'Log in');
537
    $this->assertNoText($user2->getAccountName(), 'The invalid password reset page does not show the user name.');
538
    $this->assertSession()->addressEquals('user/password');
539
    $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.');
540
  }
541

542
}