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
26defined('MOODLE_INTERNAL') || die();
27
28
29/**
30 * Functional test for authentication related APIs.
31 */
32class 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->assertStringContainsString('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}
478