Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.
   1  <?php
   2  // This file is part of Moodle - http://moodle.org/
   3  //
   4  // Moodle is free software: you can redistribute it and/or modify
   5  // it under the terms of the GNU General Public License as published by
   6  // the Free Software Foundation, either version 3 of the License, or
   7  // (at your option) any later version.
   8  //
   9  // Moodle is distributed in the hope that it will be useful,
  10  // but WITHOUT ANY WARRANTY; without even the implied warranty of
  11  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  12  // GNU General Public License for more details.
  13  //
  14  // You should have received a copy of the GNU General Public License
  15  // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
  16  
  17  /**
  18   * Unit tests for login lib.
  19   *
  20   * @package    core
  21   * @copyright  2017 Juan Leyva
  22   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  23   */
  24  namespace core;
  25  
  26  defined('MOODLE_INTERNAL') || die();
  27  
  28  global $CFG;
  29  require_once($CFG->dirroot . '/login/lib.php');
  30  
  31  /**
  32   * Login lib testcase.
  33   *
  34   * @package    core
  35   * @copyright  2017 Juan Leyva
  36   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  37   */
  38  class login_lib_test extends \advanced_testcase {
  39  
  40      public function test_core_login_process_password_reset_one_time_without_username_protection() {
  41          global $CFG;
  42  
  43          $this->resetAfterTest();
  44          $CFG->protectusernames = 0;
  45          $user = $this->getDataGenerator()->create_user(array('auth' => 'manual'));
  46  
  47          $sink = $this->redirectEmails();
  48  
  49          list($status, $notice, $url) = core_login_process_password_reset($user->username, null);
  50          $this->assertSame('emailresetconfirmsent', $status);
  51          $emails = $sink->get_messages();
  52          $this->assertCount(1, $emails);
  53          $email = reset($emails);
  54          $this->assertSame($user->email, $email->to);
  55          $this->assertNotEmpty($email->header);
  56          $this->assertNotEmpty($email->body);
  57          $this->assertMatchesRegularExpression('/A password reset was requested for your account/',
  58              quoted_printable_decode($email->body));
  59          $sink->clear();
  60      }
  61  
  62      public function test_core_login_process_password_reset_two_consecutive_times_without_username_protection() {
  63          global $CFG;
  64  
  65          $this->resetAfterTest();
  66          $CFG->protectusernames = 0;
  67          $user = $this->getDataGenerator()->create_user(array('auth' => 'manual'));
  68  
  69          $sink = $this->redirectEmails();
  70  
  71          list($status, $notice, $url) = core_login_process_password_reset($user->username, null);
  72          $this->assertSame('emailresetconfirmsent', $status);
  73          // Request for a second time.
  74          list($status, $notice, $url) = core_login_process_password_reset($user->username, null);
  75          $this->assertSame('emailresetconfirmsent', $status);
  76          $emails = $sink->get_messages();
  77          $this->assertCount(2, $emails); // Two emails sent (one per each request).
  78          $email = array_pop($emails);
  79          $this->assertSame($user->email, $email->to);
  80          $this->assertNotEmpty($email->header);
  81          $this->assertNotEmpty($email->body);
  82          $this->assertMatchesRegularExpression('/A password reset was requested for your account/',
  83              quoted_printable_decode($email->body));
  84          $sink->clear();
  85      }
  86  
  87      public function test_core_login_process_password_reset_three_consecutive_times_without_username_protection() {
  88          global $CFG;
  89  
  90          $this->resetAfterTest();
  91          $CFG->protectusernames = 0;
  92          $user = $this->getDataGenerator()->create_user(array('auth' => 'manual'));
  93  
  94          $sink = $this->redirectEmails();
  95  
  96          list($status, $notice, $url) = core_login_process_password_reset($user->username, null);
  97          $this->assertSame('emailresetconfirmsent', $status);
  98          // Request for a second time.
  99          list($status, $notice, $url) = core_login_process_password_reset($user->username, null);
 100          $this->assertSame('emailresetconfirmsent', $status);
 101          // Third time.
 102          list($status, $notice, $url) = core_login_process_password_reset($user->username, null);
 103          $this->assertSame('emailalreadysent', $status);
 104          $emails = $sink->get_messages();
 105          $this->assertCount(2, $emails); // Third time email is not sent.
 106      }
 107  
 108      public function test_core_login_process_password_reset_one_time_with_username_protection() {
 109          global $CFG;
 110  
 111          $this->resetAfterTest();
 112          $CFG->protectusernames = 1;
 113          $user = $this->getDataGenerator()->create_user(array('auth' => 'manual'));
 114  
 115          $sink = $this->redirectEmails();
 116  
 117          list($status, $notice, $url) = core_login_process_password_reset($user->username, null);
 118          $this->assertSame('emailpasswordconfirmmaybesent', $status);   // Generic message not giving clues.
 119          $emails = $sink->get_messages();
 120          $this->assertCount(1, $emails);
 121          $email = reset($emails);
 122          $this->assertSame($user->email, $email->to);
 123          $this->assertNotEmpty($email->header);
 124          $this->assertNotEmpty($email->body);
 125          $this->assertMatchesRegularExpression('/A password reset was requested for your account/',
 126              quoted_printable_decode($email->body));
 127          $sink->clear();
 128      }
 129  
 130      public function test_core_login_process_password_reset_with_preexisting_expired_request_without_username_protection() {
 131          global $CFG, $DB;
 132  
 133          $this->resetAfterTest();
 134          $CFG->protectusernames = 0;
 135          $user = $this->getDataGenerator()->create_user(array('auth' => 'manual'));
 136  
 137          $sink = $this->redirectEmails();
 138  
 139          list($status, $notice, $url) = core_login_process_password_reset($user->username, null);
 140          $this->assertSame('emailresetconfirmsent', $status);
 141          // Request again.
 142          list($status, $notice, $url) = core_login_process_password_reset($user->username, null);
 143          $this->assertSame('emailresetconfirmsent', $status);
 144  
 145          $resetrequests = $DB->get_records('user_password_resets');
 146          $request = reset($resetrequests);
 147          $request->timerequested = time() - YEARSECS;
 148          $DB->update_record('user_password_resets', $request);
 149  
 150          // Request again - third time - but it shuld be expired so we should get an email.
 151          list($status, $notice, $url) = core_login_process_password_reset($user->username, null);
 152          $this->assertSame('emailresetconfirmsent', $status);
 153          $emails = $sink->get_messages();
 154          $this->assertCount(3, $emails); // Normal process, the previous request was deleted.
 155          $email = reset($emails);
 156          $this->assertSame($user->email, $email->to);
 157          $this->assertNotEmpty($email->header);
 158          $this->assertNotEmpty($email->body);
 159          $this->assertMatchesRegularExpression('/A password reset was requested for your account/',
 160              quoted_printable_decode($email->body));
 161          $sink->clear();
 162      }
 163  
 164      public function test_core_login_process_password_reset_disabled_auth() {
 165          $this->resetAfterTest();
 166          $user = $this->getDataGenerator()->create_user(array('auth' => 'oauth2'));
 167  
 168          $sink = $this->redirectEmails();
 169  
 170          core_login_process_password_reset($user->username, null);
 171          $emails = $sink->get_messages();
 172          $this->assertCount(1, $emails);
 173          $email = reset($emails);
 174          $this->assertSame($user->email, $email->to);
 175          $this->assertNotEmpty($email->header);
 176          $this->assertNotEmpty($email->body);
 177          $this->assertMatchesRegularExpression('/Unfortunately your account on this site is disabled/',
 178              quoted_printable_decode($email->body));
 179          $sink->clear();
 180      }
 181  
 182      public function test_core_login_process_password_reset_auth_not_supporting_email_reset() {
 183          global $CFG;
 184  
 185          $this->resetAfterTest();
 186          $CFG->auth = $CFG->auth . ',mnet';
 187          $user = $this->getDataGenerator()->create_user(array('auth' => 'mnet'));
 188  
 189          $sink = $this->redirectEmails();
 190  
 191          core_login_process_password_reset($user->username, null);
 192          $emails = $sink->get_messages();
 193          $this->assertCount(1, $emails);
 194          $email = reset($emails);
 195          $this->assertSame($user->email, $email->to);
 196          $this->assertNotEmpty($email->header);
 197          $this->assertNotEmpty($email->body);
 198          $this->assertMatchesRegularExpression('/Unfortunately passwords cannot be reset on this site/',
 199              quoted_printable_decode($email->body));
 200          $sink->clear();
 201      }
 202  
 203      public function test_core_login_process_password_reset_missing_parameters() {
 204          $this->expectException('moodle_exception');
 205          $this->expectExceptionMessage(get_string('cannotmailconfirm', 'error'));
 206          core_login_process_password_reset(null, null);
 207      }
 208  
 209      public function test_core_login_process_password_reset_invalid_username_with_username_protection() {
 210          global $CFG;
 211          $this->resetAfterTest();
 212          $CFG->protectusernames = 1;
 213          list($status, $notice, $url) = core_login_process_password_reset('72347234nasdfasdf/Ds', null);
 214          $this->assertEquals('emailpasswordconfirmmaybesent', $status);
 215      }
 216  
 217      public function test_core_login_process_password_reset_invalid_username_without_username_protection() {
 218          global $CFG;
 219          $this->resetAfterTest();
 220          $CFG->protectusernames = 0;
 221          list($status, $notice, $url) = core_login_process_password_reset('72347234nasdfasdf/Ds', null);
 222          $this->assertEquals('emailpasswordconfirmnotsent', $status);
 223      }
 224  
 225      public function test_core_login_process_password_reset_invalid_email_without_username_protection() {
 226          global $CFG;
 227          $this->resetAfterTest();
 228          $CFG->protectusernames = 0;
 229          list($status, $notice, $url) = core_login_process_password_reset(null, 'fakeemail@nofd.zdy');
 230          $this->assertEquals('emailpasswordconfirmnotsent', $status);
 231      }
 232  
 233      /**
 234       * Data provider for \core_login_lib_testcase::test_core_login_validate_forgot_password_data().
 235       */
 236      public function forgot_password_data_provider() {
 237          return [
 238              'Both username and password supplied' => [
 239                  [
 240                      'username' => 's1',
 241                      'email' => 's1@example.com'
 242                  ],
 243                  [
 244                      'username' => get_string('usernameoremail'),
 245                      'email' => get_string('usernameoremail'),
 246                  ]
 247              ],
 248              'Valid username' => [
 249                  ['username' => 's1']
 250              ],
 251              'Valid username, different case' => [
 252                  ['username' => 'S1']
 253              ],
 254              'Valid username, different case, username protection off' => [
 255                  ['username' => 'S1'],
 256                  [],
 257                  ['protectusernames' => 0]
 258              ],
 259              'Non-existent username' => [
 260                  ['username' => 's2'],
 261              ],
 262              'Non-existing username, username protection off' => [
 263                  ['username' => 's2'],
 264                  ['username' => get_string('usernamenotfound')],
 265                  ['protectusernames' => 0]
 266              ],
 267              'Valid username, unconfirmed username, username protection on' => [
 268                  ['username' => 's1'],
 269                  [],
 270                  ['confirmed' => 0]
 271              ],
 272              'Invalid email' => [
 273                  ['email' => 's1-example.com'],
 274                  ['email' => get_string('invalidemail')]
 275              ],
 276              'Multiple accounts with the same email, username protection on' => [
 277                  ['email' => 's1@example.com'],
 278                  [],
 279                  ['allowaccountssameemail' => 1]
 280              ],
 281              'Multiple accounts with the same email, username protection off' => [
 282                  ['email' => 's1@example.com'],
 283                  ['email' => get_string('forgottenduplicate')],
 284                  ['allowaccountssameemail' => 1, 'protectusernames' => 0]
 285              ],
 286              'Multiple accounts with the same email but with different case, username protection is on' => [
 287                  ['email' => 'S1@EXAMPLE.COM'],
 288                  [],
 289                  ['allowaccountssameemail' => 1]
 290              ],
 291              'Multiple accounts with the same email but with different case, username protection is off' => [
 292                  ['email' => 'S1@EXAMPLE.COM'],
 293                  ['email' => get_string('forgottenduplicate')],
 294                  ['allowaccountssameemail' => 1, 'protectusernames' => 0]
 295              ],
 296              'Non-existent email, username protection on' => [
 297                  ['email' => 's2@example.com']
 298              ],
 299              'Non-existent email, username protection off' => [
 300                  ['email' => 's2@example.com'],
 301                  ['email' => get_string('emailnotfound')],
 302                  ['protectusernames' => 0]
 303              ],
 304              'Valid email' => [
 305                  ['email' => 's1@example.com']
 306              ],
 307              'Valid email, different case' => [
 308                  ['email' => 'S1@EXAMPLE.COM']
 309              ],
 310              'Valid email, unconfirmed user, username protection is on' => [
 311                  ['email' => 's1@example.com'],
 312                  [],
 313                  ['confirmed' => 0]
 314              ],
 315              'Valid email, unconfirmed user, username protection is off' => [
 316                  ['email' => 's1@example.com'],
 317                  ['email' => get_string('confirmednot')],
 318                  ['confirmed' => 0, 'protectusernames' => 0]
 319              ],
 320          ];
 321      }
 322  
 323      /**
 324       * Test for core_login_validate_forgot_password_data().
 325       *
 326       * @dataProvider forgot_password_data_provider
 327       * @param array $data Key-value array containing username and email data.
 328       * @param array $errors Key-value array containing error messages for the username and email fields.
 329       * @param array $options Options for $CFG->protectusernames, $CFG->allowaccountssameemail and $user->confirmed.
 330       */
 331      public function test_core_login_validate_forgot_password_data($data, $errors = [], $options = []) {
 332          $this->resetAfterTest();
 333  
 334          // Set config settings we need for our environment.
 335          $protectusernames = $options['protectusernames'] ?? 1;
 336          set_config('protectusernames', $protectusernames);
 337  
 338          $allowaccountssameemail = $options['allowaccountssameemail'] ?? 0;
 339          set_config('allowaccountssameemail', $allowaccountssameemail);
 340  
 341          // Generate the user data.
 342          $generator = $this->getDataGenerator();
 343          $userdata = [
 344              'username' => 's1',
 345              'email' => 's1@example.com',
 346              'confirmed' => $options['confirmed'] ?? 1
 347          ];
 348          $generator->create_user($userdata);
 349  
 350          if ($allowaccountssameemail) {
 351              // Create another user with the same email address.
 352              $generator->create_user(['email' => 's1@example.com']);
 353          }
 354  
 355          // Validate the data.
 356          $validationerrors = core_login_validate_forgot_password_data($data);
 357  
 358          // Check validation errors for the username field.
 359          if (isset($errors['username'])) {
 360              // If we expect and error for the username field, confirm that it's set.
 361              $this->assertArrayHasKey('username', $validationerrors);
 362              // And the actual validation error is equal to the expected validation error.
 363              $this->assertEquals($errors['username'], $validationerrors['username']);
 364          } else {
 365              // If we don't expect that there's a validation for the username field, confirm that it's not set.
 366              $this->assertArrayNotHasKey('username', $validationerrors);
 367          }
 368  
 369          // Check validation errors for the email field.
 370          if (isset($errors['email'])) {
 371              // If we expect and error for the email field, confirm that it's set.
 372              $this->assertArrayHasKey('email', $validationerrors);
 373              // And the actual validation error is equal to the expected validation error.
 374              $this->assertEquals($errors['email'], $validationerrors['email']);
 375          } else {
 376              // If we don't expect that there's a validation for the email field, confirm that it's not set.
 377              $this->assertArrayNotHasKey('email', $validationerrors);
 378          }
 379      }
 380  
 381      /**
 382       * Test searching for the user record by matching the provided email address when resetting password.
 383       *
 384       * Email addresses should be handled as case-insensitive but accent sensitive.
 385       */
 386      public function test_core_login_process_password_reset_email_sensitivity() {
 387          global $CFG;
 388          require_once($CFG->libdir.'/phpmailer/moodle_phpmailer.php');
 389  
 390          $this->resetAfterTest();
 391          $sink = $this->redirectEmails();
 392          $CFG->protectusernames = 0;
 393  
 394          // In this test, we need to mock sending emails on non-ASCII email addresses. However, such email addresses do
 395          // not pass the default `validate_email()` and Moodle does not yet provide a CFG switch to allow such emails.
 396          // So we inject our own validation method here and revert it back once we are done. This custom validator method
 397          // is identical to the default 'php' validator with the only difference: it has the FILTER_FLAG_EMAIL_UNICODE
 398          // set so that it allows to use non-ASCII characters in email addresses.
 399          $defaultvalidator = \moodle_phpmailer::$validator;
 400          \moodle_phpmailer::$validator = function($address) {
 401              return (bool) filter_var($address, FILTER_VALIDATE_EMAIL, FILTER_FLAG_EMAIL_UNICODE);
 402          };
 403  
 404          // Emails are treated as case-insensitive when searching for the matching user account.
 405          $u1 = $this->getDataGenerator()->create_user(['email' => 'priliszlutouckykunupeldabelskeody@example.com']);
 406  
 407          list($status, $notice, $url) = core_login_process_password_reset(null, 'PrIlIsZlUtOuCkYKuNupELdAbElSkEoDy@eXaMpLe.CoM');
 408  
 409          $this->assertSame('emailresetconfirmsent', $status);
 410          $emails = $sink->get_messages();
 411          $this->assertCount(1, $emails);
 412          $email = reset($emails);
 413          $this->assertSame($u1->email, $email->to);
 414          $sink->clear();
 415  
 416          // There may exist two users with same emails.
 417          $u2 = $this->getDataGenerator()->create_user(['email' => 'PRILISZLUTOUCKYKUNUPELDABELSKEODY@example.com']);
 418  
 419          list($status, $notice, $url) = core_login_process_password_reset(null, 'PrIlIsZlUtOuCkYKuNupELdAbElSkEoDy@eXaMpLe.CoM');
 420  
 421          $this->assertSame('emailresetconfirmsent', $status);
 422          $emails = $sink->get_messages();
 423          $this->assertCount(1, $emails);
 424          $email = reset($emails);
 425          $this->assertSame(\core_text::strtolower($u2->email), \core_text::strtolower($email->to));
 426          $sink->clear();
 427  
 428          // However, emails are accent sensitive - note this is the u1's email with a single character a -> á changed.
 429          list($status, $notice, $url) = core_login_process_password_reset(null, 'priliszlutouckykunupeldábelskeody@example.com');
 430  
 431          $this->assertSame('emailpasswordconfirmnotsent', $status);
 432          $emails = $sink->get_messages();
 433          $this->assertCount(0, $emails);
 434          $sink->clear();
 435  
 436          $u3 = $this->getDataGenerator()->create_user(['email' => 'PřílišŽluťoučkýKůňÚpělĎálebskéÓdy@example.com']);
 437  
 438          list($status, $notice, $url) = core_login_process_password_reset(null, 'pŘÍLIŠžLuŤOuČkÝkŮŇúPĚLďÁLEBSKÉóDY@eXaMpLe.CoM');
 439  
 440          $this->assertSame('emailresetconfirmsent', $status);
 441          $emails = $sink->get_messages();
 442          $this->assertCount(1, $emails);
 443          $email = reset($emails);
 444          $this->assertSame($u3->email, $email->to);
 445          $sink->clear();
 446  
 447          // Restore the original email address validator.
 448          \moodle_phpmailer::$validator = $defaultvalidator;
 449      }
 450  
 451  }