2 // This file is part of Moodle - http://moodle.org/
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.
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.
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/>.
21 * Authentication related tests.
25 * @copyright 2012 Petr Skoda {@link http://skodak.org}
26 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
28 class authlib_test
extends \advanced_testcase
{
29 public function test_lockout() {
31 require_once("$CFG->libdir/authlib.php");
33 $this->resetAfterTest();
35 $oldlog = ini_get('error_log');
36 ini_set('error_log', "$CFG->dataroot/testlog.log"); // Prevent standard logging.
38 unset_config('noemailever');
40 set_config('lockoutthreshold', 0);
41 set_config('lockoutwindow', 60*20);
42 set_config('lockoutduration', 60*30);
44 $user = $this->getDataGenerator()->create_user();
46 // Test lockout is disabled when threshold not set.
48 $this->assertFalse(login_is_lockedout($user));
49 login_attempt_failed($user);
50 login_attempt_failed($user);
51 login_attempt_failed($user);
52 login_attempt_failed($user);
53 $this->assertFalse(login_is_lockedout($user));
55 // Test lockout threshold works.
57 set_config('lockoutthreshold', 3);
58 login_attempt_failed($user);
59 login_attempt_failed($user);
60 $this->assertFalse(login_is_lockedout($user));
61 $sink = $this->redirectEmails();
62 login_attempt_failed($user);
63 $this->assertCount(1, $sink->get_messages());
65 $this->assertTrue(login_is_lockedout($user));
69 login_unlock_account($user);
70 $this->assertFalse(login_is_lockedout($user));
72 // Test lockout window works.
74 login_attempt_failed($user);
75 login_attempt_failed($user);
76 $this->assertFalse(login_is_lockedout($user));
77 set_user_preference('login_failed_last', time()-60*20-10, $user);
78 login_attempt_failed($user);
79 $this->assertFalse(login_is_lockedout($user));
81 // Test valid login resets window.
83 login_attempt_valid($user);
84 $this->assertFalse(login_is_lockedout($user));
85 login_attempt_failed($user);
86 login_attempt_failed($user);
87 $this->assertFalse(login_is_lockedout($user));
89 // Test lock duration works.
91 $sink = $this->redirectEmails();
92 login_attempt_failed($user);
93 $this->assertCount(1, $sink->get_messages());
95 $this->assertTrue(login_is_lockedout($user));
96 set_user_preference('login_lockout', time()-60*30+
10, $user);
97 $this->assertTrue(login_is_lockedout($user));
98 set_user_preference('login_lockout', time()-60*30-10, $user);
99 $this->assertFalse(login_is_lockedout($user));
101 // Test lockout ignored pref works.
103 set_user_preference('login_lockout_ignored', 1, $user);
104 login_attempt_failed($user);
105 login_attempt_failed($user);
106 login_attempt_failed($user);
107 login_attempt_failed($user);
108 $this->assertFalse(login_is_lockedout($user));
110 ini_set('error_log', $oldlog);
113 public function test_authenticate_user_login() {
116 $this->resetAfterTest();
118 $oldlog = ini_get('error_log');
119 ini_set('error_log', "$CFG->dataroot/testlog.log"); // Prevent standard logging.
121 unset_config('noemailever');
123 set_config('lockoutthreshold', 0);
124 set_config('lockoutwindow', 60*20);
125 set_config('lockoutduration', 60*30);
127 $_SERVER['HTTP_USER_AGENT'] = 'no browser'; // Hack around missing user agent in CLI scripts.
129 $user1 = $this->getDataGenerator()->create_user(array('username'=>'username1', 'password'=>'password1', 'email'=>'email1@example.com'));
130 $user2 = $this->getDataGenerator()->create_user(array('username'=>'username2', 'password'=>'password2', 'email'=>'email2@example.com', 'suspended'=>1));
131 $user3 = $this->getDataGenerator()->create_user(array('username'=>'username3', 'password'=>'password3', 'email'=>'email2@example.com', 'auth'=>'nologin'));
134 $sink = $this->redirectEvents();
135 $result = authenticate_user_login('username1', 'password1');
136 $events = $sink->get_events();
138 $this->assertEmpty($events);
139 $this->assertInstanceOf('stdClass', $result);
140 $this->assertEquals($user1->id
, $result->id
);
142 // Normal login with reason.
144 $sink = $this->redirectEvents();
145 $result = authenticate_user_login('username1', 'password1', false, $reason);
146 $events = $sink->get_events();
148 $this->assertEmpty($events);
149 $this->assertInstanceOf('stdClass', $result);
150 $this->assertEquals(AUTH_LOGIN_OK
, $reason);
152 // Test login via email
154 $this->assertEmpty($CFG->authloginviaemail
);
155 $sink = $this->redirectEvents();
156 $result = authenticate_user_login('email1@example.com', 'password1', false, $reason);
158 $this->assertFalse($result);
159 $this->assertEquals(AUTH_LOGIN_NOUSER
, $reason);
161 set_config('authloginviaemail', 1);
162 $this->assertNotEmpty($CFG->authloginviaemail
);
163 $sink = $this->redirectEvents();
164 $result = authenticate_user_login('email1@example.com', 'password1');
165 $events = $sink->get_events();
167 $this->assertEmpty($events);
168 $this->assertInstanceOf('stdClass', $result);
169 $this->assertEquals($user1->id
, $result->id
);
172 $sink = $this->redirectEvents();
173 $result = authenticate_user_login('email2@example.com', 'password2', false, $reason);
174 $events = $sink->get_events();
176 $this->assertFalse($result);
177 $this->assertEquals(AUTH_LOGIN_NOUSER
, $reason);
178 set_config('authloginviaemail', 0);
181 // Capture failed login event.
182 $sink = $this->redirectEvents();
183 $result = authenticate_user_login('username1', 'nopass', false, $reason);
184 $events = $sink->get_events();
186 $event = array_pop($events);
188 $this->assertFalse($result);
189 $this->assertEquals(AUTH_LOGIN_FAILED
, $reason);
191 $this->assertInstanceOf('\core\event\user_login_failed', $event);
192 $eventdata = $event->get_data();
193 $this->assertSame($eventdata['other']['username'], 'username1');
194 $this->assertSame($eventdata['other']['reason'], AUTH_LOGIN_FAILED
);
195 $this->assertEventContextNotUsed($event);
197 // Capture failed login token.
198 unset($CFG->alternateloginurl
);
199 unset($CFG->disablelogintoken
);
200 $sink = $this->redirectEvents();
201 $result = authenticate_user_login('username1', 'password1', false, $reason, 'invalidtoken');
202 $events = $sink->get_events();
204 $event = array_pop($events);
206 $this->assertFalse($result);
207 $this->assertEquals(AUTH_LOGIN_FAILED
, $reason);
209 $this->assertInstanceOf('\core\event\user_login_failed', $event);
210 $eventdata = $event->get_data();
211 $this->assertSame($eventdata['other']['username'], 'username1');
212 $this->assertSame($eventdata['other']['reason'], AUTH_LOGIN_FAILED
);
213 $this->assertEventContextNotUsed($event);
215 // Login should work with invalid token if CFG login token settings override it.
216 $CFG->alternateloginurl
= 'http://localhost/';
217 $sink = $this->redirectEvents();
218 $result = authenticate_user_login('username1', 'password1', false, $reason, 'invalidtoken');
219 $events = $sink->get_events();
221 $this->assertEmpty($events);
222 $this->assertInstanceOf('stdClass', $result);
223 $this->assertEquals(AUTH_LOGIN_OK
, $reason);
225 unset($CFG->alternateloginurl
);
226 $CFG->disablelogintoken
= true;
228 $sink = $this->redirectEvents();
229 $result = authenticate_user_login('username1', 'password1', false, $reason, 'invalidtoken');
230 $events = $sink->get_events();
232 $this->assertEmpty($events);
233 $this->assertInstanceOf('stdClass', $result);
234 $this->assertEquals(AUTH_LOGIN_OK
, $reason);
236 unset($CFG->disablelogintoken
);
237 // Normal login with valid token.
239 $token = \core\session\manager
::get_login_token();
240 $sink = $this->redirectEvents();
241 $result = authenticate_user_login('username1', 'password1', false, $reason, $token);
242 $events = $sink->get_events();
244 $this->assertEmpty($events);
245 $this->assertInstanceOf('stdClass', $result);
246 $this->assertEquals(AUTH_LOGIN_OK
, $reason);
249 // Capture failed login event.
250 $sink = $this->redirectEvents();
251 $result = authenticate_user_login('username2', 'password2', false, $reason);
252 $events = $sink->get_events();
254 $event = array_pop($events);
256 $this->assertFalse($result);
257 $this->assertEquals(AUTH_LOGIN_SUSPENDED
, $reason);
259 $this->assertInstanceOf('\core\event\user_login_failed', $event);
260 $eventdata = $event->get_data();
261 $this->assertSame($eventdata['other']['username'], 'username2');
262 $this->assertSame($eventdata['other']['reason'], AUTH_LOGIN_SUSPENDED
);
263 $this->assertEventContextNotUsed($event);
266 // Capture failed login event.
267 $sink = $this->redirectEvents();
268 $result = authenticate_user_login('username3', 'password3', false, $reason);
269 $events = $sink->get_events();
271 $event = array_pop($events);
273 $this->assertFalse($result);
274 $this->assertEquals(AUTH_LOGIN_SUSPENDED
, $reason);
276 $this->assertInstanceOf('\core\event\user_login_failed', $event);
277 $eventdata = $event->get_data();
278 $this->assertSame($eventdata['other']['username'], 'username3');
279 $this->assertSame($eventdata['other']['reason'], AUTH_LOGIN_SUSPENDED
);
280 $this->assertEventContextNotUsed($event);
283 // Capture failed login event.
284 $sink = $this->redirectEvents();
285 $result = authenticate_user_login('username4', 'password3', false, $reason);
286 $events = $sink->get_events();
288 $event = array_pop($events);
290 $this->assertFalse($result);
291 $this->assertEquals(AUTH_LOGIN_NOUSER
, $reason);
293 $this->assertInstanceOf('\core\event\user_login_failed', $event);
294 $eventdata = $event->get_data();
295 $this->assertSame($eventdata['other']['username'], 'username4');
296 $this->assertSame($eventdata['other']['reason'], AUTH_LOGIN_NOUSER
);
297 $this->assertEventContextNotUsed($event);
299 set_config('lockoutthreshold', 3);
302 $result = authenticate_user_login('username1', 'nopass', false, $reason);
303 $this->assertFalse($result);
304 $this->assertEquals(AUTH_LOGIN_FAILED
, $reason);
305 $result = authenticate_user_login('username1', 'nopass', false, $reason);
306 $this->assertFalse($result);
307 $this->assertEquals(AUTH_LOGIN_FAILED
, $reason);
308 $sink = $this->redirectEmails();
309 $result = authenticate_user_login('username1', 'nopass', false, $reason);
310 $this->assertCount(1, $sink->get_messages());
312 $this->assertFalse($result);
313 $this->assertEquals(AUTH_LOGIN_FAILED
, $reason);
315 $result = authenticate_user_login('username1', 'password1', false, $reason);
316 $this->assertFalse($result);
317 $this->assertEquals(AUTH_LOGIN_LOCKOUT
, $reason);
319 $result = authenticate_user_login('username1', 'password1', true, $reason);
320 $this->assertInstanceOf('stdClass', $result);
321 $this->assertEquals(AUTH_LOGIN_OK
, $reason);
323 ini_set('error_log', $oldlog);
325 // Test password policy check on login.
326 $CFG->passwordpolicy
= 0;
327 $CFG->passwordpolicycheckonlogin
= 1;
329 // First test with password policy disabled.
330 $user4 = $this->getDataGenerator()->create_user(array('username' => 'username4', 'password' => 'a'));
331 $sink = $this->redirectEvents();
333 $result = authenticate_user_login('username4', 'a', false, $reason);
334 $events = $sink->get_events();
336 $notifications = notification
::fetch();
337 $this->assertInstanceOf('stdClass', $result);
338 $this->assertEquals(AUTH_LOGIN_OK
, $reason);
339 $this->assertEquals(get_user_preferences('auth_forcepasswordchange', false, $result), false);
341 $this->assertEquals(count($events), 0);
342 // Check no notifications.
343 $this->assertEquals(count($notifications), 0);
345 // Now test with the password policy enabled, flip reset flag.
346 $sink = $this->redirectEvents();
348 $CFG->passwordpolicy
= 1;
349 $result = authenticate_user_login('username4', 'a', false, $reason);
350 $events = $sink->get_events();
352 $this->assertInstanceOf('stdClass', $result);
353 $this->assertEquals(AUTH_LOGIN_OK
, $reason);
354 $this->assertEquals(get_user_preferences('auth_forcepasswordchange', true, $result), true);
355 // Check that an event was emitted for the policy failure.
356 $this->assertEquals(count($events), 1);
357 $this->assertEquals(reset($events)->eventname
, '\core\event\user_password_policy_failed');
358 // Check notification fired.
359 $notifications = notification
::fetch();
360 $this->assertEquals(count($notifications), 1);
362 // Now the same tests with a user that passes the password policy.
363 $user5 = $this->getDataGenerator()->create_user(array('username' => 'username5', 'password' => 'ThisPassword1sSecure!'));
365 $CFG->passwordpolicy
= 0;
366 $sink = $this->redirectEvents();
367 $result = authenticate_user_login('username5', 'ThisPassword1sSecure!', false, $reason);
368 $events = $sink->get_events();
370 $notifications = notification
::fetch();
371 $this->assertInstanceOf('stdClass', $result);
372 $this->assertEquals(AUTH_LOGIN_OK
, $reason);
373 $this->assertEquals(get_user_preferences('auth_forcepasswordchange', false, $result), false);
375 $this->assertEquals(count($events), 0);
376 // Check no notifications.
377 $this->assertEquals(count($notifications), 0);
380 $CFG->passwordpolicy
= 1;
381 $sink = $this->redirectEvents();
382 $result = authenticate_user_login('username5', 'ThisPassword1sSecure!', false, $reason);
383 $events = $sink->get_events();
385 $notifications = notification
::fetch();
386 $this->assertInstanceOf('stdClass', $result);
387 $this->assertEquals(AUTH_LOGIN_OK
, $reason);
388 $this->assertEquals(get_user_preferences('auth_forcepasswordchange', false, $result), false);
390 $this->assertEquals(count($events), 0);
391 // Check no notifications.
392 $this->assertEquals(count($notifications), 0);
394 // Capture failed login reCaptcha.
395 $CFG->recaptchapublickey
= 'randompublickey';
396 $CFG->recaptchaprivatekey
= 'randomprivatekey';
397 $CFG->enableloginrecaptcha
= true;
399 // Login with blank captcha.
400 $sink = $this->redirectEvents();
401 $result = authenticate_user_login('username1', 'password1', false, $reason, false, '');
402 $events = $sink->get_events();
404 $event = array_pop($events);
406 $this->assertFalse($result);
407 $this->assertEquals(AUTH_LOGIN_FAILED_RECAPTCHA
, $reason);
410 $this->assertInstanceOf('\core\event\user_login_failed', $event);
411 $eventdata = $event->get_data();
412 $this->assertSame($eventdata['other']['username'], 'username1');
413 $this->assertSame($eventdata['other']['reason'], AUTH_LOGIN_FAILED_RECAPTCHA
);
414 $this->assertEventContextNotUsed($event);
416 // Login with invalid captcha.
417 $sink = $this->redirectEvents();
418 $result = authenticate_user_login('username1', 'password1', false, $reason, false, 'invalidcaptcha');
419 $events = $sink->get_events();
421 $event = array_pop($events);
423 $this->assertFalse($result);
424 $this->assertEquals(AUTH_LOGIN_FAILED_RECAPTCHA
, $reason);
427 $this->assertInstanceOf('\core\event\user_login_failed', $event);
428 $eventdata = $event->get_data();
429 $this->assertSame($eventdata['other']['username'], 'username1');
430 $this->assertSame($eventdata['other']['reason'], AUTH_LOGIN_FAILED_RECAPTCHA
);
431 $this->assertEventContextNotUsed($event);
434 unset($CFG->recaptchapublickey
);
435 unset($CFG->recaptchaprivatekey
);
436 unset($CFG->enableloginrecaptcha
);
439 public function test_user_loggedin_event_exceptions() {
441 $event = \core\event\user_loggedin
::create(array('objectid' => 1));
442 $this->fail('\core\event\user_loggedin requires other[\'username\']');
443 } catch(\Exception
$e) {
444 $this->assertInstanceOf('coding_exception', $e);
449 * Test the {@link signup_validate_data()} duplicate email validation.
451 public function test_signup_validate_data_same_email() {
453 require_once($CFG->libdir
. '/authlib.php');
454 require_once($CFG->libdir
. '/phpmailer/moodle_phpmailer.php');
455 require_once($CFG->dirroot
. '/user/profile/lib.php');
457 $this->resetAfterTest();
459 $CFG->registerauth
= 'email';
460 $CFG->passwordpolicy
= false;
462 // In this test, we want to check accent-sensitive email search. However, accented email addresses do not pass
463 // the default `validate_email()` and Moodle does not yet provide a CFG switch to allow such emails. So we
464 // inject our own validation method here and revert it back once we are done. This custom validator method is
465 // identical to the default 'php' validator with the only difference: it has the FILTER_FLAG_EMAIL_UNICODE set
466 // so that it allows to use non-ASCII characters in email addresses.
467 $defaultvalidator = \moodle_phpmailer
::$validator;
468 \moodle_phpmailer
::$validator = function($address) {
469 return (bool) filter_var($address, FILTER_VALIDATE_EMAIL
, FILTER_FLAG_EMAIL_UNICODE
);
472 // Check that two users cannot share the same email address if the site is configured so.
473 // Emails in Moodle are supposed to be case-insensitive (and accent-sensitive but accents are not yet supported).
474 $CFG->allowaccountssameemail
= false;
476 $u1 = $this->getDataGenerator()->create_user([
477 'username' => 'abcdef',
478 'email' => 'abcdef@example.com',
482 'username' => 'newuser',
483 'firstname' => 'First',
484 'lastname' => 'Last',
485 'password' => 'weak',
486 'email' => 'ABCDEF@example.com',
489 $errors = signup_validate_data($formdata, []);
490 $this->assertStringContainsString('This email address is already registered.', $errors['email']);
492 // Emails are accent-sensitive though so if we change a -> á in the u1's email, it should pass.
493 // Please note that Moodle does not normally support such emails yet. We test the DB search sensitivity here.
494 $formdata['email'] = 'ábcdef@example.com';
495 $errors = signup_validate_data($formdata, []);
496 $this->assertArrayNotHasKey('email', $errors);
498 // Check that users can share the same email if the site is configured so.
499 $CFG->allowaccountssameemail
= true;
500 $formdata['email'] = 'abcdef@example.com';
501 $errors = signup_validate_data($formdata, []);
502 $this->assertArrayNotHasKey('email', $errors);
504 // Restore the original email address validator.
505 \moodle_phpmailer
::$validator = $defaultvalidator;
509 * Test the find_cli_user method
510 * @covers ::find_cli_user
512 public function test_find_cli_user(): void
{
514 require_once("$CFG->libdir/authlib.php");
515 require_once("$CFG->libdir/tests/fixtures/testable_auth_plugin_base.php");
517 $this->resetAfterTest();
519 $user = \testable_auth_plugin_base
::find_cli_admin_user();
520 $this->assertEmpty($user);
522 $u1 = $this->getDataGenerator()->create_user([
523 'username' => 'abcdef',
524 'email' => 'abcdef@example.com',
526 $user = \testable_auth_plugin_base
::find_cli_admin_user();
527 $this->assertEmpty($user); // User is not an admin yet.
529 \testable_auth_plugin_base
::login_cli_admin_user();
530 $this->assertEquals($USER->id
, 0); // User is not logged in.
532 $CFG->siteadmins
.= "," . $u1->id
;
534 \testable_auth_plugin_base
::login_cli_admin_user();
535 $this->assertEquals($USER->id
, $u1->id
); // User is now logged in.
537 $user = \testable_auth_plugin_base
::find_cli_admin_user();
538 $this->assertNotEmpty($user);
542 * Test the get_enabled_auth_plugin_classes method
543 * @covers ::get_enabled_auth_plugin_classes
545 public function test_get_enabled_auth_plugin_classes(): void
{
547 require_once("$CFG->libdir/authlib.php");
548 $plugins = \auth_plugin_base
::get_enabled_auth_plugin_classes();
549 $this->assertEquals(get_class($plugins[0]), 'auth_plugin_manual');
550 $this->assertEquals(count($plugins), 3);