Search moodle.org's
Developer Documentation

  • Bug fixes for general core bugs in 3.11.x will end 9 May 2022 (12 months).
  • Bug fixes for security issues in 3.11.x will end 14 November 2022 (18 months).
  • PHP version: minimum PHP 7.3.0 Note: minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is supported too.
  • Differences Between: [Versions 310 and 311] [Versions 35 and 311] [Versions 36 and 311] [Versions 37 and 311] [Versions 38 and 311] [Versions 39 and 311]

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