Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 3.11.x will end 14 Nov 2022 (12 months plus 6 months extension).
  • Bug fixes for security issues in 3.11.x will end 13 Nov 2023 (18 months plus 12 months extension).
  • PHP version: minimum PHP 7.3.0 Note: minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is supported too.

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

   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          if (!empty($data->id)) {
 332              foreach ($data as $property => $value) {
 333                  $issuer->set($property, $value);
 334              }
 335          }
 336  
 337          // Will throw exceptions on validation failures.
 338          if ($create) {
 339              $issuer->create();
 340  
 341              // Perform service discovery.
 342              $classname = self::get_service_classname($issuer->get('servicetype'));
 343              $classname::discover_endpoints($issuer);
 344              self::guess_image($issuer);
 345          } else {
 346              $issuer->update();
 347          }
 348  
 349          return $issuer;
 350      }
 351  
 352      /**
 353       * Get the service classname for an issuer.
 354       *
 355       * @param string $type The OAuth issuer type (google, facebook...).
 356       *
 357       * @return string The classname for this issuer or "Custom" service class if the class for the defined type doesn't exist
 358       *                 or null type is defined.
 359       */
 360      protected static function get_service_classname(?string $type): string {
 361          // Default custom service class.
 362          $classname = 'core\\oauth2\\service\\custom';
 363  
 364          if (!empty($type)) {
 365              $typeclassname = 'core\\oauth2\\service\\' . $type;
 366              if (class_exists($typeclassname)) {
 367                  $classname = $typeclassname;
 368              }
 369          }
 370  
 371          return $classname;
 372      }
 373  
 374      /**
 375       * Take the data from the mform and update the endpoint.
 376       *
 377       * @param stdClass $data
 378       * @return \core\oauth2\endpoint
 379       */
 380      public static function update_endpoint($data) {
 381          require_capability('moodle/site:config', context_system::instance());
 382          $endpoint = new endpoint(0, $data);
 383  
 384          // Will throw exceptions on validation failures.
 385          $endpoint->update();
 386  
 387          return $endpoint;
 388      }
 389  
 390      /**
 391       * Take the data from the mform and create the endpoint.
 392       *
 393       * @param stdClass $data
 394       * @return \core\oauth2\endpoint
 395       */
 396      public static function create_endpoint($data) {
 397          require_capability('moodle/site:config', context_system::instance());
 398          $endpoint = new endpoint(0, $data);
 399  
 400          // Will throw exceptions on validation failures.
 401          $endpoint->create();
 402          return $endpoint;
 403      }
 404  
 405      /**
 406       * Take the data from the mform and update the user field mapping.
 407       *
 408       * @param stdClass $data
 409       * @return \core\oauth2\user_field_mapping
 410       */
 411      public static function update_user_field_mapping($data) {
 412          require_capability('moodle/site:config', context_system::instance());
 413          $userfieldmapping = new user_field_mapping(0, $data);
 414  
 415          // Will throw exceptions on validation failures.
 416          $userfieldmapping->update();
 417  
 418          return $userfieldmapping;
 419      }
 420  
 421      /**
 422       * Take the data from the mform and create the user field mapping.
 423       *
 424       * @param stdClass $data
 425       * @return \core\oauth2\user_field_mapping
 426       */
 427      public static function create_user_field_mapping($data) {
 428          require_capability('moodle/site:config', context_system::instance());
 429          $userfieldmapping = new user_field_mapping(0, $data);
 430  
 431          // Will throw exceptions on validation failures.
 432          $userfieldmapping->create();
 433          return $userfieldmapping;
 434      }
 435  
 436      /**
 437       * Reorder this identity issuer.
 438       *
 439       * Requires moodle/site:config capability at the system context.
 440       *
 441       * @param int $id The id of the identity issuer to move.
 442       * @return boolean
 443       */
 444      public static function move_up_issuer($id) {
 445          require_capability('moodle/site:config', context_system::instance());
 446          $current = new issuer($id);
 447  
 448          $sortorder = $current->get('sortorder');
 449          if ($sortorder == 0) {
 450              return false;
 451          }
 452  
 453          $sortorder = $sortorder - 1;
 454          $current->set('sortorder', $sortorder);
 455  
 456          $filters = array('sortorder' => $sortorder);
 457          $children = issuer::get_records($filters, 'id');
 458          foreach ($children as $needtoswap) {
 459              $needtoswap->set('sortorder', $sortorder + 1);
 460              $needtoswap->update();
 461          }
 462  
 463          // OK - all set.
 464          $result = $current->update();
 465  
 466          return $result;
 467      }
 468  
 469      /**
 470       * Reorder this identity issuer.
 471       *
 472       * Requires moodle/site:config capability at the system context.
 473       *
 474       * @param int $id The id of the identity issuer to move.
 475       * @return boolean
 476       */
 477      public static function move_down_issuer($id) {
 478          require_capability('moodle/site:config', context_system::instance());
 479          $current = new issuer($id);
 480  
 481          $max = issuer::count_records();
 482          if ($max > 0) {
 483              $max--;
 484          }
 485  
 486          $sortorder = $current->get('sortorder');
 487          if ($sortorder >= $max) {
 488              return false;
 489          }
 490          $sortorder = $sortorder + 1;
 491          $current->set('sortorder', $sortorder);
 492  
 493          $filters = array('sortorder' => $sortorder);
 494          $children = issuer::get_records($filters);
 495          foreach ($children as $needtoswap) {
 496              $needtoswap->set('sortorder', $sortorder - 1);
 497              $needtoswap->update();
 498          }
 499  
 500          // OK - all set.
 501          $result = $current->update();
 502  
 503          return $result;
 504      }
 505  
 506      /**
 507       * Disable an identity issuer.
 508       *
 509       * Requires moodle/site:config capability at the system context.
 510       *
 511       * @param int $id The id of the identity issuer to disable.
 512       * @return boolean
 513       */
 514      public static function disable_issuer($id) {
 515          require_capability('moodle/site:config', context_system::instance());
 516          $issuer = new issuer($id);
 517  
 518          $issuer->set('enabled', 0);
 519          return $issuer->update();
 520      }
 521  
 522  
 523      /**
 524       * Enable an identity issuer.
 525       *
 526       * Requires moodle/site:config capability at the system context.
 527       *
 528       * @param int $id The id of the identity issuer to enable.
 529       * @return boolean
 530       */
 531      public static function enable_issuer($id) {
 532          require_capability('moodle/site:config', context_system::instance());
 533          $issuer = new issuer($id);
 534  
 535          $issuer->set('enabled', 1);
 536          return $issuer->update();
 537      }
 538  
 539      /**
 540       * Delete an identity issuer.
 541       *
 542       * Requires moodle/site:config capability at the system context.
 543       *
 544       * @param int $id The id of the identity issuer to delete.
 545       * @return boolean
 546       */
 547      public static function delete_issuer($id) {
 548          require_capability('moodle/site:config', context_system::instance());
 549          $issuer = new issuer($id);
 550  
 551          $systemaccount = self::get_system_account($issuer);
 552          if ($systemaccount) {
 553              $systemaccount->delete();
 554          }
 555          $endpoints = self::get_endpoints($issuer);
 556          if ($endpoints) {
 557              foreach ($endpoints as $endpoint) {
 558                  $endpoint->delete();
 559              }
 560          }
 561  
 562          // Will throw exceptions on validation failures.
 563          return $issuer->delete();
 564      }
 565  
 566      /**
 567       * Delete an endpoint.
 568       *
 569       * Requires moodle/site:config capability at the system context.
 570       *
 571       * @param int $id The id of the endpoint to delete.
 572       * @return boolean
 573       */
 574      public static function delete_endpoint($id) {
 575          require_capability('moodle/site:config', context_system::instance());
 576          $endpoint = new endpoint($id);
 577  
 578          // Will throw exceptions on validation failures.
 579          return $endpoint->delete();
 580      }
 581  
 582      /**
 583       * Delete a user_field_mapping.
 584       *
 585       * Requires moodle/site:config capability at the system context.
 586       *
 587       * @param int $id The id of the user_field_mapping to delete.
 588       * @return boolean
 589       */
 590      public static function delete_user_field_mapping($id) {
 591          require_capability('moodle/site:config', context_system::instance());
 592          $userfieldmapping = new user_field_mapping($id);
 593  
 594          // Will throw exceptions on validation failures.
 595          return $userfieldmapping->delete();
 596      }
 597  
 598      /**
 599       * Perform the OAuth dance and get a refresh token.
 600       *
 601       * Requires moodle/site:config capability at the system context.
 602       *
 603       * @param \core\oauth2\issuer $issuer
 604       * @param moodle_url $returnurl The url to the current page (we will be redirected back here after authentication).
 605       * @return boolean
 606       */
 607      public static function connect_system_account($issuer, $returnurl) {
 608          require_capability('moodle/site:config', context_system::instance());
 609  
 610          // We need to authenticate with an oauth 2 client AS a system user and get a refresh token for offline access.
 611          $scopes = self::get_system_scopes_for_issuer($issuer);
 612  
 613          // Allow callbacks to inject non-standard scopes to the auth request.
 614          $class = self::get_client_classname($issuer->get('servicetype'));
 615          $client = new $class($issuer, $returnurl, $scopes, true);
 616  
 617          if (!optional_param('response', false, PARAM_BOOL)) {
 618              $client->log_out();
 619          }
 620  
 621          if (optional_param('error', '', PARAM_RAW)) {
 622              return false;
 623          }
 624  
 625          if (!$client->is_logged_in()) {
 626              redirect($client->get_login_url());
 627          }
 628  
 629          $refreshtoken = $client->get_refresh_token();
 630          if (!$refreshtoken) {
 631              return false;
 632          }
 633  
 634          $systemaccount = self::get_system_account($issuer);
 635          if ($systemaccount) {
 636              $systemaccount->delete();
 637          }
 638  
 639          $userinfo = $client->get_userinfo();
 640  
 641          $record = new stdClass();
 642          $record->issuerid = $issuer->get('id');
 643          $record->refreshtoken = $refreshtoken;
 644          $record->grantedscopes = $scopes;
 645          $record->email = isset($userinfo['email']) ? $userinfo['email'] : '';
 646          $record->username = $userinfo['username'];
 647  
 648          $systemaccount = new system_account(0, $record);
 649  
 650          $systemaccount->create();
 651  
 652          $client->log_out();
 653          return true;
 654      }
 655  }