Merge branch 'MDL-60494-stray_img_end-39' of https://github.com/leonstr/moodle into...
[moodle.git] / auth / oauth2 / classes / auth.php
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/>.
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  */
25 namespace auth_oauth2;
27 defined('MOODLE_INTERNAL') || die();
29 use pix_icon;
30 use moodle_url;
31 use core_text;
32 use context_system;
33 use stdClass;
34 use core\oauth2\issuer;
35 use core\oauth2\client;
37 require_once($CFG->libdir.'/authlib.php');
38 require_once($CFG->dirroot.'/user/lib.php');
39 require_once($CFG->dirroot.'/user/profile/lib.php');
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  */
48 class auth extends \auth_plugin_base {
50     /**
51      * @var stdClass $userinfo The set of user info returned from the oauth handshake
52      */
53     private static $userinfo;
55     /**
56      * @var stdClass $userpicture The url to a picture.
57      */
58     private static $userpicture;
60     /**
61      * Constructor.
62      */
63     public function __construct() {
64         $this->authtype = 'oauth2';
65         $this->config = get_config('auth_oauth2');
66     }
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();
78         if (empty($cached)) {
79             // This means we were called as part of a normal login flow - without using oauth.
80             return false;
81         }
82         $verifyusername = $cached['username'];
83         if ($verifyusername == $username) {
84             return true;
85         }
86         return false;
87     }
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     }
98     /**
99      * Returns true if this authentication plugin is 'internal'.
100      *
101      * @return bool
102      */
103     public function is_internal() {
104         return false;
105     }
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() {
115         return true;
116     }
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     }
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     }
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     }
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     }
156     /**
157      * Return the userinfo from the oauth handshake. Will only be valid
158      * for the logged in user.
159      * @param string $username
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     }
169     /**
170      * Do some checks on the identity provider before showing it on the login page.
171      * @param core\oauth2\issuer $issuer
172      * @return boolean
173      */
174     private function is_ready_for_login_page(\core\oauth2\issuer $issuer) {
175         return $issuer->get('enabled') &&
176                 $issuer->is_configured() &&
177                 !empty($issuer->get('showonloginpage'));
178     }
180     /**
181      * Return a list of identity providers to display on the login page.
182      *
183      * @param string|moodle_url $wantsurl The requested URL.
184      * @return array List of arrays with keys url, iconurl and name.
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     }
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     }
211     /**
212      * Get the static cached user info
213      * @return stdClass
214      */
215     private function get_static_user_info() {
216         return self::$userinfo;
217     }
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     }
227     /**
228      * Get the static cached user picture
229      * @return string
230      */
231     private function get_static_user_picture() {
232         return self::$userpicture;
233     }
235     /**
236      * If this user has no picture - but we got one from oauth - set it.
237      * @param stdClass $user
238      * @return boolean True if the image was updated.
239      */
240     private function update_picture($user) {
241         global $CFG, $DB, $USER;
243         require_once($CFG->libdir . '/filelib.php');
244         require_once($CFG->libdir . '/gdlib.php');
245         require_once($CFG->dirroot . '/user/lib.php');
247         $fs = get_file_storage();
248         $userid = $user->id;
249         if (!empty($user->picture)) {
250             return false;
251         }
252         if (!empty($CFG->enablegravatar)) {
253             return false;
254         }
256         $picture = $this->get_static_user_picture();
257         if (empty($picture)) {
258             return false;
259         }
261         $context = \context_user::instance($userid, MUST_EXIST);
262         $fs->delete_area_files($context->id, 'user', 'newicon');
264         $filerecord = array(
265             'contextid' => $context->id,
266             'component' => 'user',
267             'filearea' => 'newicon',
268             'itemid' => 0,
269             'filepath' => '/',
270             'filename' => 'image'
271         );
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         }
279         $iconfile = $fs->get_area_files($context->id, 'user', 'newicon', false, 'itemid', false);
281         // There should only be one.
282         $iconfile = reset($iconfile);
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         }
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     }
305     /**
306      * Update user data according to data sent by authorization server.
307      *
308      * @param array $externaldata data from authorization server
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.
311      */
312     private function update_user(array $externaldata, $userdata) {
313         $user = (object) [
314             'id' => $userdata->id,
315         ];
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         }
323         // Go through each field from the external data.
324         foreach ($externaldata as $fieldname => $value) {
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             }
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             }
335             // Get the old value.
336             $oldvalue = (string)$userdata->$fieldname;
338             // Get the lock configuration of the field.
339             $lockvalue = $this->config->{'field_lock_' . $fieldname};
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             }
350         }
351         // Update the user data.
352         user_update_user($user, false);
354         // Save user profile data.
355         profile_save_data($user);
357         // Refresh user for $USER variable.
358         return get_complete_user_data('id', $user->id);
359     }
361     /**
362      * Confirm the new user as registered.
363      *
364      * @param string $username
365      * @param string $confirmsecret
366      */
367     public function user_confirm($username, $confirmsecret) {
368         global $DB;
369         $user = get_complete_user_data('username', $username);
371         if (!empty($user)) {
372             if ($user->auth != $this->authtype) {
373                 return AUTH_CONFIRM_ERROR;
375             } else if ($user->secret == $confirmsecret && $user->confirmed) {
376                 return AUTH_CONFIRM_ALREADY;
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));
380                 return AUTH_CONFIRM_OK;
381             }
382         } else {
383             return AUTH_CONFIRM_ERROR;
384         }
385     }
387     /**
388      * Print a page showing that a confirm email was sent with instructions.
389      *
390      * @param string $title
391      * @param string $message
392      */
393     public function print_confirm_required($title, $message) {
394         global $PAGE, $OUTPUT, $CFG;
396         $PAGE->navbar->add($title);
397         $PAGE->set_title($title);
398         $PAGE->set_heading($PAGE->course->fullname);
399         echo $OUTPUT->header();
400         notice($message, "$CFG->wwwroot/index.php");
401     }
403     /**
404      * Complete the login process after oauth handshake is complete.
405      * @param \core\oauth2\client $client
406      * @param string $redirecturl
407      * @return void Either redirects or throws an exception
408      */
409     public function complete_login(client $client, $redirecturl) {
410         global $CFG, $SESSION, $PAGE;
412         $userinfo = $client->get_userinfo();
414         if (!$userinfo) {
415             // Trigger login failed event.
416             $failurereason = AUTH_LOGIN_NOUSER;
417             $event = \core\event\user_login_failed::create(['other' => ['username' => 'unknown',
418                                                                         'reason' => $failurereason]]);
419             $event->trigger();
421             $errormsg = get_string('loginerror_nouserinfo', 'auth_oauth2');
422             $SESSION->loginerrormsg = $errormsg;
423             $client->log_out();
424             redirect(new moodle_url('/login/index.php'));
425         }
426         if (empty($userinfo['username']) || empty($userinfo['email'])) {
427             // Trigger login failed event.
428             $failurereason = AUTH_LOGIN_NOUSER;
429             $event = \core\event\user_login_failed::create(['other' => ['username' => 'unknown',
430                                                                         'reason' => $failurereason]]);
431             $event->trigger();
433             $errormsg = get_string('loginerror_userincomplete', 'auth_oauth2');
434             $SESSION->loginerrormsg = $errormsg;
435             $client->log_out();
436             redirect(new moodle_url('/login/index.php'));
437         }
439         $userinfo['username'] = trim(core_text::strtolower($userinfo['username']));
440         $oauthemail = $userinfo['email'];
442         // Once we get here we have the user info from oauth.
443         $userwasmapped = false;
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         }
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         }
458         $issuer = $client->get_issuer();
459         // First we try and find a defined mapping.
460         $linkedlogin = api::match_username_to_user($userinfo['username'], $issuer);
462         if (!empty($linkedlogin) && empty($linkedlogin->get('confirmtoken'))) {
463             $mappeduser = get_complete_user_data('id', $linkedlogin->get('userid'));
465             if ($mappeduser && $mappeduser->suspended) {
466                 $failurereason = AUTH_LOGIN_SUSPENDED;
467                 $event = \core\event\user_login_failed::create([
468                     'userid' => $mappeduser->id,
469                     'other' => [
470                         'username' => $userinfo['username'],
471                         'reason' => $failurereason
472                     ]
473                 ]);
474                 $event->trigger();
475                 $SESSION->loginerrormsg = get_string('invalidlogin');
476                 $client->log_out();
477                 redirect(new moodle_url('/login/index.php'));
478             } else if ($mappeduser && ($mappeduser->confirmed || !$issuer->get('requireconfirmation'))) {
479                 // Update user fields.
480                 $userinfo = $this->update_user($userinfo, $mappeduser);
481                 $userwasmapped = true;
482             } else {
483                 // Trigger login failed event.
484                 $failurereason = AUTH_LOGIN_UNAUTHORISED;
485                 $event = \core\event\user_login_failed::create(['other' => ['username' => $userinfo['username'],
486                                                                             'reason' => $failurereason]]);
487                 $event->trigger();
489                 $errormsg = get_string('confirmationpending', 'auth_oauth2');
490                 $SESSION->loginerrormsg = $errormsg;
491                 $client->log_out();
492                 redirect(new moodle_url('/login/index.php'));
493             }
494         } else if (!empty($linkedlogin)) {
495             // Trigger login failed event.
496             $failurereason = AUTH_LOGIN_UNAUTHORISED;
497             $event = \core\event\user_login_failed::create(['other' => ['username' => $userinfo['username'],
498                                                                         'reason' => $failurereason]]);
499             $event->trigger();
501             $errormsg = get_string('confirmationpending', 'auth_oauth2');
502             $SESSION->loginerrormsg = $errormsg;
503             $client->log_out();
504             redirect(new moodle_url('/login/index.php'));
505         }
508         if (!$issuer->is_valid_login_domain($oauthemail)) {
509             // Trigger login failed event.
510             $failurereason = AUTH_LOGIN_UNAUTHORISED;
511             $event = \core\event\user_login_failed::create(['other' => ['username' => $userinfo['username'],
512                                                                         'reason' => $failurereason]]);
513             $event->trigger();
515             $errormsg = get_string('notloggedindebug', 'auth_oauth2', get_string('loginerror_invaliddomain', 'auth_oauth2'));
516             $SESSION->loginerrormsg = $errormsg;
517             $client->log_out();
518             redirect(new moodle_url('/login/index.php'));
519         }
521         if (!$userwasmapped) {
522             // No defined mapping - we need to see if there is an existing account with the same email.
524             $moodleuser = \core_user::get_user_by_email($userinfo['email']);
525             if (!empty($moodleuser)) {
526                 if ($issuer->get('requireconfirmation')) {
527                     $PAGE->set_url('/auth/oauth2/confirm-link-login.php');
528                     $PAGE->set_context(context_system::instance());
530                     \auth_oauth2\api::send_confirm_link_login_email($userinfo, $issuer, $moodleuser->id);
531                     // Request to link to existing account.
532                     $emailconfirm = get_string('emailconfirmlink', 'auth_oauth2');
533                     $message = get_string('emailconfirmlinksent', 'auth_oauth2', $moodleuser->email);
534                     $this->print_confirm_required($emailconfirm, $message);
535                     exit();
536                 } else {
537                     \auth_oauth2\api::link_login($userinfo, $issuer, $moodleuser->id, true);
538                     $userinfo = $this->update_user($userinfo, $moodleuser);
539                     // No redirect, we will complete this login.
540                 }
542             } else {
543                 // This is a new account.
544                 $exists = \core_user::get_user_by_username($userinfo['username']);
545                 // Creating a new user?
546                 if ($exists) {
547                     // Trigger login failed event.
548                     $failurereason = AUTH_LOGIN_FAILED;
549                     $event = \core\event\user_login_failed::create(['other' => ['username' => $userinfo['username'],
550                                                                                 'reason' => $failurereason]]);
551                     $event->trigger();
553                     // The username exists but the emails don't match. Refuse to continue.
554                     $errormsg = get_string('accountexists', 'auth_oauth2');
555                     $SESSION->loginerrormsg = $errormsg;
556                     $client->log_out();
557                     redirect(new moodle_url('/login/index.php'));
558                 }
560                 if (email_is_not_allowed($userinfo['email'])) {
561                     // Trigger login failed event.
562                     $failurereason = AUTH_LOGIN_FAILED;
563                     $event = \core\event\user_login_failed::create(['other' => ['username' => $userinfo['username'],
564                                                                                 'reason' => $failurereason]]);
565                     $event->trigger();
566                     // The username exists but the emails don't match. Refuse to continue.
567                     $reason = get_string('loginerror_invaliddomain', 'auth_oauth2');
568                     $errormsg = get_string('notloggedindebug', 'auth_oauth2', $reason);
569                     $SESSION->loginerrormsg = $errormsg;
570                     $client->log_out();
571                     redirect(new moodle_url('/login/index.php'));
572                 }
574                 if (!empty($CFG->authpreventaccountcreation)) {
575                     // Trigger login failed event.
576                     $failurereason = AUTH_LOGIN_UNAUTHORISED;
577                     $event = \core\event\user_login_failed::create(['other' => ['username' => $userinfo['username'],
578                                                                                 'reason' => $failurereason]]);
579                     $event->trigger();
580                     // The username does not exist and settings prevent creating new accounts.
581                     $reason = get_string('loginerror_cannotcreateaccounts', 'auth_oauth2');
582                     $errormsg = get_string('notloggedindebug', 'auth_oauth2', $reason);
583                     $SESSION->loginerrormsg = $errormsg;
584                     $client->log_out();
585                     redirect(new moodle_url('/login/index.php'));
586                 }
588                 if ($issuer->get('requireconfirmation')) {
589                     $PAGE->set_url('/auth/oauth2/confirm-account.php');
590                     $PAGE->set_context(context_system::instance());
592                     // Create a new (unconfirmed account) and send an email to confirm it.
593                     $user = \auth_oauth2\api::send_confirm_account_email($userinfo, $issuer);
595                     $this->update_picture($user);
596                     $emailconfirm = get_string('emailconfirm');
597                     $message = get_string('emailconfirmsent', '', $userinfo['email']);
598                     $this->print_confirm_required($emailconfirm, $message);
599                     exit();
600                 } else {
601                     // Create a new confirmed account.
602                     $newuser = \auth_oauth2\api::create_new_confirmed_account($userinfo, $issuer);
603                     $userinfo = get_complete_user_data('id', $newuser->id);
604                     // No redirect, we will complete this login.
605                 }
606             }
607         }
609         // We used to call authenticate_user - but that won't work if the current user has a different default authentication
610         // method. Since we now ALWAYS link a login - if we get to here we can directly allow the user in.
611         $user = (object) $userinfo;
612         complete_user_login($user);
613         $this->update_picture($user);
614         redirect($redirecturl);
615     }
617     /**
618      * Returns information on how the specified user can change their password.
619      * The password of the oauth2 accounts is not stored in Moodle.
620      *
621      * @param stdClass $user A user object
622      * @return string[] An array of strings with keys subject and message
623      */
624     public function get_password_change_info(stdClass $user) : array {
625         $site = get_site();
627         $data = new stdClass();
628         $data->firstname = $user->firstname;
629         $data->lastname  = $user->lastname;
630         $data->username  = $user->username;
631         $data->sitename  = format_string($site->fullname);
632         $data->admin     = generate_email_signoff();
634         $message = get_string('emailpasswordchangeinfo', 'auth_oauth2', $data);
635         $subject = get_string('emailpasswordchangeinfosubject', 'auth_oauth2', format_string($site->fullname));
637         return [
638             'subject' => $subject,
639             'message' => $message
640         ];
641     }