2 // This file is part of Moodle - http://moodle.org/
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 // GNU General Public License for more details.
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle. If not, see <http://www.gnu.org/licenses/>.
18 * Anobody can login with any password.
20 * @package auth_oauth2
21 * @copyright 2017 Damyon Wiese
22 * @license http://www.gnu.org/copyleft/gpl.html GNU Public License
25 namespace auth_oauth2
;
27 defined('MOODLE_INTERNAL') ||
die();
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');
42 * Plugin for oauth2 authentication.
44 * @package auth_oauth2
45 * @copyright 2017 Damyon Wiese
46 * @license http://www.gnu.org/copyleft/gpl.html GNU Public License
48 class auth
extends \auth_plugin_base
{
51 * @var stdClass $userinfo The set of user info returned from the oauth handshake
53 private static $userinfo;
56 * @var stdClass $userpicture The url to a picture.
58 private static $userpicture;
63 public function __construct() {
64 $this->authtype
= 'oauth2';
65 $this->config
= get_config('auth_oauth2');
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.
72 * @param string $username The username
73 * @param string $password The password
74 * @return bool Authentication success or failure.
76 public function user_login($username, $password) {
77 $cached = $this->get_static_user_info();
79 // This means we were called as part of a normal login flow - without using oauth.
82 $verifyusername = $cached['username'];
83 if ($verifyusername == $username) {
90 * We don't want to allow users setting an internal password.
94 public function prevent_local_passwords() {
99 * Returns true if this authentication plugin is 'internal'.
103 public function is_internal() {
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().
112 * @return bool true means automatically copy data from ext to user table
114 public function is_synchronised_with_external() {
119 * Returns true if this authentication plugin can change the user's
124 public function can_change_password() {
129 * Returns the URL for changing the user's pw, or empty if the default can
134 public function change_password_url() {
139 * Returns true if plugin allows resetting of internal password.
143 public function can_reset_password() {
148 * Returns true if plugin can be manually set.
152 public function can_be_manually_set() {
157 * Return the userinfo from the oauth handshake. Will only be valid
158 * for the logged in user.
159 * @param string $username
161 public function get_userinfo($username) {
162 $cached = $this->get_static_user_info();
163 if (!empty($cached) && $cached['username'] == $username) {
170 * Do some checks on the identity provider before showing it on the login page.
171 * @param core\oauth2\issuer $issuer
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'));
181 * Return a list of identity providers to display on the login page.
183 * @param string|moodle_url $wantsurl The requested URL.
184 * @return array List of arrays with keys url, iconurl and name.
186 public function loginpage_idp_list($wantsurl) {
187 $providers = \core\oauth2\api
::get_all_issuers();
189 if (empty($wantsurl)) {
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')];
204 * Statically cache the user info from the oauth handshake
205 * @param stdClass $userinfo
207 private function set_static_user_info($userinfo) {
208 self
::$userinfo = $userinfo;
212 * Get the static cached user info
215 private function get_static_user_info() {
216 return self
::$userinfo;
220 * Statically cache the user picture from the oauth handshake
221 * @param string $userpicture
223 private function set_static_user_picture($userpicture) {
224 self
::$userpicture = $userpicture;
228 * Get the static cached user picture
231 private function get_static_user_picture() {
232 return self
::$userpicture;
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.
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();
249 if (!empty($user->picture
)) {
252 if (!empty($CFG->enablegravatar
)) {
256 $picture = $this->get_static_user_picture();
257 if (empty($picture)) {
261 $context = \context_user
::instance($userid, MUST_EXIST
);
262 $fs->delete_area_files($context->id
, 'user', 'newicon');
265 'contextid' => $context->id
,
266 'component' => 'user',
267 'filearea' => 'newicon',
270 'filename' => 'image'
274 $fs->create_file_from_string($filerecord, $picture);
275 } catch (\file_exception
$e) {
276 return get_string($e->errorcode
, $e->module
, $e->a
);
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');
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.
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);
306 * Update user data according to data sent by authorization server.
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.
312 private function update_user(array $externaldata, $userdata) {
314 'id' => $userdata->id
,
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
) {
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.
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.
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;
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
);
362 * Confirm the new user as registered.
364 * @param string $username
365 * @param string $confirmsecret
367 public function user_confirm($username, $confirmsecret) {
369 $user = get_complete_user_data('username', $username);
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
;
383 return AUTH_CONFIRM_ERROR
;
388 * Print a page showing that a confirm email was sent with instructions.
390 * @param string $title
391 * @param string $message
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");
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
409 public function complete_login(client
$client, $redirecturl) {
410 global $CFG, $SESSION, $PAGE;
412 $userinfo = $client->get_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]]);
421 $errormsg = get_string('loginerror_nouserinfo', 'auth_oauth2');
422 $SESSION->loginerrormsg
= $errormsg;
424 redirect(new moodle_url('/login/index.php'));
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]]);
433 $errormsg = get_string('loginerror_userincomplete', 'auth_oauth2');
434 $SESSION->loginerrormsg
= $errormsg;
436 redirect(new moodle_url('/login/index.php'));
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']);
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']);
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
,
470 'username' => $userinfo['username'],
471 'reason' => $failurereason
475 $SESSION->loginerrormsg
= get_string('invalidlogin');
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;
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]]);
489 $errormsg = get_string('confirmationpending', 'auth_oauth2');
490 $SESSION->loginerrormsg
= $errormsg;
492 redirect(new moodle_url('/login/index.php'));
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]]);
501 $errormsg = get_string('confirmationpending', 'auth_oauth2');
502 $SESSION->loginerrormsg
= $errormsg;
504 redirect(new moodle_url('/login/index.php'));
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]]);
515 $errormsg = get_string('notloggedindebug', 'auth_oauth2', get_string('loginerror_invaliddomain', 'auth_oauth2'));
516 $SESSION->loginerrormsg
= $errormsg;
518 redirect(new moodle_url('/login/index.php'));
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_oauth
2\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);
537 \auth_oauth
2\api
::link_login($userinfo, $issuer, $moodleuser->id
, true);
538 $userinfo = $this->update_user($userinfo, $moodleuser);
539 // No redirect, we will complete this login.
543 // This is a new account.
544 $exists = \core_user
::get_user_by_username($userinfo['username']);
545 // Creating a new user?
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]]);
553 // The username exists but the emails don't match. Refuse to continue.
554 $errormsg = get_string('accountexists', 'auth_oauth2');
555 $SESSION->loginerrormsg
= $errormsg;
557 redirect(new moodle_url('/login/index.php'));
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]]);
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;
571 redirect(new moodle_url('/login/index.php'));
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]]);
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;
585 redirect(new moodle_url('/login/index.php'));
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_oauth
2\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);
601 // Create a new confirmed account.
602 $newuser = \auth_oauth
2\api
::create_new_confirmed_account($userinfo, $issuer);
603 $userinfo = get_complete_user_data('id', $newuser->id
);
604 // No redirect, we will complete this login.
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);
618 * Returns information on how the specified user can change their password.
619 * The password of the oauth2 accounts is not stored in Moodle.
621 * @param stdClass $user A user object
622 * @return string[] An array of strings with keys subject and message
624 public function get_password_change_info(stdClass
$user) : array {
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
));
638 'subject' => $subject,
639 'message' => $message