Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

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