Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.3.x will end 7 October 2024 (12 months).
  • Bug fixes for security issues in 4.3.x will end 21 April 2025 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.2.x is supported too.

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