MDL-65918 auth_oauth2: Require profile library once
[moodle.git] / auth / oauth2 / classes / auth.php
CommitLineData
60237253
DW
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 * Anobody can login with any password.
19 *
20 * @package auth_oauth2
21 * @copyright 2017 Damyon Wiese
22 * @license http://www.gnu.org/copyleft/gpl.html GNU Public License
23 */
24
25namespace auth_oauth2;
26
27defined('MOODLE_INTERNAL') || die();
28
29use pix_icon;
30use moodle_url;
31use core_text;
28b592d5 32use context_system;
60237253
DW
33use stdClass;
34use core\oauth2\issuer;
35use core\oauth2\client;
36
37require_once($CFG->libdir.'/authlib.php');
c65dadbc 38require_once($CFG->dirroot.'/user/lib.php');
09267294 39require_once($CFG->dirroot.'/user/profile/lib.php');
60237253
DW
40
41/**
42 * Plugin for oauth2 authentication.
43 *
44 * @package auth_oauth2
45 * @copyright 2017 Damyon Wiese
46 * @license http://www.gnu.org/copyleft/gpl.html GNU Public License
47 */
48class auth extends \auth_plugin_base {
49
50 /**
51 * @var stdClass $userinfo The set of user info returned from the oauth handshake
52 */
53 private static $userinfo;
54
55 /**
56 * @var stdClass $userpicture The url to a picture.
57 */
58 private static $userpicture;
59
60 /**
61 * Constructor.
62 */
63 public function __construct() {
64 $this->authtype = 'oauth2';
65 $this->config = get_config('auth_oauth2');
66 }
67
68 /**
69 * Returns true if the username and password work or don't exist and false
70 * if the user exists and the password is wrong.
71 *
72 * @param string $username The username
73 * @param string $password The password
74 * @return bool Authentication success or failure.
75 */
76 public function user_login($username, $password) {
77 $cached = $this->get_static_user_info();
bfc60d38
DW
78 if (empty($cached)) {
79 // This means we were called as part of a normal login flow - without using oauth.
80 return false;
81 }
60237253
DW
82 $verifyusername = $cached['username'];
83 if ($verifyusername == $username) {
84 return true;
85 }
979d1f66 86 return false;
60237253
DW
87 }
88
89 /**
90 * We don't want to allow users setting an internal password.
91 *
92 * @return bool
93 */
94 public function prevent_local_passwords() {
95 return true;
96 }
97
98 /**
99 * Returns true if this authentication plugin is 'internal'.
100 *
101 * @return bool
102 */
103 public function is_internal() {
104 return false;
105 }
106
107 /**
108 * Indicates if moodle should automatically update internal user
109 * records with data from external sources using the information
110 * from auth_plugin_base::get_userinfo().
111 *
112 * @return bool true means automatically copy data from ext to user table
113 */
114 public function is_synchronised_with_external() {
c65dadbc 115 return true;
60237253
DW
116 }
117
118 /**
119 * Returns true if this authentication plugin can change the user's
120 * password.
121 *
122 * @return bool
123 */
124 public function can_change_password() {
125 return false;
126 }
127
128 /**
129 * Returns the URL for changing the user's pw, or empty if the default can
130 * be used.
131 *
132 * @return moodle_url
133 */
134 public function change_password_url() {
135 return null;
136 }
137
138 /**
139 * Returns true if plugin allows resetting of internal password.
140 *
141 * @return bool
142 */
143 public function can_reset_password() {
144 return false;
145 }
146
147 /**
148 * Returns true if plugin can be manually set.
149 *
150 * @return bool
151 */
152 public function can_be_manually_set() {
153 return true;
154 }
155
60237253
DW
156 /**
157 * Return the userinfo from the oauth handshake. Will only be valid
158 * for the logged in user.
72fd103a 159 * @param string $username
60237253
DW
160 */
161 public function get_userinfo($username) {
162 $cached = $this->get_static_user_info();
163 if (!empty($cached) && $cached['username'] == $username) {
164 return $cached;
165 }
166 return false;
167 }
168
f9f243f9
DW
169 /**
170 * Do some checks on the identity provider before showing it on the login page.
29911249 171 * @param core\oauth2\issuer $issuer
f9f243f9
DW
172 * @return boolean
173 */
174 private function is_ready_for_login_page(\core\oauth2\issuer $issuer) {
eca128bf 175 return $issuer->get('enabled') &&
5b0b35c0 176 $issuer->is_configured() &&
60237253
DW
177 !empty($issuer->get('showonloginpage'));
178 }
179
180 /**
181 * Return a list of identity providers to display on the login page.
f9f243f9
DW
182 *
183 * @param string|moodle_url $wantsurl The requested URL.
1cb5c7b3 184 * @return array List of arrays with keys url, iconurl and name.
60237253
DW
185 */
186 public function loginpage_idp_list($wantsurl) {
187 $providers = \core\oauth2\api::get_all_issuers();
188 $result = [];
189 if (empty($wantsurl)) {
190 $wantsurl = '/';
191 }
192 foreach ($providers as $idp) {
193 if ($this->is_ready_for_login_page($idp)) {
194 $params = ['id' => $idp->get('id'), 'wantsurl' => $wantsurl, 'sesskey' => sesskey()];
195 $url = new moodle_url('/auth/oauth2/login.php', $params);
196 $icon = $idp->get('image');
197 $result[] = ['url' => $url, 'iconurl' => $icon, 'name' => $idp->get('name')];
198 }
199 }
200 return $result;
201 }
202
203 /**
204 * Statically cache the user info from the oauth handshake
205 * @param stdClass $userinfo
206 */
207 private function set_static_user_info($userinfo) {
208 self::$userinfo = $userinfo;
209 }
210
211 /**
212 * Get the static cached user info
213 * @return stdClass
214 */
215 private function get_static_user_info() {
216 return self::$userinfo;
217 }
218
219 /**
220 * Statically cache the user picture from the oauth handshake
221 * @param string $userpicture
222 */
223 private function set_static_user_picture($userpicture) {
224 self::$userpicture = $userpicture;
225 }
226
227 /**
228 * Get the static cached user picture
229 * @return string
230 */
231 private function get_static_user_picture() {
232 return self::$userpicture;
233 }
234
235 /**
236 * If this user has no picture - but we got one from oauth - set it.
29911249 237 * @param stdClass $user
60237253
DW
238 * @return boolean True if the image was updated.
239 */
240 private function update_picture($user) {
241 global $CFG, $DB, $USER;
242
243 require_once($CFG->libdir . '/filelib.php');
244 require_once($CFG->libdir . '/gdlib.php');
427fa27c 245 require_once($CFG->dirroot . '/user/lib.php');
60237253
DW
246
247 $fs = get_file_storage();
248 $userid = $user->id;
249 if (!empty($user->picture)) {
250 return false;
251 }
95e13556
DW
252 if (!empty($CFG->enablegravatar)) {
253 return false;
254 }
255
60237253
DW
256 $picture = $this->get_static_user_picture();
257 if (empty($picture)) {
258 return false;
259 }
260
261 $context = \context_user::instance($userid, MUST_EXIST);
262 $fs->delete_area_files($context->id, 'user', 'newicon');
263
264 $filerecord = array(
265 'contextid' => $context->id,
266 'component' => 'user',
267 'filearea' => 'newicon',
268 'itemid' => 0,
269 'filepath' => '/',
270 'filename' => 'image'
271 );
272
273 try {
274 $fs->create_file_from_string($filerecord, $picture);
275 } catch (\file_exception $e) {
276 return get_string($e->errorcode, $e->module, $e->a);
277 }
278
279 $iconfile = $fs->get_area_files($context->id, 'user', 'newicon', false, 'itemid', false);
280
281 // There should only be one.
282 $iconfile = reset($iconfile);
283
284 // Something went wrong while creating temp file - remove the uploaded file.
285 if (!$iconfile = $iconfile->copy_content_to_temp()) {
286 $fs->delete_area_files($context->id, 'user', 'newicon');
287 return false;
288 }
289
290 // Copy file to temporary location and the send it for processing icon.
291 $newpicture = (int) process_new_icon($context, 'user', 'icon', 0, $iconfile);
292 // Delete temporary file.
293 @unlink($iconfile);
294 // Remove uploaded file.
295 $fs->delete_area_files($context->id, 'user', 'newicon');
296 // Set the user's picture.
297 $updateuser = new stdClass();
298 $updateuser->id = $userid;
299 $updateuser->picture = $newpicture;
300 $USER->picture = $newpicture;
301 user_update_user($updateuser);
302 return true;
303 }
304
c65dadbc 305 /**
c9f2a8c2
AN
306 * Update user data according to data sent by authorization server.
307 *
c65dadbc 308 * @param array $externaldata data from authorization server
e2b812fc
JP
309 * @param stdClass $userdata Current data of the user to be updated
310 * @return stdClass The updated user record, or the existing one if there's nothing to be updated.
c65dadbc 311 */
e2b812fc 312 private function update_user(array $externaldata, $userdata) {
c9f2a8c2 313 $user = (object) [
e2b812fc 314 'id' => $userdata->id,
c9f2a8c2 315 ];
e2b812fc
JP
316
317 // We can only update if the default authentication type of the user is set to OAuth2 as well. Otherwise, we might mess
318 // up the user data of other users that use different authentication mechanisms (e.g. linked logins).
319 if ($userdata->auth !== $this->authtype) {
320 return $userdata;
321 }
322
323 // Go through each field from the external data.
c65dadbc 324 foreach ($externaldata as $fieldname => $value) {
e2b812fc
JP
325 if (!in_array($fieldname, $this->userfields)) {
326 // Skip if this field doesn't belong to the list of fields that can be synced with the OAuth2 issuer.
327 continue;
328 }
329
330 if (!property_exists($userdata, $fieldname)) {
331 // Just in case this field is on the list, but not part of the user data. This shouldn't happen though.
332 continue;
333 }
334
335 // Get the old value.
336 $oldvalue = (string)$userdata->$fieldname;
337
338 // Get the lock configuration of the field.
339 $lockvalue = $this->config->{'field_lock_' . $fieldname};
340
341 // We should update fields that meet the following criteria:
342 // - Lock value set to 'unlocked'; or 'unlockedifempty', given the current value is empty.
343 // - The value has changed.
344 if ($lockvalue === 'unlocked' || ($lockvalue === 'unlockedifempty' && empty($oldvalue))) {
345 $value = (string)$value;
346 if ($oldvalue !== $value) {
347 $user->$fieldname = $value;
348 }
349 }
c65dadbc 350 }
e2b812fc 351 // Update the user data.
c9f2a8c2
AN
352 user_update_user($user, false);
353
e2b812fc
JP
354 // Save user profile data.
355 profile_save_data($user);
356
c65dadbc 357 // Refresh user for $USER variable.
c9f2a8c2 358 return get_complete_user_data('id', $user->id);
c65dadbc
MM
359 }
360
72fd103a 361 /**
28b592d5
DW
362 * Confirm the new user as registered.
363 *
364 * @param string $username
365 * @param string $confirmsecret
72fd103a 366 */
13b449f4 367 public function user_confirm($username, $confirmsecret) {
28b592d5
DW
368 global $DB;
369 $user = get_complete_user_data('username', $username);
370
371 if (!empty($user)) {
372 if ($user->auth != $this->authtype) {
373 return AUTH_CONFIRM_ERROR;
374
375 } else if ($user->secret == $confirmsecret && $user->confirmed) {
376 return AUTH_CONFIRM_ALREADY;
377
13b449f4
DW
378 } else if ($user->secret == $confirmsecret) { // They have provided the secret key to get in.
379 $DB->set_field("user", "confirmed", 1, array("id" => $user->id));
28b592d5
DW
380 return AUTH_CONFIRM_OK;
381 }
382 } else {
383 return AUTH_CONFIRM_ERROR;
1dca8d1a 384 }
28b592d5
DW
385 }
386
387 /**
388 * Print a page showing that a confirm email was sent with instructions.
389 *
13b449f4
DW
390 * @param string $title
391 * @param string $message
28b592d5
DW
392 */
393 public function print_confirm_required($title, $message) {
394 global $PAGE, $OUTPUT, $CFG;
395
396 $PAGE->navbar->add($title);
397 $PAGE->set_title($title);
398 $PAGE->set_heading($PAGE->course->fullname);
399 echo $OUTPUT->header();
672f4836 400 notice($message, "$CFG->wwwroot/index.php");
1dca8d1a
DW
401 }
402
60237253
DW
403 /**
404 * Complete the login process after oauth handshake is complete.
405 * @param \core\oauth2\client $client
406 * @param string $redirecturl
92c8cb9d 407 * @return void Either redirects or throws an exception
60237253
DW
408 */
409 public function complete_login(client $client, $redirecturl) {
28b592d5 410 global $CFG, $SESSION, $PAGE;
60237253
DW
411
412 $userinfo = $client->get_userinfo();
413
414 if (!$userinfo) {
6cee96c8
DW
415 // Trigger login failed event.
416 $failurereason = AUTH_LOGIN_NOUSER;
92c8cb9d 417 $event = \core\event\user_login_failed::create(['other' => ['username' => 'unknown',
6cee96c8
DW
418 'reason' => $failurereason]]);
419 $event->trigger();
420
7f158660 421 $errormsg = get_string('loginerror_nouserinfo', 'auth_oauth2');
60237253 422 $SESSION->loginerrormsg = $errormsg;
427fa27c 423 $client->log_out();
7eb50b32 424 redirect(new moodle_url('/login/index.php'));
8445556b 425 }
28b592d5 426 if (empty($userinfo['username']) || empty($userinfo['email'])) {
6cee96c8
DW
427 // Trigger login failed event.
428 $failurereason = AUTH_LOGIN_NOUSER;
92c8cb9d 429 $event = \core\event\user_login_failed::create(['other' => ['username' => 'unknown',
6cee96c8
DW
430 'reason' => $failurereason]]);
431 $event->trigger();
432
7f158660 433 $errormsg = get_string('loginerror_userincomplete', 'auth_oauth2');
8445556b 434 $SESSION->loginerrormsg = $errormsg;
427fa27c 435 $client->log_out();
7eb50b32 436 redirect(new moodle_url('/login/index.php'));
60237253
DW
437 }
438
439 $userinfo['username'] = trim(core_text::strtolower($userinfo['username']));
427fa27c 440 $oauthemail = $userinfo['email'];
60237253 441
28b592d5 442 // Once we get here we have the user info from oauth.
1dca8d1a 443 $userwasmapped = false;
1dca8d1a 444
28b592d5
DW
445 // Clean and remember the picture / lang.
446 if (!empty($userinfo['picture'])) {
447 $this->set_static_user_picture($userinfo['picture']);
448 unset($userinfo['picture']);
449 }
450
451 if (!empty($userinfo['lang'])) {
452 $userinfo['lang'] = str_replace('-', '_', trim(core_text::strtolower($userinfo['lang'])));
453 if (!get_string_manager()->translation_exists($userinfo['lang'], false)) {
454 unset($userinfo['lang']);
455 }
456 }
457
458 // First we try and find a defined mapping.
459 $linkedlogin = api::match_username_to_user($userinfo['username'], $client->get_issuer());
460
461 if (!empty($linkedlogin) && empty($linkedlogin->get('confirmtoken'))) {
462 $mappeduser = get_complete_user_data('id', $linkedlogin->get('userid'));
463
6cc9dce2
DM
464 if ($mappeduser && $mappeduser->suspended) {
465 $failurereason = AUTH_LOGIN_SUSPENDED;
466 $event = \core\event\user_login_failed::create([
467 'userid' => $mappeduser->id,
468 'other' => [
469 'username' => $userinfo['username'],
470 'reason' => $failurereason
471 ]
472 ]);
473 $event->trigger();
474 $SESSION->loginerrormsg = get_string('invalidlogin');
475 $client->log_out();
476 redirect(new moodle_url('/login/index.php'));
477 } else if ($mappeduser && $mappeduser->confirmed) {
c65dadbc 478 // Update user fields.
e2b812fc 479 $userinfo = $this->update_user($userinfo, $mappeduser);
1dca8d1a 480 $userwasmapped = true;
28b592d5 481 } else {
6cee96c8
DW
482 // Trigger login failed event.
483 $failurereason = AUTH_LOGIN_UNAUTHORISED;
484 $event = \core\event\user_login_failed::create(['other' => ['username' => $userinfo['username'],
485 'reason' => $failurereason]]);
486 $event->trigger();
487
28b592d5
DW
488 $errormsg = get_string('confirmationpending', 'auth_oauth2');
489 $SESSION->loginerrormsg = $errormsg;
427fa27c 490 $client->log_out();
7eb50b32 491 redirect(new moodle_url('/login/index.php'));
1dca8d1a 492 }
28b592d5 493 } else if (!empty($linkedlogin)) {
6cee96c8
DW
494 // Trigger login failed event.
495 $failurereason = AUTH_LOGIN_UNAUTHORISED;
496 $event = \core\event\user_login_failed::create(['other' => ['username' => $userinfo['username'],
497 'reason' => $failurereason]]);
498 $event->trigger();
499
28b592d5
DW
500 $errormsg = get_string('confirmationpending', 'auth_oauth2');
501 $SESSION->loginerrormsg = $errormsg;
427fa27c 502 $client->log_out();
7eb50b32 503 redirect(new moodle_url('/login/index.php'));
28b592d5 504 }
427fa27c 505
28b592d5 506 $issuer = $client->get_issuer();
427fa27c 507 if (!$issuer->is_valid_login_domain($oauthemail)) {
6cee96c8
DW
508 // Trigger login failed event.
509 $failurereason = AUTH_LOGIN_UNAUTHORISED;
510 $event = \core\event\user_login_failed::create(['other' => ['username' => $userinfo['username'],
511 'reason' => $failurereason]]);
512 $event->trigger();
513
7f158660 514 $errormsg = get_string('notloggedindebug', 'auth_oauth2', get_string('loginerror_invaliddomain', 'auth_oauth2'));
28b592d5 515 $SESSION->loginerrormsg = $errormsg;
427fa27c 516 $client->log_out();
7eb50b32 517 redirect(new moodle_url('/login/index.php'));
60237253
DW
518 }
519
1dca8d1a 520 if (!$userwasmapped) {
28b592d5
DW
521 // No defined mapping - we need to see if there is an existing account with the same email.
522
523 $moodleuser = \core_user::get_user_by_email($userinfo['email']);
524 if (!empty($moodleuser)) {
859e2033
DW
525 if ($issuer->get('requireconfirmation')) {
526 $PAGE->set_url('/auth/oauth2/confirm-link-login.php');
527 $PAGE->set_context(context_system::instance());
528
529 \auth_oauth2\api::send_confirm_link_login_email($userinfo, $issuer, $moodleuser->id);
530 // Request to link to existing account.
531 $emailconfirm = get_string('emailconfirmlink', 'auth_oauth2');
532 $message = get_string('emailconfirmlinksent', 'auth_oauth2', $moodleuser->email);
533 $this->print_confirm_required($emailconfirm, $message);
534 exit();
535 } else {
536 \auth_oauth2\api::link_login($userinfo, $issuer, $moodleuser->id, true);
e2b812fc 537 $userinfo = $this->update_user($userinfo, $moodleuser);
859e2033
DW
538 // No redirect, we will complete this login.
539 }
28b592d5
DW
540
541 } else {
542 // This is a new account.
543 $exists = \core_user::get_user_by_username($userinfo['username']);
544 // Creating a new user?
545 if ($exists) {
6cee96c8
DW
546 // Trigger login failed event.
547 $failurereason = AUTH_LOGIN_FAILED;
548 $event = \core\event\user_login_failed::create(['other' => ['username' => $userinfo['username'],
549 'reason' => $failurereason]]);
550 $event->trigger();
28b592d5
DW
551
552 // The username exists but the emails don't match. Refuse to continue.
553 $errormsg = get_string('accountexists', 'auth_oauth2');
554 $SESSION->loginerrormsg = $errormsg;
427fa27c 555 $client->log_out();
7eb50b32 556 redirect(new moodle_url('/login/index.php'));
28b592d5 557 }
1dca8d1a 558
28b592d5 559 if (email_is_not_allowed($userinfo['email'])) {
6cee96c8
DW
560 // Trigger login failed event.
561 $failurereason = AUTH_LOGIN_FAILED;
562 $event = \core\event\user_login_failed::create(['other' => ['username' => $userinfo['username'],
563 'reason' => $failurereason]]);
564 $event->trigger();
28b592d5 565 // The username exists but the emails don't match. Refuse to continue.
7f158660
DW
566 $reason = get_string('loginerror_invaliddomain', 'auth_oauth2');
567 $errormsg = get_string('notloggedindebug', 'auth_oauth2', $reason);
28b592d5 568 $SESSION->loginerrormsg = $errormsg;
427fa27c 569 $client->log_out();
7eb50b32 570 redirect(new moodle_url('/login/index.php'));
1dca8d1a 571 }
1dca8d1a 572
6cee96c8
DW
573 if (!empty($CFG->authpreventaccountcreation)) {
574 // Trigger login failed event.
575 $failurereason = AUTH_LOGIN_UNAUTHORISED;
576 $event = \core\event\user_login_failed::create(['other' => ['username' => $userinfo['username'],
577 'reason' => $failurereason]]);
578 $event->trigger();
579 // The username does not exist and settings prevent creating new accounts.
580 $reason = get_string('loginerror_cannotcreateaccounts', 'auth_oauth2');
581 $errormsg = get_string('notloggedindebug', 'auth_oauth2', $reason);
582 $SESSION->loginerrormsg = $errormsg;
427fa27c 583 $client->log_out();
7eb50b32 584 redirect(new moodle_url('/login/index.php'));
6cee96c8
DW
585 }
586
859e2033
DW
587 if ($issuer->get('requireconfirmation')) {
588 $PAGE->set_url('/auth/oauth2/confirm-account.php');
589 $PAGE->set_context(context_system::instance());
28b592d5 590
859e2033
DW
591 // Create a new (unconfirmed account) and send an email to confirm it.
592 $user = \auth_oauth2\api::send_confirm_account_email($userinfo, $issuer);
60237253 593
859e2033
DW
594 $this->update_picture($user);
595 $emailconfirm = get_string('emailconfirm');
596 $message = get_string('emailconfirmsent', '', $userinfo['email']);
597 $this->print_confirm_required($emailconfirm, $message);
598 exit();
599 } else {
600 // Create a new confirmed account.
601 $newuser = \auth_oauth2\api::create_new_confirmed_account($userinfo, $issuer);
e2b812fc 602 $userinfo = get_complete_user_data('id', $newuser->id);
859e2033
DW
603 // No redirect, we will complete this login.
604 }
28b592d5 605 }
c21a66e4 606 }
60237253 607
f3315133
DW
608 // We used to call authenticate_user - but that won't work if the current user has a different default authentication
609 // method. Since we now ALWAYS link a login - if we get to here we can directly allow the user in.
610 $user = (object) $userinfo;
611 complete_user_login($user);
612 $this->update_picture($user);
613 redirect($redirecturl);
60237253 614 }
18641610
SR
615
616 /**
617 * Returns information on how the specified user can change their password.
618 * The password of the oauth2 accounts is not stored in Moodle.
619 *
620 * @param stdClass $user A user object
621 * @return string[] An array of strings with keys subject and message
622 */
623 public function get_password_change_info(stdClass $user) : array {
624 $site = get_site();
625
626 $data = new stdClass();
627 $data->firstname = $user->firstname;
628 $data->lastname = $user->lastname;
629 $data->username = $user->username;
630 $data->sitename = format_string($site->fullname);
631 $data->admin = generate_email_signoff();
632
633 $message = get_string('emailpasswordchangeinfo', 'auth_oauth2', $data);
634 $subject = get_string('emailpasswordchangeinfosubject', 'auth_oauth2', format_string($site->fullname));
635
636 return [
637 'subject' => $subject,
638 'message' => $message
639 ];
640 }
60237253 641}