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.

Differences Between: [Versions 39 and 310] [Versions 39 and 311] [Versions 39 and 400] [Versions 39 and 401] [Versions 39 and 402] [Versions 39 and 403]

   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   * Authentication related tests.
  19   *
  20   * @package    core_auth
  21   * @category   phpunit
  22   * @copyright  2012 Petr Skoda {@link http://skodak.org}
  23   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  24   */
  25  
  26  defined('MOODLE_INTERNAL') || die();
  27  
  28  
  29  /**
  30   * Functional test for authentication related APIs.
  31   */
  32  class core_authlib_testcase extends advanced_testcase {
  33      public function test_lockout() {
  34          global $CFG;
  35          require_once("$CFG->libdir/authlib.php");
  36  
  37          $this->resetAfterTest();
  38  
  39          $oldlog = ini_get('error_log');
  40          ini_set('error_log', "$CFG->dataroot/testlog.log"); // Prevent standard logging.
  41  
  42          unset_config('noemailever');
  43  
  44          set_config('lockoutthreshold', 0);
  45          set_config('lockoutwindow', 60*20);
  46          set_config('lockoutduration', 60*30);
  47  
  48          $user = $this->getDataGenerator()->create_user();
  49  
  50          // Test lockout is disabled when threshold not set.
  51  
  52          $this->assertFalse(login_is_lockedout($user));
  53          login_attempt_failed($user);
  54          login_attempt_failed($user);
  55          login_attempt_failed($user);
  56          login_attempt_failed($user);
  57          $this->assertFalse(login_is_lockedout($user));
  58  
  59          // Test lockout threshold works.
  60  
  61          set_config('lockoutthreshold', 3);
  62          login_attempt_failed($user);
  63          login_attempt_failed($user);
  64          $this->assertFalse(login_is_lockedout($user));
  65          $sink = $this->redirectEmails();
  66          login_attempt_failed($user);
  67          $this->assertCount(1, $sink->get_messages());
  68          $sink->close();
  69          $this->assertTrue(login_is_lockedout($user));
  70  
  71          // Test unlock works.
  72  
  73          login_unlock_account($user);
  74          $this->assertFalse(login_is_lockedout($user));
  75  
  76          // Test lockout window works.
  77  
  78          login_attempt_failed($user);
  79          login_attempt_failed($user);
  80          $this->assertFalse(login_is_lockedout($user));
  81          set_user_preference('login_failed_last', time()-60*20-10, $user);
  82          login_attempt_failed($user);
  83          $this->assertFalse(login_is_lockedout($user));
  84  
  85          // Test valid login resets window.
  86  
  87          login_attempt_valid($user);
  88          $this->assertFalse(login_is_lockedout($user));
  89          login_attempt_failed($user);
  90          login_attempt_failed($user);
  91          $this->assertFalse(login_is_lockedout($user));
  92  
  93          // Test lock duration works.
  94  
  95          $sink = $this->redirectEmails();
  96          login_attempt_failed($user);
  97          $this->assertCount(1, $sink->get_messages());
  98          $sink->close();
  99          $this->assertTrue(login_is_lockedout($user));
 100          set_user_preference('login_lockout', time()-60*30+10, $user);
 101          $this->assertTrue(login_is_lockedout($user));
 102          set_user_preference('login_lockout', time()-60*30-10, $user);
 103          $this->assertFalse(login_is_lockedout($user));
 104  
 105          // Test lockout ignored pref works.
 106  
 107          set_user_preference('login_lockout_ignored', 1, $user);
 108          login_attempt_failed($user);
 109          login_attempt_failed($user);
 110          login_attempt_failed($user);
 111          login_attempt_failed($user);
 112          $this->assertFalse(login_is_lockedout($user));
 113  
 114          ini_set('error_log', $oldlog);
 115      }
 116  
 117      public function test_authenticate_user_login() {
 118          global $CFG;
 119  
 120          $this->resetAfterTest();
 121  
 122          $oldlog = ini_get('error_log');
 123          ini_set('error_log', "$CFG->dataroot/testlog.log"); // Prevent standard logging.
 124  
 125          unset_config('noemailever');
 126  
 127          set_config('lockoutthreshold', 0);
 128          set_config('lockoutwindow', 60*20);
 129          set_config('lockoutduration', 60*30);
 130  
 131          $_SERVER['HTTP_USER_AGENT'] = 'no browser'; // Hack around missing user agent in CLI scripts.
 132  
 133          $user1 = $this->getDataGenerator()->create_user(array('username'=>'username1', 'password'=>'password1', 'email'=>'email1@example.com'));
 134          $user2 = $this->getDataGenerator()->create_user(array('username'=>'username2', 'password'=>'password2', 'email'=>'email2@example.com', 'suspended'=>1));
 135          $user3 = $this->getDataGenerator()->create_user(array('username'=>'username3', 'password'=>'password3', 'email'=>'email2@example.com', 'auth'=>'nologin'));
 136  
 137          // Normal login.
 138          $sink = $this->redirectEvents();
 139          $result = authenticate_user_login('username1', 'password1');
 140          $events = $sink->get_events();
 141          $sink->close();
 142          $this->assertEmpty($events);
 143          $this->assertInstanceOf('stdClass', $result);
 144          $this->assertEquals($user1->id, $result->id);
 145  
 146          // Normal login with reason.
 147          $reason = null;
 148          $sink = $this->redirectEvents();
 149          $result = authenticate_user_login('username1', 'password1', false, $reason);
 150          $events = $sink->get_events();
 151          $sink->close();
 152          $this->assertEmpty($events);
 153          $this->assertInstanceOf('stdClass', $result);
 154          $this->assertEquals(AUTH_LOGIN_OK, $reason);
 155  
 156          // Test login via email
 157          $reason = null;
 158          $this->assertEmpty($CFG->authloginviaemail);
 159          $sink = $this->redirectEvents();
 160          $result = authenticate_user_login('email1@example.com', 'password1', false, $reason);
 161          $sink->close();
 162          $this->assertFalse($result);
 163          $this->assertEquals(AUTH_LOGIN_NOUSER, $reason);
 164  
 165          set_config('authloginviaemail', 1);
 166          $this->assertNotEmpty($CFG->authloginviaemail);
 167          $sink = $this->redirectEvents();
 168          $result = authenticate_user_login('email1@example.com', 'password1');
 169          $events = $sink->get_events();
 170          $sink->close();
 171          $this->assertEmpty($events);
 172          $this->assertInstanceOf('stdClass', $result);
 173          $this->assertEquals($user1->id, $result->id);
 174  
 175          $reason = null;
 176          $sink = $this->redirectEvents();
 177          $result = authenticate_user_login('email2@example.com', 'password2', false, $reason);
 178          $events = $sink->get_events();
 179          $sink->close();
 180          $this->assertFalse($result);
 181          $this->assertEquals(AUTH_LOGIN_NOUSER, $reason);
 182          set_config('authloginviaemail', 0);
 183  
 184          $reason = null;
 185          // Capture failed login event.
 186          $sink = $this->redirectEvents();
 187          $result = authenticate_user_login('username1', 'nopass', false, $reason);
 188          $events = $sink->get_events();
 189          $sink->close();
 190          $event = array_pop($events);
 191  
 192          $this->assertFalse($result);
 193          $this->assertEquals(AUTH_LOGIN_FAILED, $reason);
 194          // Test Event.
 195          $this->assertInstanceOf('\core\event\user_login_failed', $event);
 196          $expectedlogdata = array(SITEID, 'login', 'error', 'index.php', 'username1');
 197          $this->assertEventLegacyLogData($expectedlogdata, $event);
 198          $eventdata = $event->get_data();
 199          $this->assertSame($eventdata['other']['username'], 'username1');
 200          $this->assertSame($eventdata['other']['reason'], AUTH_LOGIN_FAILED);
 201          $this->assertEventContextNotUsed($event);
 202  
 203          // Capture failed login token.
 204          unset($CFG->alternateloginurl);
 205          unset($CFG->disablelogintoken);
 206          $sink = $this->redirectEvents();
 207          $result = authenticate_user_login('username1', 'password1', false, $reason, 'invalidtoken');
 208          $events = $sink->get_events();
 209          $sink->close();
 210          $event = array_pop($events);
 211  
 212          $this->assertFalse($result);
 213          $this->assertEquals(AUTH_LOGIN_FAILED, $reason);
 214          // Test Event.
 215          $this->assertInstanceOf('\core\event\user_login_failed', $event);
 216          $expectedlogdata = array(SITEID, 'login', 'error', 'index.php', 'username1');
 217          $this->assertEventLegacyLogData($expectedlogdata, $event);
 218          $eventdata = $event->get_data();
 219          $this->assertSame($eventdata['other']['username'], 'username1');
 220          $this->assertSame($eventdata['other']['reason'], AUTH_LOGIN_FAILED);
 221          $this->assertEventContextNotUsed($event);
 222  
 223          // Login should work with invalid token if CFG login token settings override it.
 224          $CFG->alternateloginurl = 'http://localhost/';
 225          $sink = $this->redirectEvents();
 226          $result = authenticate_user_login('username1', 'password1', false, $reason, 'invalidtoken');
 227          $events = $sink->get_events();
 228          $sink->close();
 229          $this->assertEmpty($events);
 230          $this->assertInstanceOf('stdClass', $result);
 231          $this->assertEquals(AUTH_LOGIN_OK, $reason);
 232  
 233          unset($CFG->alternateloginurl);
 234          $CFG->disablelogintoken = true;
 235  
 236          $sink = $this->redirectEvents();
 237          $result = authenticate_user_login('username1', 'password1', false, $reason, 'invalidtoken');
 238          $events = $sink->get_events();
 239          $sink->close();
 240          $this->assertEmpty($events);
 241          $this->assertInstanceOf('stdClass', $result);
 242          $this->assertEquals(AUTH_LOGIN_OK, $reason);
 243  
 244          unset($CFG->disablelogintoken);
 245          // Normal login with valid token.
 246          $reason = null;
 247          $token = \core\session\manager::get_login_token();
 248          $sink = $this->redirectEvents();
 249          $result = authenticate_user_login('username1', 'password1', false, $reason, $token);
 250          $events = $sink->get_events();
 251          $sink->close();
 252          $this->assertEmpty($events);
 253          $this->assertInstanceOf('stdClass', $result);
 254          $this->assertEquals(AUTH_LOGIN_OK, $reason);
 255  
 256          $reason = null;
 257          // Capture failed login event.
 258          $sink = $this->redirectEvents();
 259          $result = authenticate_user_login('username2', 'password2', false, $reason);
 260          $events = $sink->get_events();
 261          $sink->close();
 262          $event = array_pop($events);
 263  
 264          $this->assertFalse($result);
 265          $this->assertEquals(AUTH_LOGIN_SUSPENDED, $reason);
 266          // Test Event.
 267          $this->assertInstanceOf('\core\event\user_login_failed', $event);
 268          $expectedlogdata = array(SITEID, 'login', 'error', 'index.php', 'username2');
 269          $this->assertEventLegacyLogData($expectedlogdata, $event);
 270          $eventdata = $event->get_data();
 271          $this->assertSame($eventdata['other']['username'], 'username2');
 272          $this->assertSame($eventdata['other']['reason'], AUTH_LOGIN_SUSPENDED);
 273          $this->assertEventContextNotUsed($event);
 274  
 275          $reason = null;
 276          // Capture failed login event.
 277          $sink = $this->redirectEvents();
 278          $result = authenticate_user_login('username3', 'password3', false, $reason);
 279          $events = $sink->get_events();
 280          $sink->close();
 281          $event = array_pop($events);
 282  
 283          $this->assertFalse($result);
 284          $this->assertEquals(AUTH_LOGIN_SUSPENDED, $reason);
 285          // Test Event.
 286          $this->assertInstanceOf('\core\event\user_login_failed', $event);
 287          $expectedlogdata = array(SITEID, 'login', 'error', 'index.php', 'username3');
 288          $this->assertEventLegacyLogData($expectedlogdata, $event);
 289          $eventdata = $event->get_data();
 290          $this->assertSame($eventdata['other']['username'], 'username3');
 291          $this->assertSame($eventdata['other']['reason'], AUTH_LOGIN_SUSPENDED);
 292          $this->assertEventContextNotUsed($event);
 293  
 294          $reason = null;
 295          // Capture failed login event.
 296          $sink = $this->redirectEvents();
 297          $result = authenticate_user_login('username4', 'password3', false, $reason);
 298          $events = $sink->get_events();
 299          $sink->close();
 300          $event = array_pop($events);
 301  
 302          $this->assertFalse($result);
 303          $this->assertEquals(AUTH_LOGIN_NOUSER, $reason);
 304          // Test Event.
 305          $this->assertInstanceOf('\core\event\user_login_failed', $event);
 306          $expectedlogdata = array(SITEID, 'login', 'error', 'index.php', 'username4');
 307          $this->assertEventLegacyLogData($expectedlogdata, $event);
 308          $eventdata = $event->get_data();
 309          $this->assertSame($eventdata['other']['username'], 'username4');
 310          $this->assertSame($eventdata['other']['reason'], AUTH_LOGIN_NOUSER);
 311          $this->assertEventContextNotUsed($event);
 312  
 313          set_config('lockoutthreshold', 3);
 314  
 315          $reason = null;
 316          $result = authenticate_user_login('username1', 'nopass', false, $reason);
 317          $this->assertFalse($result);
 318          $this->assertEquals(AUTH_LOGIN_FAILED, $reason);
 319          $result = authenticate_user_login('username1', 'nopass', false, $reason);
 320          $this->assertFalse($result);
 321          $this->assertEquals(AUTH_LOGIN_FAILED, $reason);
 322          $sink = $this->redirectEmails();
 323          $result = authenticate_user_login('username1', 'nopass', false, $reason);
 324          $this->assertCount(1, $sink->get_messages());
 325          $sink->close();
 326          $this->assertFalse($result);
 327          $this->assertEquals(AUTH_LOGIN_FAILED, $reason);
 328  
 329          $result = authenticate_user_login('username1', 'password1', false, $reason);
 330          $this->assertFalse($result);
 331          $this->assertEquals(AUTH_LOGIN_LOCKOUT, $reason);
 332  
 333          $result = authenticate_user_login('username1', 'password1', true, $reason);
 334          $this->assertInstanceOf('stdClass', $result);
 335          $this->assertEquals(AUTH_LOGIN_OK, $reason);
 336  
 337          ini_set('error_log', $oldlog);
 338  
 339          // Test password policy check on login.
 340          $CFG->passwordpolicy = 0;
 341          $CFG->passwordpolicycheckonlogin = 1;
 342  
 343          // First test with password policy disabled.
 344          $user4 = $this->getDataGenerator()->create_user(array('username' => 'username4', 'password' => 'a'));
 345          $sink = $this->redirectEvents();
 346          $reason = null;
 347          $result = authenticate_user_login('username4', 'a', false, $reason);
 348          $events = $sink->get_events();
 349          $sink->close();
 350          $notifications = \core\notification::fetch();
 351          $this->assertInstanceOf('stdClass', $result);
 352          $this->assertEquals(AUTH_LOGIN_OK, $reason);
 353          $this->assertEquals(get_user_preferences('auth_forcepasswordchange', false, $result), false);
 354          // Check no events.
 355          $this->assertEquals(count($events), 0);
 356          // Check no notifications.
 357          $this->assertEquals(count($notifications), 0);
 358  
 359          // Now test with the password policy enabled, flip reset flag.
 360          $sink = $this->redirectEvents();
 361          $reason = null;
 362          $CFG->passwordpolicy = 1;
 363          $result = authenticate_user_login('username4', 'a', false, $reason);
 364          $events = $sink->get_events();
 365          $sink->close();
 366          $this->assertInstanceOf('stdClass', $result);
 367          $this->assertEquals(AUTH_LOGIN_OK, $reason);
 368          $this->assertEquals(get_user_preferences('auth_forcepasswordchange', true, $result), true);
 369          // Check that an event was emitted for the policy failure.
 370          $this->assertEquals(count($events), 1);
 371          $this->assertEquals(reset($events)->eventname, '\core\event\user_password_policy_failed');
 372          // Check notification fired.
 373          $notifications = \core\notification::fetch();
 374          $this->assertEquals(count($notifications), 1);
 375  
 376          // Now the same tests with a user that passes the password policy.
 377          $user5 = $this->getDataGenerator()->create_user(array('username' => 'username5', 'password' => 'ThisPassword1sSecure!'));
 378          $reason = null;
 379          $CFG->passwordpolicy = 0;
 380          $sink = $this->redirectEvents();
 381          $result = authenticate_user_login('username5', 'ThisPassword1sSecure!', false, $reason);
 382          $events = $sink->get_events();
 383          $sink->close();
 384          $notifications = \core\notification::fetch();
 385          $this->assertInstanceOf('stdClass', $result);
 386          $this->assertEquals(AUTH_LOGIN_OK, $reason);
 387          $this->assertEquals(get_user_preferences('auth_forcepasswordchange', false, $result), false);
 388          // Check no events.
 389          $this->assertEquals(count($events), 0);
 390          // Check no notifications.
 391          $this->assertEquals(count($notifications), 0);
 392  
 393          $reason = null;
 394          $CFG->passwordpolicy = 1;
 395          $sink = $this->redirectEvents();
 396          $result = authenticate_user_login('username5', 'ThisPassword1sSecure!', false, $reason);
 397          $events = $sink->get_events();
 398          $sink->close();
 399          $notifications = \core\notification::fetch();
 400          $this->assertInstanceOf('stdClass', $result);
 401          $this->assertEquals(AUTH_LOGIN_OK, $reason);
 402          $this->assertEquals(get_user_preferences('auth_forcepasswordchange', false, $result), false);
 403          // Check no events.
 404          $this->assertEquals(count($events), 0);
 405          // Check no notifications.
 406          $this->assertEquals(count($notifications), 0);
 407      }
 408  
 409      public function test_user_loggedin_event_exceptions() {
 410          try {
 411              $event = \core\event\user_loggedin::create(array('objectid' => 1));
 412              $this->fail('\core\event\user_loggedin requires other[\'username\']');
 413          } catch(Exception $e) {
 414              $this->assertInstanceOf('coding_exception', $e);
 415          }
 416      }
 417  
 418      /**
 419       * Test the {@link signup_validate_data()} duplicate email validation.
 420       */
 421      public function test_signup_validate_data_same_email() {
 422          global $CFG;
 423          require_once($CFG->libdir . '/authlib.php');
 424          require_once($CFG->libdir . '/phpmailer/moodle_phpmailer.php');
 425          require_once($CFG->dirroot . '/user/profile/lib.php');
 426  
 427          $this->resetAfterTest();
 428  
 429          $CFG->registerauth = 'email';
 430          $CFG->passwordpolicy = false;
 431  
 432          // In this test, we want to check accent-sensitive email search. However, accented email addresses do not pass
 433          // the default `validate_email()` and Moodle does not yet provide a CFG switch to allow such emails.  So we
 434          // inject our own validation method here and revert it back once we are done. This custom validator method is
 435          // identical to the default 'php' validator with the only difference: it has the FILTER_FLAG_EMAIL_UNICODE set
 436          // so that it allows to use non-ASCII characters in email addresses.
 437          $defaultvalidator = moodle_phpmailer::$validator;
 438          moodle_phpmailer::$validator = function($address) {
 439              return (bool) filter_var($address, FILTER_VALIDATE_EMAIL, FILTER_FLAG_EMAIL_UNICODE);
 440          };
 441  
 442          // Check that two users cannot share the same email address if the site is configured so.
 443          // Emails in Moodle are supposed to be case-insensitive (and accent-sensitive but accents are not yet supported).
 444          $CFG->allowaccountssameemail = false;
 445  
 446          $u1 = $this->getDataGenerator()->create_user([
 447              'username' => 'abcdef',
 448              'email' => 'abcdef@example.com',
 449          ]);
 450  
 451          $formdata = [
 452              'username' => 'newuser',
 453              'firstname' => 'First',
 454              'lastname' => 'Last',
 455              'password' => 'weak',
 456              'email' => 'ABCDEF@example.com',
 457          ];
 458  
 459          $errors = signup_validate_data($formdata, []);
 460          $this->assertContains('This email address is already registered.', $errors['email']);
 461  
 462          // Emails are accent-sensitive though so if we change a -> á in the u1's email, it should pass.
 463          // Please note that Moodle does not normally support such emails yet. We test the DB search sensitivity here.
 464          $formdata['email'] = 'ábcdef@example.com';
 465          $errors = signup_validate_data($formdata, []);
 466          $this->assertArrayNotHasKey('email', $errors);
 467  
 468          // Check that users can share the same email if the site is configured so.
 469          $CFG->allowaccountssameemail = true;
 470          $formdata['email'] = 'abcdef@example.com';
 471          $errors = signup_validate_data($formdata, []);
 472          $this->assertArrayNotHasKey('email', $errors);
 473  
 474          // Restore the original email address validator.
 475          moodle_phpmailer::$validator = $defaultvalidator;
 476      }
 477  }