Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.

Differences Between: [Versions 310 and 401] [Versions 311 and 401] [Versions 39 and 401] [Versions 401 and 402] [Versions 401 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   * Class for loading/storing oauth2 endpoints from the DB.
  19   *
  20   * @package    core
  21   * @copyright  2017 Damyon Wiese
  22   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  23   */
  24  namespace core\oauth2;
  25  
  26  defined('MOODLE_INTERNAL') || die();
  27  
  28  require_once($CFG->libdir . '/filelib.php');
  29  
  30  use stdClass;
  31  use moodle_url;
  32  use context_system;
  33  use moodle_exception;
  34  
  35  /**
  36   * Static list of api methods for system oauth2 configuration.
  37   *
  38   * @copyright  2017 Damyon Wiese
  39   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  40   */
  41  class api {
  42  
  43      /**
  44       * Initializes a record for one of the standard issuers to be displayed in the settings.
  45       * The issuer is not yet created in the database.
  46       * @param string $type One of google, facebook, microsoft, nextcloud, imsobv2p1
  47       * @return \core\oauth2\issuer
  48       */
  49      public static function init_standard_issuer($type) {
  50          require_capability('moodle/site:config', context_system::instance());
  51  
  52          $classname = self::get_service_classname($type);
  53          if (class_exists($classname)) {
  54              return $classname::init();
  55          }
  56          throw new moodle_exception('OAuth 2 service type not recognised: ' . $type);
  57      }
  58  
  59      /**
  60       * Create endpoints for standard issuers, based on the issuer created from submitted data.
  61       * @param string $type One of google, facebook, microsoft, nextcloud, imsobv2p1
  62       * @param issuer $issuer issuer the endpoints should be created for.
  63       * @return \core\oauth2\issuer
  64       */
  65      public static function create_endpoints_for_standard_issuer($type, $issuer) {
  66          require_capability('moodle/site:config', context_system::instance());
  67  
  68          $classname = self::get_service_classname($type);
  69          if (class_exists($classname)) {
  70              $classname::create_endpoints($issuer);
  71              return $issuer;
  72          }
  73          throw new moodle_exception('OAuth 2 service type not recognised: ' . $type);
  74      }
  75  
  76      /**
  77       * Create one of the standard issuers.
  78       *
  79       * @param string $type One of google, facebook, microsoft, nextcloud or imsobv2p1
  80       * @param string|false $baseurl Baseurl (only required for nextcloud and imsobv2p1)
  81       * @return \core\oauth2\issuer
  82       */
  83      public static function create_standard_issuer($type, $baseurl = false) {
  84          require_capability('moodle/site:config', context_system::instance());
  85  
  86          switch ($type) {
  87              case 'imsobv2p1':
  88                  if (!$baseurl) {
  89                      throw new moodle_exception('IMS OBv2.1 service type requires the baseurl parameter.');
  90                  }
  91              case 'nextcloud':
  92                  if (!$baseurl) {
  93                      throw new moodle_exception('Nextcloud service type requires the baseurl parameter.');
  94                  }
  95              case 'google':
  96              case 'facebook':
  97              case 'microsoft':
  98                  $classname = self::get_service_classname($type);
  99                  $issuer = $classname::init();
 100                  if ($baseurl) {
 101                      $issuer->set('baseurl', $baseurl);
 102                  }
 103                  $issuer->create();
 104                  return self::create_endpoints_for_standard_issuer($type, $issuer);
 105          }
 106  
 107          throw new moodle_exception('OAuth 2 service type not recognised: ' . $type);
 108      }
 109  
 110  
 111      /**
 112       * List all the issuers, ordered by the sortorder field
 113       *
 114       * @param bool $includeloginonly also include issuers that are configured to be shown only on login page,
 115       *     By default false, in this case the method returns all issuers that can be used in services
 116       * @return \core\oauth2\issuer[]
 117       */
 118      public static function get_all_issuers(bool $includeloginonly = false) {
 119          if ($includeloginonly) {
 120              return issuer::get_records([], 'sortorder');
 121          } else {
 122              return array_values(issuer::get_records_select('showonloginpage<>?', [issuer::LOGINONLY], 'sortorder'));
 123          }
 124      }
 125  
 126      /**
 127       * Get a single issuer by id.
 128       *
 129       * @param int $id
 130       * @return \core\oauth2\issuer
 131       */
 132      public static function get_issuer($id) {
 133          return new issuer($id);
 134      }
 135  
 136      /**
 137       * Get a single endpoint by id.
 138       *
 139       * @param int $id
 140       * @return \core\oauth2\endpoint
 141       */
 142      public static function get_endpoint($id) {
 143          return new endpoint($id);
 144      }
 145  
 146      /**
 147       * Get a single user field mapping by id.
 148       *
 149       * @param int $id
 150       * @return \core\oauth2\user_field_mapping
 151       */
 152      public static function get_user_field_mapping($id) {
 153          return new user_field_mapping($id);
 154      }
 155  
 156      /**
 157       * Get the system account for an installed OAuth service.
 158       * Never ever ever expose this to a webservice because it contains the refresh token which grants API access.
 159       *
 160       * @param \core\oauth2\issuer $issuer
 161       * @return system_account|false
 162       */
 163      public static function get_system_account(issuer $issuer) {
 164          return system_account::get_record(['issuerid' => $issuer->get('id')]);
 165      }
 166  
 167      /**
 168       * Get the full list of system scopes required by an oauth issuer.
 169       * This includes the list required for login as well as any scopes injected by the oauth2_system_scopes callback in plugins.
 170       *
 171       * @param \core\oauth2\issuer $issuer
 172       * @return string
 173       */
 174      public static function get_system_scopes_for_issuer($issuer) {
 175          $scopes = $issuer->get('loginscopesoffline');
 176  
 177          $pluginsfunction = get_plugins_with_function('oauth2_system_scopes', 'lib.php');
 178          foreach ($pluginsfunction as $plugintype => $plugins) {
 179              foreach ($plugins as $pluginfunction) {
 180                  // Get additional scopes from the plugin.
 181                  $pluginscopes = $pluginfunction($issuer);
 182                  if (empty($pluginscopes)) {
 183                      continue;
 184                  }
 185  
 186                  // Merge the additional scopes with the existing ones.
 187                  $additionalscopes = explode(' ', $pluginscopes);
 188  
 189                  foreach ($additionalscopes as $scope) {
 190                      if (!empty($scope)) {
 191                          if (strpos(' ' . $scopes . ' ', ' ' . $scope . ' ') === false) {
 192                              $scopes .= ' ' . $scope;
 193                          }
 194                      }
 195                  }
 196              }
 197          }
 198  
 199          return $scopes;
 200      }
 201  
 202      /**
 203       * Get an authenticated oauth2 client using the system account.
 204       * This call uses the refresh token to get an access token.
 205       *
 206       * @param \core\oauth2\issuer $issuer
 207       * @return \core\oauth2\client|false An authenticated client (or false if the token could not be upgraded)
 208       * @throws moodle_exception Request for token upgrade failed for technical reasons
 209       */
 210      public static function get_system_oauth_client(issuer $issuer) {
 211          $systemaccount = self::get_system_account($issuer);
 212          if (empty($systemaccount)) {
 213              return false;
 214          }
 215          // Get all the scopes!
 216          $scopes = self::get_system_scopes_for_issuer($issuer);
 217          $class = self::get_client_classname($issuer->get('servicetype'));
 218          $client = new $class($issuer, null, $scopes, true);
 219  
 220          if (!$client->is_logged_in()) {
 221              if (!$client->upgrade_refresh_token($systemaccount)) {
 222                  return false;
 223              }
 224          }
 225          return $client;
 226      }
 227  
 228      /**
 229       * Get an authenticated oauth2 client using the current user account.
 230       * This call does the redirect dance back to the current page after authentication.
 231       *
 232       * @param \core\oauth2\issuer $issuer The desired OAuth issuer
 233       * @param moodle_url $currenturl The url to the current page.
 234       * @param string $additionalscopes The additional scopes required for authorization.
 235       * @param bool $autorefresh Should the client support the use of refresh tokens to persist access across sessions.
 236       * @return \core\oauth2\client
 237       */
 238      public static function get_user_oauth_client(issuer $issuer, moodle_url $currenturl, $additionalscopes = '',
 239              $autorefresh = false) {
 240          $class = self::get_client_classname($issuer->get('servicetype'));
 241          $client = new $class($issuer, $currenturl, $additionalscopes, false, $autorefresh);
 242  
 243          return $client;
 244      }
 245  
 246      /**
 247       * Get the client classname for an issuer.
 248       *
 249       * @param string $type The OAuth issuer type (google, facebook...).
 250       * @return string The classname for the custom client or core client class if the class for the defined type
 251       *                 doesn't exist or null type is defined.
 252       */
 253      protected static function get_client_classname(?string $type): string {
 254          // Default core client class.
 255          $classname = 'core\\oauth2\\client';
 256  
 257          if (!empty($type)) {
 258              $typeclassname = 'core\\oauth2\\client\\' . $type;
 259              if (class_exists($typeclassname)) {
 260                  $classname = $typeclassname;
 261              }
 262          }
 263  
 264          return $classname;
 265      }
 266  
 267      /**
 268       * Get the list of defined endpoints for this OAuth issuer
 269       *
 270       * @param \core\oauth2\issuer $issuer The desired OAuth issuer
 271       * @return \core\oauth2\endpoint[]
 272       */
 273      public static function get_endpoints(issuer $issuer) {
 274          return endpoint::get_records(['issuerid' => $issuer->get('id')]);
 275      }
 276  
 277      /**
 278       * Get the list of defined mapping from OAuth user fields to moodle user fields.
 279       *
 280       * @param \core\oauth2\issuer $issuer The desired OAuth issuer
 281       * @return \core\oauth2\user_field_mapping[]
 282       */
 283      public static function get_user_field_mappings(issuer $issuer) {
 284          return user_field_mapping::get_records(['issuerid' => $issuer->get('id')]);
 285      }
 286  
 287      /**
 288       * Guess an image from the discovery URL.
 289       *
 290       * @param \core\oauth2\issuer $issuer The desired OAuth issuer
 291       */
 292      protected static function guess_image($issuer) {
 293          if (empty($issuer->get('image')) && !empty($issuer->get('baseurl'))) {
 294              $baseurl = parse_url($issuer->get('baseurl'));
 295              $imageurl = $baseurl['scheme'] . '://' . $baseurl['host'] . '/favicon.ico';
 296              $issuer->set('image', $imageurl);
 297              $issuer->update();
 298          }
 299      }
 300  
 301      /**
 302       * Take the data from the mform and update the issuer.
 303       *
 304       * @param stdClass $data
 305       * @return \core\oauth2\issuer
 306       */
 307      public static function update_issuer($data) {
 308          return self::create_or_update_issuer($data, false);
 309      }
 310  
 311      /**
 312       * Take the data from the mform and create the issuer.
 313       *
 314       * @param stdClass $data
 315       * @return \core\oauth2\issuer
 316       */
 317      public static function create_issuer($data) {
 318          return self::create_or_update_issuer($data, true);
 319      }
 320  
 321      /**
 322       * Take the data from the mform and create or update the issuer.
 323       *
 324       * @param stdClass $data Form data for them issuer to be created/updated.
 325       * @param bool $create If true, the issuer will be created; otherwise, it will be updated.
 326       * @return issuer The created/updated issuer.
 327       */
 328      protected static function create_or_update_issuer($data, bool $create): issuer {
 329          require_capability('moodle/site:config', context_system::instance());
 330          $issuer = new issuer($data->id ?? 0, $data);
 331  
 332          // Will throw exceptions on validation failures.
 333          if ($create) {
 334              $issuer->create();
 335  
 336              // Perform service discovery.
 337              $classname = self::get_service_classname($issuer->get('servicetype'));
 338              $classname::discover_endpoints($issuer);
 339              self::guess_image($issuer);
 340          } else {
 341              $issuer->update();
 342          }
 343  
 344          return $issuer;
 345      }
 346  
 347      /**
 348       * Get the service classname for an issuer.
 349       *
 350       * @param string $type The OAuth issuer type (google, facebook...).
 351       *
 352       * @return string The classname for this issuer or "Custom" service class if the class for the defined type doesn't exist
 353       *                 or null type is defined.
 354       */
 355      protected static function get_service_classname(?string $type): string {
 356          // Default custom service class.
 357          $classname = 'core\\oauth2\\service\\custom';
 358  
 359          if (!empty($type)) {
 360              $typeclassname = 'core\\oauth2\\service\\' . $type;
 361              if (class_exists($typeclassname)) {
 362                  $classname = $typeclassname;
 363              }
 364          }
 365  
 366          return $classname;
 367      }
 368  
 369      /**
 370       * Take the data from the mform and update the endpoint.
 371       *
 372       * @param stdClass $data
 373       * @return \core\oauth2\endpoint
 374       */
 375      public static function update_endpoint($data) {
 376          require_capability('moodle/site:config', context_system::instance());
 377          $endpoint = new endpoint(0, $data);
 378  
 379          // Will throw exceptions on validation failures.
 380          $endpoint->update();
 381  
 382          return $endpoint;
 383      }
 384  
 385      /**
 386       * Take the data from the mform and create the endpoint.
 387       *
 388       * @param stdClass $data
 389       * @return \core\oauth2\endpoint
 390       */
 391      public static function create_endpoint($data) {
 392          require_capability('moodle/site:config', context_system::instance());
 393          $endpoint = new endpoint(0, $data);
 394  
 395          // Will throw exceptions on validation failures.
 396          $endpoint->create();
 397          return $endpoint;
 398      }
 399  
 400      /**
 401       * Take the data from the mform and update the user field mapping.
 402       *
 403       * @param stdClass $data
 404       * @return \core\oauth2\user_field_mapping
 405       */
 406      public static function update_user_field_mapping($data) {
 407          require_capability('moodle/site:config', context_system::instance());
 408          $userfieldmapping = new user_field_mapping(0, $data);
 409  
 410          // Will throw exceptions on validation failures.
 411          $userfieldmapping->update();
 412  
 413          return $userfieldmapping;
 414      }
 415  
 416      /**
 417       * Take the data from the mform and create the user field mapping.
 418       *
 419       * @param stdClass $data
 420       * @return \core\oauth2\user_field_mapping
 421       */
 422      public static function create_user_field_mapping($data) {
 423          require_capability('moodle/site:config', context_system::instance());
 424          $userfieldmapping = new user_field_mapping(0, $data);
 425  
 426          // Will throw exceptions on validation failures.
 427          $userfieldmapping->create();
 428          return $userfieldmapping;
 429      }
 430  
 431      /**
 432       * Reorder this identity issuer.
 433       *
 434       * Requires moodle/site:config capability at the system context.
 435       *
 436       * @param int $id The id of the identity issuer to move.
 437       * @return boolean
 438       */
 439      public static function move_up_issuer($id) {
 440          require_capability('moodle/site:config', context_system::instance());
 441          $current = new issuer($id);
 442  
 443          $sortorder = $current->get('sortorder');
 444          if ($sortorder == 0) {
 445              return false;
 446          }
 447  
 448          $sortorder = $sortorder - 1;
 449          $current->set('sortorder', $sortorder);
 450  
 451          $filters = array('sortorder' => $sortorder);
 452          $children = issuer::get_records($filters, 'id');
 453          foreach ($children as $needtoswap) {
 454              $needtoswap->set('sortorder', $sortorder + 1);
 455              $needtoswap->update();
 456          }
 457  
 458          // OK - all set.
 459          $result = $current->update();
 460  
 461          return $result;
 462      }
 463  
 464      /**
 465       * Reorder this identity issuer.
 466       *
 467       * Requires moodle/site:config capability at the system context.
 468       *
 469       * @param int $id The id of the identity issuer to move.
 470       * @return boolean
 471       */
 472      public static function move_down_issuer($id) {
 473          require_capability('moodle/site:config', context_system::instance());
 474          $current = new issuer($id);
 475  
 476          $max = issuer::count_records();
 477          if ($max > 0) {
 478              $max--;
 479          }
 480  
 481          $sortorder = $current->get('sortorder');
 482          if ($sortorder >= $max) {
 483              return false;
 484          }
 485          $sortorder = $sortorder + 1;
 486          $current->set('sortorder', $sortorder);
 487  
 488          $filters = array('sortorder' => $sortorder);
 489          $children = issuer::get_records($filters);
 490          foreach ($children as $needtoswap) {
 491              $needtoswap->set('sortorder', $sortorder - 1);
 492              $needtoswap->update();
 493          }
 494  
 495          // OK - all set.
 496          $result = $current->update();
 497  
 498          return $result;
 499      }
 500  
 501      /**
 502       * Disable an identity issuer.
 503       *
 504       * Requires moodle/site:config capability at the system context.
 505       *
 506       * @param int $id The id of the identity issuer to disable.
 507       * @return boolean
 508       */
 509      public static function disable_issuer($id) {
 510          require_capability('moodle/site:config', context_system::instance());
 511          $issuer = new issuer($id);
 512  
 513          $issuer->set('enabled', 0);
 514          return $issuer->update();
 515      }
 516  
 517  
 518      /**
 519       * Enable an identity issuer.
 520       *
 521       * Requires moodle/site:config capability at the system context.
 522       *
 523       * @param int $id The id of the identity issuer to enable.
 524       * @return boolean
 525       */
 526      public static function enable_issuer($id) {
 527          require_capability('moodle/site:config', context_system::instance());
 528          $issuer = new issuer($id);
 529  
 530          $issuer->set('enabled', 1);
 531          return $issuer->update();
 532      }
 533  
 534      /**
 535       * Delete an identity issuer.
 536       *
 537       * Requires moodle/site:config capability at the system context.
 538       *
 539       * @param int $id The id of the identity issuer to delete.
 540       * @return boolean
 541       */
 542      public static function delete_issuer($id) {
 543          require_capability('moodle/site:config', context_system::instance());
 544          $issuer = new issuer($id);
 545  
 546          $systemaccount = self::get_system_account($issuer);
 547          if ($systemaccount) {
 548              $systemaccount->delete();
 549          }
 550          $endpoints = self::get_endpoints($issuer);
 551          if ($endpoints) {
 552              foreach ($endpoints as $endpoint) {
 553                  $endpoint->delete();
 554              }
 555          }
 556  
 557          // Will throw exceptions on validation failures.
 558          return $issuer->delete();
 559      }
 560  
 561      /**
 562       * Delete an endpoint.
 563       *
 564       * Requires moodle/site:config capability at the system context.
 565       *
 566       * @param int $id The id of the endpoint to delete.
 567       * @return boolean
 568       */
 569      public static function delete_endpoint($id) {
 570          require_capability('moodle/site:config', context_system::instance());
 571          $endpoint = new endpoint($id);
 572  
 573          // Will throw exceptions on validation failures.
 574          return $endpoint->delete();
 575      }
 576  
 577      /**
 578       * Delete a user_field_mapping.
 579       *
 580       * Requires moodle/site:config capability at the system context.
 581       *
 582       * @param int $id The id of the user_field_mapping to delete.
 583       * @return boolean
 584       */
 585      public static function delete_user_field_mapping($id) {
 586          require_capability('moodle/site:config', context_system::instance());
 587          $userfieldmapping = new user_field_mapping($id);
 588  
 589          // Will throw exceptions on validation failures.
 590          return $userfieldmapping->delete();
 591      }
 592  
 593      /**
 594       * Perform the OAuth dance and get a refresh token.
 595       *
 596       * Requires moodle/site:config capability at the system context.
 597       *
 598       * @param \core\oauth2\issuer $issuer
 599       * @param moodle_url $returnurl The url to the current page (we will be redirected back here after authentication).
 600       * @return boolean
 601       */
 602      public static function connect_system_account($issuer, $returnurl) {
 603          require_capability('moodle/site:config', context_system::instance());
 604  
 605          // We need to authenticate with an oauth 2 client AS a system user and get a refresh token for offline access.
 606          $scopes = self::get_system_scopes_for_issuer($issuer);
 607  
 608          // Allow callbacks to inject non-standard scopes to the auth request.
 609          $class = self::get_client_classname($issuer->get('servicetype'));
 610          $client = new $class($issuer, $returnurl, $scopes, true);
 611  
 612          if (!optional_param('response', false, PARAM_BOOL)) {
 613              $client->log_out();
 614          }
 615  
 616          if (optional_param('error', '', PARAM_RAW)) {
 617              return false;
 618          }
 619  
 620          if (!$client->is_logged_in()) {
 621              redirect($client->get_login_url());
 622          }
 623  
 624          $refreshtoken = $client->get_refresh_token();
 625          if (!$refreshtoken) {
 626              return false;
 627          }
 628  
 629          $systemaccount = self::get_system_account($issuer);
 630          if ($systemaccount) {
 631              $systemaccount->delete();
 632          }
 633  
 634          $userinfo = $client->get_userinfo();
 635  
 636          $record = new stdClass();
 637          $record->issuerid = $issuer->get('id');
 638          $record->refreshtoken = $refreshtoken;
 639          $record->grantedscopes = $scopes;
 640          $record->email = isset($userinfo['email']) ? $userinfo['email'] : '';
 641          $record->username = $userinfo['username'];
 642  
 643          $systemaccount = new system_account(0, $record);
 644  
 645          $systemaccount->create();
 646  
 647          $client->log_out();
 648          return true;
 649      }
 650  }