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 $expectedlogdata = array(SITEID
, 'login', 'error', 'index.php', 'username1');
193 $this->assertEventLegacyLogData($expectedlogdata, $event);
194 $eventdata = $event->get_data();
195 $this->assertSame($eventdata['other']['username'], 'username1');
196 $this->assertSame($eventdata['other']['reason'], AUTH_LOGIN_FAILED
);
197 $this->assertEventContextNotUsed($event);
199 // Capture failed login token.
200 unset($CFG->alternateloginurl
);
201 unset($CFG->disablelogintoken
);
202 $sink = $this->redirectEvents();
203 $result = authenticate_user_login('username1', 'password1', false, $reason, 'invalidtoken');
204 $events = $sink->get_events();
206 $event = array_pop($events);
208 $this->assertFalse($result);
209 $this->assertEquals(AUTH_LOGIN_FAILED
, $reason);
211 $this->assertInstanceOf('\core\event\user_login_failed', $event);
212 $expectedlogdata = array(SITEID
, 'login', 'error', 'index.php', 'username1');
213 $this->assertEventLegacyLogData($expectedlogdata, $event);
214 $eventdata = $event->get_data();
215 $this->assertSame($eventdata['other']['username'], 'username1');
216 $this->assertSame($eventdata['other']['reason'], AUTH_LOGIN_FAILED
);
217 $this->assertEventContextNotUsed($event);
219 // Login should work with invalid token if CFG login token settings override it.
220 $CFG->alternateloginurl
= 'http://localhost/';
221 $sink = $this->redirectEvents();
222 $result = authenticate_user_login('username1', 'password1', false, $reason, 'invalidtoken');
223 $events = $sink->get_events();
225 $this->assertEmpty($events);
226 $this->assertInstanceOf('stdClass', $result);
227 $this->assertEquals(AUTH_LOGIN_OK
, $reason);
229 unset($CFG->alternateloginurl
);
230 $CFG->disablelogintoken
= true;
232 $sink = $this->redirectEvents();
233 $result = authenticate_user_login('username1', 'password1', false, $reason, 'invalidtoken');
234 $events = $sink->get_events();
236 $this->assertEmpty($events);
237 $this->assertInstanceOf('stdClass', $result);
238 $this->assertEquals(AUTH_LOGIN_OK
, $reason);
240 unset($CFG->disablelogintoken
);
241 // Normal login with valid token.
243 $token = \core\session\manager
::get_login_token();
244 $sink = $this->redirectEvents();
245 $result = authenticate_user_login('username1', 'password1', false, $reason, $token);
246 $events = $sink->get_events();
248 $this->assertEmpty($events);
249 $this->assertInstanceOf('stdClass', $result);
250 $this->assertEquals(AUTH_LOGIN_OK
, $reason);
253 // Capture failed login event.
254 $sink = $this->redirectEvents();
255 $result = authenticate_user_login('username2', 'password2', false, $reason);
256 $events = $sink->get_events();
258 $event = array_pop($events);
260 $this->assertFalse($result);
261 $this->assertEquals(AUTH_LOGIN_SUSPENDED
, $reason);
263 $this->assertInstanceOf('\core\event\user_login_failed', $event);
264 $expectedlogdata = array(SITEID
, 'login', 'error', 'index.php', 'username2');
265 $this->assertEventLegacyLogData($expectedlogdata, $event);
266 $eventdata = $event->get_data();
267 $this->assertSame($eventdata['other']['username'], 'username2');
268 $this->assertSame($eventdata['other']['reason'], AUTH_LOGIN_SUSPENDED
);
269 $this->assertEventContextNotUsed($event);
272 // Capture failed login event.
273 $sink = $this->redirectEvents();
274 $result = authenticate_user_login('username3', 'password3', false, $reason);
275 $events = $sink->get_events();
277 $event = array_pop($events);
279 $this->assertFalse($result);
280 $this->assertEquals(AUTH_LOGIN_SUSPENDED
, $reason);
282 $this->assertInstanceOf('\core\event\user_login_failed', $event);
283 $expectedlogdata = array(SITEID
, 'login', 'error', 'index.php', 'username3');
284 $this->assertEventLegacyLogData($expectedlogdata, $event);
285 $eventdata = $event->get_data();
286 $this->assertSame($eventdata['other']['username'], 'username3');
287 $this->assertSame($eventdata['other']['reason'], AUTH_LOGIN_SUSPENDED
);
288 $this->assertEventContextNotUsed($event);
291 // Capture failed login event.
292 $sink = $this->redirectEvents();
293 $result = authenticate_user_login('username4', 'password3', false, $reason);
294 $events = $sink->get_events();
296 $event = array_pop($events);
298 $this->assertFalse($result);
299 $this->assertEquals(AUTH_LOGIN_NOUSER
, $reason);
301 $this->assertInstanceOf('\core\event\user_login_failed', $event);
302 $expectedlogdata = array(SITEID
, 'login', 'error', 'index.php', 'username4');
303 $this->assertEventLegacyLogData($expectedlogdata, $event);
304 $eventdata = $event->get_data();
305 $this->assertSame($eventdata['other']['username'], 'username4');
306 $this->assertSame($eventdata['other']['reason'], AUTH_LOGIN_NOUSER
);
307 $this->assertEventContextNotUsed($event);
309 set_config('lockoutthreshold', 3);
312 $result = authenticate_user_login('username1', 'nopass', false, $reason);
313 $this->assertFalse($result);
314 $this->assertEquals(AUTH_LOGIN_FAILED
, $reason);
315 $result = authenticate_user_login('username1', 'nopass', false, $reason);
316 $this->assertFalse($result);
317 $this->assertEquals(AUTH_LOGIN_FAILED
, $reason);
318 $sink = $this->redirectEmails();
319 $result = authenticate_user_login('username1', 'nopass', false, $reason);
320 $this->assertCount(1, $sink->get_messages());
322 $this->assertFalse($result);
323 $this->assertEquals(AUTH_LOGIN_FAILED
, $reason);
325 $result = authenticate_user_login('username1', 'password1', false, $reason);
326 $this->assertFalse($result);
327 $this->assertEquals(AUTH_LOGIN_LOCKOUT
, $reason);
329 $result = authenticate_user_login('username1', 'password1', true, $reason);
330 $this->assertInstanceOf('stdClass', $result);
331 $this->assertEquals(AUTH_LOGIN_OK
, $reason);
333 ini_set('error_log', $oldlog);
335 // Test password policy check on login.
336 $CFG->passwordpolicy
= 0;
337 $CFG->passwordpolicycheckonlogin
= 1;
339 // First test with password policy disabled.
340 $user4 = $this->getDataGenerator()->create_user(array('username' => 'username4', 'password' => 'a'));
341 $sink = $this->redirectEvents();
343 $result = authenticate_user_login('username4', 'a', false, $reason);
344 $events = $sink->get_events();
346 $notifications = notification
::fetch();
347 $this->assertInstanceOf('stdClass', $result);
348 $this->assertEquals(AUTH_LOGIN_OK
, $reason);
349 $this->assertEquals(get_user_preferences('auth_forcepasswordchange', false, $result), false);
351 $this->assertEquals(count($events), 0);
352 // Check no notifications.
353 $this->assertEquals(count($notifications), 0);
355 // Now test with the password policy enabled, flip reset flag.
356 $sink = $this->redirectEvents();
358 $CFG->passwordpolicy
= 1;
359 $result = authenticate_user_login('username4', 'a', false, $reason);
360 $events = $sink->get_events();
362 $this->assertInstanceOf('stdClass', $result);
363 $this->assertEquals(AUTH_LOGIN_OK
, $reason);
364 $this->assertEquals(get_user_preferences('auth_forcepasswordchange', true, $result), true);
365 // Check that an event was emitted for the policy failure.
366 $this->assertEquals(count($events), 1);
367 $this->assertEquals(reset($events)->eventname
, '\core\event\user_password_policy_failed');
368 // Check notification fired.
369 $notifications = notification
::fetch();
370 $this->assertEquals(count($notifications), 1);
372 // Now the same tests with a user that passes the password policy.
373 $user5 = $this->getDataGenerator()->create_user(array('username' => 'username5', 'password' => 'ThisPassword1sSecure!'));
375 $CFG->passwordpolicy
= 0;
376 $sink = $this->redirectEvents();
377 $result = authenticate_user_login('username5', 'ThisPassword1sSecure!', false, $reason);
378 $events = $sink->get_events();
380 $notifications = notification
::fetch();
381 $this->assertInstanceOf('stdClass', $result);
382 $this->assertEquals(AUTH_LOGIN_OK
, $reason);
383 $this->assertEquals(get_user_preferences('auth_forcepasswordchange', false, $result), false);
385 $this->assertEquals(count($events), 0);
386 // Check no notifications.
387 $this->assertEquals(count($notifications), 0);
390 $CFG->passwordpolicy
= 1;
391 $sink = $this->redirectEvents();
392 $result = authenticate_user_login('username5', 'ThisPassword1sSecure!', false, $reason);
393 $events = $sink->get_events();
395 $notifications = notification
::fetch();
396 $this->assertInstanceOf('stdClass', $result);
397 $this->assertEquals(AUTH_LOGIN_OK
, $reason);
398 $this->assertEquals(get_user_preferences('auth_forcepasswordchange', false, $result), false);
400 $this->assertEquals(count($events), 0);
401 // Check no notifications.
402 $this->assertEquals(count($notifications), 0);
405 public function test_user_loggedin_event_exceptions() {
407 $event = \core\event\user_loggedin
::create(array('objectid' => 1));
408 $this->fail('\core\event\user_loggedin requires other[\'username\']');
409 } catch(\Exception
$e) {
410 $this->assertInstanceOf('coding_exception', $e);
415 * Test the {@link signup_validate_data()} duplicate email validation.
417 public function test_signup_validate_data_same_email() {
419 require_once($CFG->libdir
. '/authlib.php');
420 require_once($CFG->libdir
. '/phpmailer/moodle_phpmailer.php');
421 require_once($CFG->dirroot
. '/user/profile/lib.php');
423 $this->resetAfterTest();
425 $CFG->registerauth
= 'email';
426 $CFG->passwordpolicy
= false;
428 // In this test, we want to check accent-sensitive email search. However, accented email addresses do not pass
429 // the default `validate_email()` and Moodle does not yet provide a CFG switch to allow such emails. So we
430 // inject our own validation method here and revert it back once we are done. This custom validator method is
431 // identical to the default 'php' validator with the only difference: it has the FILTER_FLAG_EMAIL_UNICODE set
432 // so that it allows to use non-ASCII characters in email addresses.
433 $defaultvalidator = \moodle_phpmailer
::$validator;
434 \moodle_phpmailer
::$validator = function($address) {
435 return (bool) filter_var($address, FILTER_VALIDATE_EMAIL
, FILTER_FLAG_EMAIL_UNICODE
);
438 // Check that two users cannot share the same email address if the site is configured so.
439 // Emails in Moodle are supposed to be case-insensitive (and accent-sensitive but accents are not yet supported).
440 $CFG->allowaccountssameemail
= false;
442 $u1 = $this->getDataGenerator()->create_user([
443 'username' => 'abcdef',
444 'email' => 'abcdef@example.com',
448 'username' => 'newuser',
449 'firstname' => 'First',
450 'lastname' => 'Last',
451 'password' => 'weak',
452 'email' => 'ABCDEF@example.com',
455 $errors = signup_validate_data($formdata, []);
456 $this->assertStringContainsString('This email address is already registered.', $errors['email']);
458 // Emails are accent-sensitive though so if we change a -> á in the u1's email, it should pass.
459 // Please note that Moodle does not normally support such emails yet. We test the DB search sensitivity here.
460 $formdata['email'] = 'ábcdef@example.com';
461 $errors = signup_validate_data($formdata, []);
462 $this->assertArrayNotHasKey('email', $errors);
464 // Check that users can share the same email if the site is configured so.
465 $CFG->allowaccountssameemail
= true;
466 $formdata['email'] = 'abcdef@example.com';
467 $errors = signup_validate_data($formdata, []);
468 $this->assertArrayNotHasKey('email', $errors);
470 // Restore the original email address validator.
471 \moodle_phpmailer
::$validator = $defaultvalidator;