annotate core/modules/user/src/Tests/UserPasswordResetTest.php @ 0:c75dbcec494b

Initial commit from drush-created site
author Chris Cannam
date Thu, 05 Jul 2018 14:24:15 +0000
parents
children
rev   line source
Chris@0 1 <?php
Chris@0 2
Chris@0 3 namespace Drupal\user\Tests;
Chris@0 4
Chris@0 5 use Drupal\Component\Render\FormattableMarkup;
Chris@0 6 use Drupal\Core\Url;
Chris@0 7 use Drupal\system\Tests\Cache\PageCacheTagsTestBase;
Chris@0 8 use Drupal\user\Entity\User;
Chris@0 9
Chris@0 10 /**
Chris@0 11 * Ensure that password reset methods work as expected.
Chris@0 12 *
Chris@0 13 * @group user
Chris@0 14 */
Chris@0 15 class UserPasswordResetTest extends PageCacheTagsTestBase {
Chris@0 16
Chris@0 17 /**
Chris@0 18 * The profile to install as a basis for testing.
Chris@0 19 *
Chris@0 20 * This test uses the standard profile to test the password reset in
Chris@0 21 * combination with an ajax request provided by the user picture configuration
Chris@0 22 * in the standard profile.
Chris@0 23 *
Chris@0 24 * @var string
Chris@0 25 */
Chris@0 26 protected $profile = 'standard';
Chris@0 27
Chris@0 28 /**
Chris@0 29 * The user object to test password resetting.
Chris@0 30 *
Chris@0 31 * @var \Drupal\user\UserInterface
Chris@0 32 */
Chris@0 33 protected $account;
Chris@0 34
Chris@0 35 /**
Chris@0 36 * Modules to enable.
Chris@0 37 *
Chris@0 38 * @var array
Chris@0 39 */
Chris@0 40 public static $modules = ['block'];
Chris@0 41
Chris@0 42 /**
Chris@0 43 * {@inheritdoc}
Chris@0 44 */
Chris@0 45 protected function setUp() {
Chris@0 46 parent::setUp();
Chris@0 47
Chris@0 48 $this->drupalPlaceBlock('system_menu_block:account');
Chris@0 49
Chris@0 50 // Create a user.
Chris@0 51 $account = $this->drupalCreateUser();
Chris@0 52
Chris@0 53 // Activate user by logging in.
Chris@0 54 $this->drupalLogin($account);
Chris@0 55
Chris@0 56 $this->account = User::load($account->id());
Chris@0 57 $this->account->pass_raw = $account->pass_raw;
Chris@0 58 $this->drupalLogout();
Chris@0 59
Chris@0 60 // Set the last login time that is used to generate the one-time link so
Chris@0 61 // that it is definitely over a second ago.
Chris@0 62 $account->login = REQUEST_TIME - mt_rand(10, 100000);
Chris@0 63 db_update('users_field_data')
Chris@0 64 ->fields(['login' => $account->getLastLoginTime()])
Chris@0 65 ->condition('uid', $account->id())
Chris@0 66 ->execute();
Chris@0 67 }
Chris@0 68
Chris@0 69 /**
Chris@0 70 * Tests password reset functionality.
Chris@0 71 */
Chris@0 72 public function testUserPasswordReset() {
Chris@0 73 // Verify that accessing the password reset form without having the session
Chris@0 74 // variables set results in an access denied message.
Chris@0 75 $this->drupalGet(Url::fromRoute('user.reset.form', ['uid' => $this->account->id()]));
Chris@0 76 $this->assertResponse(403);
Chris@0 77
Chris@0 78 // Try to reset the password for an invalid account.
Chris@0 79 $this->drupalGet('user/password');
Chris@0 80
Chris@0 81 $edit = ['name' => $this->randomMachineName(32)];
Chris@0 82 $this->drupalPostForm(NULL, $edit, t('Submit'));
Chris@0 83
Chris@0 84 $this->assertText(t('@name is not recognized as a username or an email address.', ['@name' => $edit['name']]), 'Validation error message shown when trying to request password for invalid account.');
Chris@0 85 $this->assertEqual(count($this->drupalGetMails(['id' => 'user_password_reset'])), 0, 'No email was sent when requesting a password for an invalid account.');
Chris@0 86
Chris@0 87 // Reset the password by username via the password reset page.
Chris@0 88 $edit['name'] = $this->account->getUsername();
Chris@0 89 $this->drupalPostForm(NULL, $edit, t('Submit'));
Chris@0 90
Chris@0 91 // Verify that the user was sent an email.
Chris@0 92 $this->assertMail('to', $this->account->getEmail(), 'Password email sent to user.');
Chris@0 93 $subject = t('Replacement login information for @username at @site', ['@username' => $this->account->getUsername(), '@site' => $this->config('system.site')->get('name')]);
Chris@0 94 $this->assertMail('subject', $subject, 'Password reset email subject is correct.');
Chris@0 95
Chris@0 96 $resetURL = $this->getResetURL();
Chris@0 97 $this->drupalGet($resetURL);
Chris@0 98 // Ensure that the current url does not contain the hash and timestamp.
Chris@0 99 $this->assertUrl(Url::fromRoute('user.reset.form', ['uid' => $this->account->id()]));
Chris@0 100
Chris@0 101 $this->assertFalse($this->drupalGetHeader('X-Drupal-Cache'));
Chris@0 102
Chris@0 103 // Ensure the password reset URL is not cached.
Chris@0 104 $this->drupalGet($resetURL);
Chris@0 105 $this->assertFalse($this->drupalGetHeader('X-Drupal-Cache'));
Chris@0 106
Chris@0 107 // Check the one-time login page.
Chris@0 108 $this->assertText($this->account->getUsername(), 'One-time login page contains the correct username.');
Chris@0 109 $this->assertText(t('This login can be used only once.'), 'Found warning about one-time login.');
Chris@0 110 $this->assertTitle(t('Reset password | Drupal'), 'Page title is "Reset password".');
Chris@0 111
Chris@0 112 // Check successful login.
Chris@0 113 $this->drupalPostForm(NULL, NULL, t('Log in'));
Chris@0 114 $this->assertLink(t('Log out'));
Chris@0 115 $this->assertTitle(t('@name | @site', ['@name' => $this->account->getUsername(), '@site' => $this->config('system.site')->get('name')]), 'Logged in using password reset link.');
Chris@0 116
Chris@0 117 // Make sure the ajax request from uploading a user picture does not
Chris@0 118 // invalidate the reset token.
Chris@0 119 $image = current($this->drupalGetTestFiles('image'));
Chris@0 120 $edit = [
Chris@0 121 'files[user_picture_0]' => \Drupal::service('file_system')->realpath($image->uri),
Chris@0 122 ];
Chris@0 123 $this->drupalPostAjaxForm(NULL, $edit, 'user_picture_0_upload_button');
Chris@0 124
Chris@0 125 // Change the forgotten password.
Chris@0 126 $password = user_password();
Chris@0 127 $edit = ['pass[pass1]' => $password, 'pass[pass2]' => $password];
Chris@0 128 $this->drupalPostForm(NULL, $edit, t('Save'));
Chris@0 129 $this->assertText(t('The changes have been saved.'), 'Forgotten password changed.');
Chris@0 130
Chris@0 131 // Verify that the password reset session has been destroyed.
Chris@0 132 $this->drupalPostForm(NULL, $edit, t('Save'));
Chris@0 133 $this->assertText(t("Your current password is missing or incorrect; it's required to change the Password."), 'Password needed to make profile changes.');
Chris@0 134
Chris@0 135 // Log out, and try to log in again using the same one-time link.
Chris@0 136 $this->drupalLogout();
Chris@0 137 $this->drupalGet($resetURL);
Chris@0 138 $this->drupalPostForm(NULL, NULL, t('Log in'));
Chris@0 139 $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.');
Chris@0 140
Chris@0 141 // Request a new password again, this time using the email address.
Chris@0 142 $this->drupalGet('user/password');
Chris@0 143 // Count email messages before to compare with after.
Chris@0 144 $before = count($this->drupalGetMails(['id' => 'user_password_reset']));
Chris@0 145 $edit = ['name' => $this->account->getEmail()];
Chris@0 146 $this->drupalPostForm(NULL, $edit, t('Submit'));
Chris@0 147 $this->assertTrue(count($this->drupalGetMails(['id' => 'user_password_reset'])) === $before + 1, 'Email sent when requesting password reset using email address.');
Chris@0 148
Chris@0 149 // Visit the user edit page without pass-reset-token and make sure it does
Chris@0 150 // not cause an error.
Chris@0 151 $resetURL = $this->getResetURL();
Chris@0 152 $this->drupalGet($resetURL);
Chris@0 153 $this->drupalPostForm(NULL, NULL, t('Log in'));
Chris@0 154 $this->drupalGet('user/' . $this->account->id() . '/edit');
Chris@0 155 $this->assertNoText('Expected user_string to be a string, NULL given');
Chris@0 156 $this->drupalLogout();
Chris@0 157
Chris@0 158 // Create a password reset link as if the request time was 60 seconds older than the allowed limit.
Chris@0 159 $timeout = $this->config('user.settings')->get('password_reset_timeout');
Chris@0 160 $bogus_timestamp = REQUEST_TIME - $timeout - 60;
Chris@0 161 $_uid = $this->account->id();
Chris@0 162 $this->drupalGet("user/reset/$_uid/$bogus_timestamp/" . user_pass_rehash($this->account, $bogus_timestamp));
Chris@0 163 $this->drupalPostForm(NULL, NULL, t('Log in'));
Chris@0 164 $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.');
Chris@0 165
Chris@0 166 // Create a user, block the account, and verify that a login link is denied.
Chris@0 167 $timestamp = REQUEST_TIME - 1;
Chris@0 168 $blocked_account = $this->drupalCreateUser()->block();
Chris@0 169 $blocked_account->save();
Chris@0 170 $this->drupalGet("user/reset/" . $blocked_account->id() . "/$timestamp/" . user_pass_rehash($blocked_account, $timestamp));
Chris@0 171 $this->assertResponse(403);
Chris@0 172
Chris@0 173 // Verify a blocked user can not request a new password.
Chris@0 174 $this->drupalGet('user/password');
Chris@0 175 // Count email messages before to compare with after.
Chris@0 176 $before = count($this->drupalGetMails(['id' => 'user_password_reset']));
Chris@0 177 $edit = ['name' => $blocked_account->getUsername()];
Chris@0 178 $this->drupalPostForm(NULL, $edit, t('Submit'));
Chris@0 179 $this->assertRaw(t('%name is blocked or has not been activated yet.', ['%name' => $blocked_account->getUsername()]), 'Notified user blocked accounts can not request a new password');
Chris@0 180 $this->assertTrue(count($this->drupalGetMails(['id' => 'user_password_reset'])) === $before, 'No email was sent when requesting password reset for a blocked account');
Chris@0 181
Chris@0 182 // Verify a password reset link is invalidated when the user's email address changes.
Chris@0 183 $this->drupalGet('user/password');
Chris@0 184 $edit = ['name' => $this->account->getUsername()];
Chris@0 185 $this->drupalPostForm(NULL, $edit, t('Submit'));
Chris@0 186 $old_email_reset_link = $this->getResetURL();
Chris@0 187 $this->account->setEmail("1" . $this->account->getEmail());
Chris@0 188 $this->account->save();
Chris@0 189 $this->drupalGet($old_email_reset_link);
Chris@0 190 $this->drupalPostForm(NULL, NULL, t('Log in'));
Chris@0 191 $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.');
Chris@0 192
Chris@0 193 // Verify a password reset link will automatically log a user when /login is
Chris@0 194 // appended.
Chris@0 195 $this->drupalGet('user/password');
Chris@0 196 $edit = ['name' => $this->account->getUsername()];
Chris@0 197 $this->drupalPostForm(NULL, $edit, t('Submit'));
Chris@0 198 $reset_url = $this->getResetURL();
Chris@0 199 $this->drupalGet($reset_url . '/login');
Chris@0 200 $this->assertLink(t('Log out'));
Chris@0 201 $this->assertTitle(t('@name | @site', ['@name' => $this->account->getUsername(), '@site' => $this->config('system.site')->get('name')]), 'Logged in using password reset link.');
Chris@0 202
Chris@0 203 // Ensure blocked and deleted accounts can't access the user.reset.login
Chris@0 204 // route.
Chris@0 205 $this->drupalLogout();
Chris@0 206 $timestamp = REQUEST_TIME - 1;
Chris@0 207 $blocked_account = $this->drupalCreateUser()->block();
Chris@0 208 $blocked_account->save();
Chris@0 209 $this->drupalGet("user/reset/" . $blocked_account->id() . "/$timestamp/" . user_pass_rehash($blocked_account, $timestamp) . '/login');
Chris@0 210 $this->assertResponse(403);
Chris@0 211
Chris@0 212 $blocked_account->delete();
Chris@0 213 $this->drupalGet("user/reset/" . $blocked_account->id() . "/$timestamp/" . user_pass_rehash($blocked_account, $timestamp) . '/login');
Chris@0 214 $this->assertResponse(403);
Chris@0 215 }
Chris@0 216
Chris@0 217 /**
Chris@0 218 * Retrieves password reset email and extracts the login link.
Chris@0 219 */
Chris@0 220 public function getResetURL() {
Chris@0 221 // Assume the most recent email.
Chris@0 222 $_emails = $this->drupalGetMails();
Chris@0 223 $email = end($_emails);
Chris@0 224 $urls = [];
Chris@0 225 preg_match('#.+user/reset/.+#', $email['body'], $urls);
Chris@0 226
Chris@0 227 return $urls[0];
Chris@0 228 }
Chris@0 229
Chris@0 230 /**
Chris@0 231 * Test user password reset while logged in.
Chris@0 232 */
Chris@0 233 public function testUserPasswordResetLoggedIn() {
Chris@0 234 $another_account = $this->drupalCreateUser();
Chris@0 235 $this->drupalLogin($another_account);
Chris@0 236 $this->drupalGet('user/password');
Chris@0 237 $this->drupalPostForm(NULL, NULL, t('Submit'));
Chris@0 238
Chris@0 239 // Click the reset URL while logged and change our password.
Chris@0 240 $resetURL = $this->getResetURL();
Chris@0 241 // Log in as a different user.
Chris@0 242 $this->drupalLogin($this->account);
Chris@0 243 $this->drupalGet($resetURL);
Chris@0 244 $this->assertRaw(new FormattableMarkup(
Chris@0 245 '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.',
Chris@0 246 ['%other_user' => $this->account->getUsername(), '%resetting_user' => $another_account->getUsername(), ':logout' => Url::fromRoute('user.logout')->toString()]
Chris@0 247 ));
Chris@0 248
Chris@0 249 $another_account->delete();
Chris@0 250 $this->drupalGet($resetURL);
Chris@0 251 $this->assertText('The one-time login link you clicked is invalid.');
Chris@0 252
Chris@0 253 // Log in.
Chris@0 254 $this->drupalLogin($this->account);
Chris@0 255
Chris@0 256 // Reset the password by username via the password reset page.
Chris@0 257 $this->drupalGet('user/password');
Chris@0 258 $this->drupalPostForm(NULL, NULL, t('Submit'));
Chris@0 259
Chris@0 260 // Click the reset URL while logged and change our password.
Chris@0 261 $resetURL = $this->getResetURL();
Chris@0 262 $this->drupalGet($resetURL);
Chris@0 263 $this->drupalPostForm(NULL, NULL, t('Log in'));
Chris@0 264
Chris@0 265 // Change the password.
Chris@0 266 $password = user_password();
Chris@0 267 $edit = ['pass[pass1]' => $password, 'pass[pass2]' => $password];
Chris@0 268 $this->drupalPostForm(NULL, $edit, t('Save'));
Chris@0 269 $this->assertText(t('The changes have been saved.'), 'Password changed.');
Chris@0 270
Chris@0 271 // Logged in users should not be able to access the user.reset.login or the
Chris@0 272 // user.reset.form routes.
Chris@0 273 $timestamp = REQUEST_TIME - 1;
Chris@0 274 $this->drupalGet("user/reset/" . $this->account->id() . "/$timestamp/" . user_pass_rehash($this->account, $timestamp) . '/login');
Chris@0 275 $this->assertResponse(403);
Chris@0 276 $this->drupalGet("user/reset/" . $this->account->id());
Chris@0 277 $this->assertResponse(403);
Chris@0 278 }
Chris@0 279
Chris@0 280 /**
Chris@0 281 * Prefill the text box on incorrect login via link to password reset page.
Chris@0 282 */
Chris@0 283 public function testUserResetPasswordTextboxFilled() {
Chris@0 284 $this->drupalGet('user/login');
Chris@0 285 $edit = [
Chris@0 286 'name' => $this->randomMachineName(),
Chris@0 287 'pass' => $this->randomMachineName(),
Chris@0 288 ];
Chris@0 289 $this->drupalPostForm('user/login', $edit, t('Log in'));
Chris@0 290 $this->assertRaw(t('Unrecognized username or password. <a href=":password">Forgot your password?</a>',
Chris@0 291 [':password' => \Drupal::url('user.pass', [], ['query' => ['name' => $edit['name']]])]));
Chris@0 292 unset($edit['pass']);
Chris@0 293 $this->drupalGet('user/password', ['query' => ['name' => $edit['name']]]);
Chris@0 294 $this->assertFieldByName('name', $edit['name'], 'User name found.');
Chris@0 295 // Ensure the name field value is not cached.
Chris@0 296 $this->drupalGet('user/password');
Chris@0 297 $this->assertNoFieldByName('name', $edit['name'], 'User name not found.');
Chris@0 298 }
Chris@0 299
Chris@0 300 /**
Chris@0 301 * Make sure that users cannot forge password reset URLs of other users.
Chris@0 302 */
Chris@0 303 public function testResetImpersonation() {
Chris@0 304 // Create two identical user accounts except for the user name. They must
Chris@0 305 // have the same empty password, so we can't use $this->drupalCreateUser().
Chris@0 306 $edit = [];
Chris@0 307 $edit['name'] = $this->randomMachineName();
Chris@0 308 $edit['mail'] = $edit['name'] . '@example.com';
Chris@0 309 $edit['status'] = 1;
Chris@0 310 $user1 = User::create($edit);
Chris@0 311 $user1->save();
Chris@0 312
Chris@0 313 $edit['name'] = $this->randomMachineName();
Chris@0 314 $user2 = User::create($edit);
Chris@0 315 $user2->save();
Chris@0 316
Chris@0 317 // Unique password hashes are automatically generated, the only way to
Chris@0 318 // change that is to update it directly in the database.
Chris@0 319 db_update('users_field_data')
Chris@0 320 ->fields(['pass' => NULL])
Chris@0 321 ->condition('uid', [$user1->id(), $user2->id()], 'IN')
Chris@0 322 ->execute();
Chris@0 323 \Drupal::entityManager()->getStorage('user')->resetCache();
Chris@0 324 $user1 = User::load($user1->id());
Chris@0 325 $user2 = User::load($user2->id());
Chris@0 326
Chris@0 327 $this->assertEqual($user1->getPassword(), $user2->getPassword(), 'Both users have the same password hash.');
Chris@0 328
Chris@0 329 // The password reset URL must not be valid for the second user when only
Chris@0 330 // the user ID is changed in the URL.
Chris@0 331 $reset_url = user_pass_reset_url($user1);
Chris@0 332 $attack_reset_url = str_replace("user/reset/{$user1->id()}", "user/reset/{$user2->id()}", $reset_url);
Chris@0 333 $this->drupalGet($attack_reset_url);
Chris@0 334 $this->drupalPostForm(NULL, NULL, t('Log in'));
Chris@0 335 $this->assertNoText($user2->getUsername(), 'The invalid password reset page does not show the user name.');
Chris@0 336 $this->assertUrl('user/password', [], 'The user is redirected to the password reset request page.');
Chris@0 337 $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.');
Chris@0 338 }
Chris@0 339
Chris@0 340 }