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