Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 3.9.x will end* 10 May 2021 (12 months).
  • Bug fixes for security issues in 3.9.x will end* 8 May 2023 (36 months).
  • PHP version: minimum PHP 7.2.0 Note: minimum PHP version has increased since Moodle 3.8. PHP 7.3.x and 7.4.x are supported too.

Differences Between: [Versions 39 and 311] [Versions 39 and 400] [Versions 39 and 401] [Versions 39 and 402] [Versions 39 and 403]

   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  
  25  namespace auth_oauth2;
  26  
  27  defined('MOODLE_INTERNAL') || die();
  28  
  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;
  36  
  37  require_once($CFG->libdir.'/authlib.php');
  38  require_once($CFG->dirroot.'/user/lib.php');
  39  require_once($CFG->dirroot.'/user/profile/lib.php');
  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   */
  48  class 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();
  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      }
  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() {
 115          return true;
 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  
 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      }
 168  
 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      }
 179  
 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      }
 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.
 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;
 242  
 243          require_once($CFG->libdir . '/filelib.php');
 244          require_once($CFG->libdir . '/gdlib.php');
 245          require_once($CFG->dirroot . '/user/lib.php');
 246  
 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          }
 255  
 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  
 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          ];
 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.
 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              }
 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              }
 350          }
 351          // Update the user data.
 352          user_update_user($user, false);
 353  
 354          // Save user profile data.
 355          profile_save_data($user);
 356  
 357          // Refresh user for $USER variable.
 358          return get_complete_user_data('id', $user->id);
 359      }
 360  
 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);
 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  
 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      }
 386  
 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;
 395  
 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      }
 402  
 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;
 411  
 412          $userinfo = $client->get_userinfo();
 413  
 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();
 420  
 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();
 432  
 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          }
 438  
 439          $userinfo['username'] = trim(core_text::strtolower($userinfo['username']));
 440          $oauthemail = $userinfo['email'];
 441  
 442          // Once we get here we have the user info from oauth.
 443          $userwasmapped = false;
 444  
 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          $issuer = $client->get_issuer();
 459          // First we try and find a defined mapping.
 460          $linkedlogin = api::match_username_to_user($userinfo['username'], $issuer);
 461  
 462          if (!empty($linkedlogin) && empty($linkedlogin->get('confirmtoken'))) {
 463              $mappeduser = get_complete_user_data('id', $linkedlogin->get('userid'));
 464  
 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();
 488  
 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();
 500  
 501              $errormsg = get_string('confirmationpending', 'auth_oauth2');
 502              $SESSION->loginerrormsg = $errormsg;
 503              $client->log_out();
 504              redirect(new moodle_url('/login/index.php'));
 505          }
 506  
 507  
 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();
 514  
 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          }
 520  
 521          if (!$userwasmapped) {
 522              // No defined mapping - we need to see if there is an existing account with the same email.
 523  
 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());
 529  
 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                  }
 541  
 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();
 552  
 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                  }
 559  
 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                  }
 573  
 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                  }
 587  
 588                  if ($issuer->get('requireconfirmation')) {
 589                      $PAGE->set_url('/auth/oauth2/confirm-account.php');
 590                      $PAGE->set_context(context_system::instance());
 591  
 592                      // Create a new (unconfirmed account) and send an email to confirm it.
 593                      $user = \auth_oauth2\api::send_confirm_account_email($userinfo, $issuer);
 594  
 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          }
 608  
 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      }
 616  
 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();
 626  
 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();
 633  
 634          $message = get_string('emailpasswordchangeinfo', 'auth_oauth2', $data);
 635          $subject = get_string('emailpasswordchangeinfosubject', 'auth_oauth2', format_string($site->fullname));
 636  
 637          return [
 638              'subject' => $subject,
 639              'message' => $message
 640          ];
 641      }
 642  }