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 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   * Configurable oauth2 client class.
  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 . '/oauthlib.php');
  29  require_once($CFG->libdir . '/filelib.php');
  30  
  31  use moodle_url;
  32  use moodle_exception;
  33  use stdClass;
  34  
  35  /**
  36   * Configurable oauth2 client class. URLs come from DB and access tokens from either DB (system accounts) or session (users').
  37   *
  38   * @copyright  2017 Damyon Wiese
  39   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  40   */
  41  class client extends \oauth2_client {
  42  
  43      /** @var \core\oauth2\issuer $issuer */
  44      private $issuer;
  45  
  46      /** @var bool $system */
  47      protected $system = false;
  48  
  49      /** @var bool $autorefresh whether this client will use a refresh token to automatically renew access tokens.*/
  50      protected $autorefresh = false;
  51  
  52      /**
  53       * Constructor.
  54       *
  55       * @param issuer $issuer
  56       * @param moodle_url|null $returnurl
  57       * @param string $scopesrequired
  58       * @param boolean $system
  59       * @param boolean $autorefresh whether refresh_token grants are used to allow continued access across sessions.
  60       */
  61      public function __construct(issuer $issuer, $returnurl, $scopesrequired, $system = false, $autorefresh = false) {
  62          $this->issuer = $issuer;
  63          $this->system = $system;
  64          $this->autorefresh = $autorefresh;
  65          $scopes = $this->get_login_scopes();
  66          $additionalscopes = explode(' ', $scopesrequired);
  67  
  68          foreach ($additionalscopes as $scope) {
  69              if (!empty($scope)) {
  70                  if (strpos(' ' . $scopes . ' ', ' ' . $scope . ' ') === false) {
  71                      $scopes .= ' ' . $scope;
  72                  }
  73              }
  74          }
  75          if (empty($returnurl)) {
  76              $returnurl = new moodle_url('/');
  77          }
  78          $this->basicauth = $issuer->get('basicauth');
  79          parent::__construct($issuer->get('clientid'), $issuer->get('clientsecret'), $returnurl, $scopes);
  80      }
  81  
  82      /**
  83       * Returns the auth url for OAuth 2.0 request
  84       * @return string the auth url
  85       */
  86      protected function auth_url() {
  87          return $this->issuer->get_endpoint_url('authorization');
  88      }
  89  
  90      /**
  91       * Get the oauth2 issuer for this client.
  92       *
  93       * @return \core\oauth2\issuer Issuer
  94       */
  95      public function get_issuer() {
  96          return $this->issuer;
  97      }
  98  
  99      /**
 100       * Override to append additional params to a authentication request.
 101       *
 102       * @return array (name value pairs).
 103       */
 104      public function get_additional_login_parameters() {
 105          $params = '';
 106  
 107          if ($this->system || $this->can_autorefresh()) {
 108              // System clients and clients supporting the refresh_token grant (provided the user is authenticated) add
 109              // extra params to the login request, depending on the issuer settings. The extra params allow a refresh
 110              // token to be returned during the authorization_code flow.
 111              if (!empty($this->issuer->get('loginparamsoffline'))) {
 112                  $params = $this->issuer->get('loginparamsoffline');
 113              }
 114          } else {
 115              // This is not a system client, nor a client supporting the refresh_token grant type, so just return the
 116              // vanilla login params.
 117              if (!empty($this->issuer->get('loginparams'))) {
 118                  $params = $this->issuer->get('loginparams');
 119              }
 120          }
 121  
 122          if (empty($params)) {
 123              return [];
 124          }
 125          $result = [];
 126          parse_str($params, $result);
 127          return $result;
 128      }
 129  
 130      /**
 131       * Override to change the scopes requested with an authentiction request.
 132       *
 133       * @return string
 134       */
 135      protected function get_login_scopes() {
 136          if ($this->system || $this->can_autorefresh()) {
 137              // System clients and clients supporting the refresh_token grant (provided the user is authenticated) add
 138              // extra scopes to the login request, depending on the issuer settings. The extra params allow a refresh
 139              // token to be returned during the authorization_code flow.
 140              return $this->issuer->get('loginscopesoffline');
 141          } else {
 142              // This is not a system client, nor a client supporting the refresh_token grant type, so just return the
 143              // vanilla login scopes.
 144              return $this->issuer->get('loginscopes');
 145          }
 146      }
 147  
 148      /**
 149       * Returns the token url for OAuth 2.0 request
 150       *
 151       * We are overriding the parent function so we get this from the configured endpoint.
 152       *
 153       * @return string the auth url
 154       */
 155      protected function token_url() {
 156          return $this->issuer->get_endpoint_url('token');
 157      }
 158  
 159      /**
 160       * We want a unique key for each issuer / and a different key for system vs user oauth.
 161       *
 162       * @return string The unique key for the session value.
 163       */
 164      protected function get_tokenname() {
 165          $name = 'oauth2-state-' . $this->issuer->get('id');
 166          if ($this->system) {
 167              $name .= '-system';
 168          }
 169          return $name;
 170      }
 171  
 172      /**
 173       * Store a token between requests. Uses session named by get_tokenname for user account tokens
 174       * and a database record for system account tokens.
 175       *
 176       * @param stdClass|null $token token object to store or null to clear
 177       */
 178      protected function store_token($token) {
 179          if (!$this->system) {
 180              parent::store_token($token);
 181              return;
 182          }
 183  
 184          $this->accesstoken = $token;
 185  
 186          // Create or update a DB record with the new token.
 187          $persistedtoken = access_token::get_record(['issuerid' => $this->issuer->get('id')]);
 188          if ($token !== null) {
 189              if (!$persistedtoken) {
 190                  $persistedtoken = new access_token();
 191                  $persistedtoken->set('issuerid', $this->issuer->get('id'));
 192              }
 193              // Update values from $token. Don't use from_record because that would skip validation.
 194              $persistedtoken->set('token', $token->token);
 195              if (isset($token->expires)) {
 196                  $persistedtoken->set('expires', $token->expires);
 197              } else {
 198                  // Assume an arbitrary time span of 1 week for access tokens without expiration.
 199                  // The "refresh_system_tokens_task" is run hourly (by default), so the token probably won't last that long.
 200                  $persistedtoken->set('expires', time() + WEEKSECS);
 201              }
 202              $persistedtoken->set('scope', $token->scope);
 203              $persistedtoken->save();
 204          } else {
 205              if ($persistedtoken) {
 206                  $persistedtoken->delete();
 207              }
 208          }
 209      }
 210  
 211      /**
 212       * Retrieve a stored token from session (user accounts) or database (system accounts).
 213       *
 214       * @return stdClass|null token object
 215       */
 216      protected function get_stored_token() {
 217          if ($this->system) {
 218              $token = access_token::get_record(['issuerid' => $this->issuer->get('id')]);
 219              if ($token !== false) {
 220                  return $token->to_record();
 221              }
 222              return null;
 223          }
 224  
 225          return parent::get_stored_token();
 226      }
 227  
 228      /**
 229       * Get a list of the mapping user fields in an associative array.
 230       *
 231       * @return array
 232       */
 233      protected function get_userinfo_mapping() {
 234          $fields = user_field_mapping::get_records(['issuerid' => $this->issuer->get('id')]);
 235  
 236          $map = [];
 237          foreach ($fields as $field) {
 238              $map[$field->get('externalfield')] = $field->get('internalfield');
 239          }
 240          return $map;
 241      }
 242  
 243      /**
 244       * Override which upgrades the authorization code to an access token and stores any refresh token in the DB.
 245       *
 246       * @param string $code the authorisation code
 247       * @return bool true if the token could be upgraded
 248       * @throws moodle_exception
 249       */
 250      public function upgrade_token($code) {
 251          $upgraded = parent::upgrade_token($code);
 252          if (!$this->can_autorefresh()) {
 253              return $upgraded;
 254          }
 255  
 256          // For clients supporting auto-refresh, try to store a refresh token.
 257          if (!empty($this->refreshtoken)) {
 258              $refreshtoken = (object) [
 259                  'token' => $this->refreshtoken,
 260                  'scope' => $this->scope
 261              ];
 262              $this->store_user_refresh_token($refreshtoken);
 263          }
 264  
 265          return $upgraded;
 266      }
 267  
 268      /**
 269       * Override which in addition to auth code upgrade, also attempts to exchange a refresh token for an access token.
 270       *
 271       * @return bool true if the user is logged in as a result, false otherwise.
 272       */
 273      public function is_logged_in() {
 274          global $DB, $USER;
 275  
 276          $isloggedin = parent::is_logged_in();
 277  
 278          // Attempt to exchange a user refresh token, but only if required and supported.
 279          if ($isloggedin || !$this->can_autorefresh()) {
 280              return $isloggedin;
 281          }
 282  
 283          // Autorefresh is supported. Try to negotiate a login by exchanging a stored refresh token for an access token.
 284          $issuerid = $this->issuer->get('id');
 285          $refreshtoken = $DB->get_record('oauth2_refresh_token', ['userid' => $USER->id, 'issuerid' => $issuerid]);
 286          if ($refreshtoken) {
 287              try {
 288                  $tokensreceived = $this->exchange_refresh_token($refreshtoken->token);
 289                  if (empty($tokensreceived)) {
 290                      // No access token was returned, so invalidate the refresh token and return false.
 291                      $DB->delete_records('oauth2_refresh_token', ['id' => $refreshtoken->id]);
 292                      return false;
 293                  }
 294  
 295                  // Otherwise, save the access token and, if provided, the new refresh token.
 296                  $this->store_token($tokensreceived['access_token']);
 297                  if (!empty($tokensreceived['refresh_token'])) {
 298                      $this->store_user_refresh_token($tokensreceived['refresh_token']);
 299                  }
 300                  return true;
 301              } catch (\moodle_exception $e) {
 302                  // The refresh attempt failed either due to an error or a bad request. A bad request could be received
 303                  // for a number of reasons including expired refresh token (lifetime is not specified in OAuth 2 spec),
 304                  // scope change or if app access has been revoked manually by the user (tokens revoked).
 305                  // Remove the refresh token and suppress the exception, allowing the user to be taken through the
 306                  // authorization_code flow again.
 307                  $DB->delete_records('oauth2_refresh_token', ['id' => $refreshtoken->id]);
 308              }
 309          }
 310  
 311          return false;
 312      }
 313  
 314      /**
 315       * Whether this client should automatically exchange a refresh token for an access token as part of login checks.
 316       *
 317       * @return bool true if supported, false otherwise.
 318       */
 319      protected function can_autorefresh(): bool {
 320          global $USER;
 321  
 322          // Auto refresh is only supported when the follow criteria are met:
 323          // a) The client is not a system client. The exchange process for system client refresh tokens is handled
 324          // externally, via a call to client->upgrade_refresh_token().
 325          // b) The user is authenticated.
 326          // c) The client has been configured with autorefresh enabled.
 327          return !$this->system && ($this->autorefresh && !empty($USER->id));
 328      }
 329  
 330      /**
 331       * Store the user's refresh token for later use.
 332       *
 333       * @param stdClass $token a refresh token.
 334       */
 335      protected function store_user_refresh_token(stdClass $token): void {
 336          global $DB, $USER;
 337  
 338          $id = $DB->get_field('oauth2_refresh_token', 'id', ['userid' => $USER->id,
 339              'scopehash' => sha1($token->scope), 'issuerid' => $this->issuer->get('id')]);
 340          $time = time();
 341          if ($id) {
 342              $record = [
 343                  'id' => $id,
 344                  'timemodified' => $time,
 345                  'token' => $token->token
 346              ];
 347              $DB->update_record('oauth2_refresh_token', $record);
 348          } else {
 349              $record = [
 350                  'timecreated' => $time,
 351                  'timemodified' => $time,
 352                  'userid' => $USER->id,
 353                  'issuerid' => $this->issuer->get('id'),
 354                  'token' => $token->token,
 355                  'scopehash' => sha1($token->scope)
 356              ];
 357              $DB->insert_record('oauth2_refresh_token', $record);
 358          }
 359      }
 360  
 361      /**
 362       * Attempt to exchange a refresh token for a new access token.
 363       *
 364       * If successful, will return an array of token objects in the form:
 365       * Array
 366       * (
 367       *     [access_token] => stdClass object
 368       *         (
 369       *             [token] => 'the_token_string'
 370       *             [expires] => 123456789
 371       *             [scope] => 'openid files etc'
 372       *         )
 373       *     [refresh_token] => stdClass object
 374       *         (
 375       *             [token] => 'the_refresh_token_string'
 376       *             [scope] => 'openid files etc'
 377       *         )
 378       *  )
 379       * where the 'refresh_token' will only be provided if supplied by the auth server in the response.
 380       *
 381       * @param string $refreshtoken the refresh token to exchange.
 382       * @return null|array array containing access token and refresh token if provided, null if the exchange was denied.
 383       * @throws moodle_exception if an invalid response is received or if the response contains errors.
 384       */
 385      protected function exchange_refresh_token(string $refreshtoken): ?array {
 386          $params = array('refresh_token' => $refreshtoken,
 387              'grant_type' => 'refresh_token'
 388          );
 389  
 390          if ($this->basicauth) {
 391              $idsecret = urlencode($this->issuer->get('clientid')) . ':' . urlencode($this->issuer->get('clientsecret'));
 392              $this->setHeader('Authorization: Basic ' . base64_encode($idsecret));
 393          } else {
 394              $params['client_id'] = $this->issuer->get('clientid');
 395              $params['client_secret'] = $this->issuer->get('clientsecret');
 396          }
 397  
 398          // Requests can either use http GET or POST.
 399          if ($this->use_http_get()) {
 400              $response = $this->get($this->token_url(), $params);
 401          } else {
 402              $response = $this->post($this->token_url(), $this->build_post_data($params));
 403          }
 404  
 405          if ($this->info['http_code'] !== 200) {
 406              $debuginfo = !empty($this->error) ? $this->error : $response;
 407              throw new moodle_exception('oauth2refreshtokenerror', 'core_error', '', $this->info['http_code'], $debuginfo);
 408          }
 409  
 410          $r = json_decode($response);
 411  
 412          if (!empty($r->error)) {
 413              throw new moodle_exception($r->error . ' ' . $r->error_description);
 414          }
 415  
 416          if (!isset($r->access_token)) {
 417              return null;
 418          }
 419  
 420          // Store the token an expiry time.
 421          $accesstoken = new stdClass();
 422          $accesstoken->token = $r->access_token;
 423          if (isset($r->expires_in)) {
 424              // Expires 10 seconds before actual expiry.
 425              $accesstoken->expires = (time() + ($r->expires_in - 10));
 426          }
 427          $accesstoken->scope = $this->scope;
 428  
 429          $tokens = ['access_token' => $accesstoken];
 430  
 431          if (isset($r->refresh_token)) {
 432              $this->refreshtoken = $r->refresh_token;
 433              $newrefreshtoken = new stdClass();
 434              $newrefreshtoken->token = $this->refreshtoken;
 435              $newrefreshtoken->scope = $this->scope;
 436              $tokens['refresh_token'] = $newrefreshtoken;
 437          }
 438  
 439          return $tokens;
 440      }
 441  
 442      /**
 443       * Override which, in addition to deleting access tokens, also deletes any stored refresh token.
 444       */
 445      public function log_out() {
 446          global $DB, $USER;
 447          parent::log_out();
 448          if (!$this->can_autorefresh()) {
 449              return;
 450          }
 451  
 452          // For clients supporting autorefresh, delete the stored refresh token too.
 453          $issuerid = $this->issuer->get('id');
 454          $refreshtoken = $DB->get_record('oauth2_refresh_token', ['userid' => $USER->id, 'issuerid' => $issuerid,
 455              'scopehash' => sha1($this->scope)]);
 456          if ($refreshtoken) {
 457              $DB->delete_records('oauth2_refresh_token', ['id' => $refreshtoken->id]);
 458          }
 459      }
 460  
 461      /**
 462       * Upgrade a refresh token from oauth 2.0 to an access token, for system clients only.
 463       *
 464       * @param \core\oauth2\system_account $systemaccount
 465       * @return boolean true if token is upgraded succesfully
 466       */
 467      public function upgrade_refresh_token(system_account $systemaccount) {
 468          $receivedtokens = $this->exchange_refresh_token($systemaccount->get('refreshtoken'));
 469  
 470          // No access token received, so return false.
 471          if (empty($receivedtokens)) {
 472              return false;
 473          }
 474  
 475          // Store the access token and, if provided by the server, the new refresh token.
 476          $this->store_token($receivedtokens['access_token']);
 477          if (isset($receivedtokens['refresh_token'])) {
 478              $systemaccount->set('refreshtoken', $receivedtokens['refresh_token']->token);
 479              $systemaccount->update();
 480          }
 481  
 482          return true;
 483      }
 484  
 485      /**
 486       * Fetch the user info from the user info endpoint and map all
 487       * the fields back into moodle fields.
 488       *
 489       * @return array|false Moodle user fields for the logged in user (or false if request failed)
 490       * @throws moodle_exception if the response is empty after decoding it.
 491       */
 492      public function get_userinfo() {
 493          $url = $this->get_issuer()->get_endpoint_url('userinfo');
 494          if (empty($url)) {
 495              return false;
 496          }
 497  
 498          $response = $this->get($url);
 499          if (!$response) {
 500              return false;
 501          }
 502          $userinfo = new stdClass();
 503          try {
 504              $userinfo = json_decode($response);
 505          } catch (\Exception $e) {
 506              return false;
 507          }
 508  
 509          if (is_null($userinfo)) {
 510              // Throw an exception displaying the original response, because, at this point, $userinfo shouldn't be empty.
 511              throw new moodle_exception($response);
 512          }
 513  
 514          return $this->map_userinfo_to_fields($userinfo);
 515      }
 516  
 517      /**
 518       * Maps the oauth2 response to userfields.
 519       *
 520       * @param stdClass $userinfo
 521       * @return array
 522       */
 523      protected function map_userinfo_to_fields(stdClass $userinfo): array {
 524          $map = $this->get_userinfo_mapping();
 525  
 526          $user = new stdClass();
 527          foreach ($map as $openidproperty => $moodleproperty) {
 528              // We support nested objects via a-b-c syntax.
 529              $getfunc = function($obj, $prop) use (&$getfunc) {
 530                  $proplist = explode('-', $prop, 2);
 531  
 532                  // The value of proplist[0] can be falsey, so just check if not set.
 533                  if (empty($obj) || !isset($proplist[0])) {
 534                      return false;
 535                  }
 536  
 537                  if (preg_match('/^(.*)\[([0-9]*)\]$/', $proplist[0], $matches)
 538                          && count($matches) == 3) {
 539                      $property = $matches[1];
 540                      $index = $matches[2];
 541                      $obj = $obj->{$property}[$index] ?? null;
 542                  } else if (!empty($obj->{$proplist[0]})) {
 543                      $obj = $obj->{$proplist[0]};
 544                  } else if (is_array($obj) && !empty($obj[$proplist[0]])) {
 545                      $obj = $obj[$proplist[0]];
 546                  } else {
 547                      // Nothing found after checking all possible valid combinations, return false.
 548                      return false;
 549                  }
 550  
 551                  if (count($proplist) > 1) {
 552                      return $getfunc($obj, $proplist[1]);
 553                  }
 554                  return $obj;
 555              };
 556  
 557              $resolved = $getfunc($userinfo, $openidproperty);
 558              if (!empty($resolved)) {
 559                  $user->$moodleproperty = $resolved;
 560              }
 561          }
 562  
 563          if (empty($user->username) && !empty($user->email)) {
 564              $user->username = $user->email;
 565          }
 566  
 567          if (!empty($user->picture)) {
 568              $user->picture = download_file_content($user->picture, null, null, false, 10, 10, true, null, false);
 569          } else {
 570              $pictureurl = $this->issuer->get_endpoint_url('userpicture');
 571              if (!empty($pictureurl)) {
 572                  $user->picture = $this->get($pictureurl);
 573              }
 574          }
 575  
 576          if (!empty($user->picture)) {
 577              // If it doesn't look like a picture lets unset it.
 578              if (function_exists('imagecreatefromstring')) {
 579                  $img = @imagecreatefromstring($user->picture);
 580                  if (empty($img)) {
 581                      unset($user->picture);
 582                  } else {
 583                      imagedestroy($img);
 584                  }
 585              }
 586          }
 587  
 588          return (array)$user;
 589      }
 590  }