Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 3.10.x will end 8 November 2021 (12 months).
  • Bug fixes for security issues in 3.10.x will end 9 May 2022 (18 months).
  • PHP version: minimum PHP 7.2.0 Note: minimum PHP version has increased since Moodle 3.8. PHP 7.3.x and 7.4.x are supported too.

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

   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              throw new moodle_exception('Could not upgrade oauth token');
 407          }
 408  
 409          $r = json_decode($response);
 410  
 411          if (!empty($r->error)) {
 412              throw new moodle_exception($r->error . ' ' . $r->error_description);
 413          }
 414  
 415          if (!isset($r->access_token)) {
 416              return null;
 417          }
 418  
 419          // Store the token an expiry time.
 420          $accesstoken = new stdClass();
 421          $accesstoken->token = $r->access_token;
 422          if (isset($r->expires_in)) {
 423              // Expires 10 seconds before actual expiry.
 424              $accesstoken->expires = (time() + ($r->expires_in - 10));
 425          }
 426          $accesstoken->scope = $this->scope;
 427  
 428          $tokens = ['access_token' => $accesstoken];
 429  
 430          if (isset($r->refresh_token)) {
 431              $this->refreshtoken = $r->refresh_token;
 432              $newrefreshtoken = new stdClass();
 433              $newrefreshtoken->token = $this->refreshtoken;
 434              $newrefreshtoken->scope = $this->scope;
 435              $tokens['refresh_token'] = $newrefreshtoken;
 436          }
 437  
 438          return $tokens;
 439      }
 440  
 441      /**
 442       * Override which, in addition to deleting access tokens, also deletes any stored refresh token.
 443       */
 444      public function log_out() {
 445          global $DB, $USER;
 446          parent::log_out();
 447          if (!$this->can_autorefresh()) {
 448              return;
 449          }
 450  
 451          // For clients supporting autorefresh, delete the stored refresh token too.
 452          $issuerid = $this->issuer->get('id');
 453          $refreshtoken = $DB->get_record('oauth2_refresh_token', ['userid' => $USER->id, 'issuerid' => $issuerid,
 454              'scopehash' => sha1($this->scope)]);
 455          if ($refreshtoken) {
 456              $DB->delete_records('oauth2_refresh_token', ['id' => $refreshtoken->id]);
 457          }
 458      }
 459  
 460      /**
 461       * Upgrade a refresh token from oauth 2.0 to an access token, for system clients only.
 462       *
 463       * @param \core\oauth2\system_account $systemaccount
 464       * @return boolean true if token is upgraded succesfully
 465       */
 466      public function upgrade_refresh_token(system_account $systemaccount) {
 467          $receivedtokens = $this->exchange_refresh_token($systemaccount->get('refreshtoken'));
 468  
 469          // No access token received, so return false.
 470          if (empty($receivedtokens)) {
 471              return false;
 472          }
 473  
 474          // Store the access token and, if provided by the server, the new refresh token.
 475          $this->store_token($receivedtokens['access_token']);
 476          if (isset($receivedtokens['refresh_token'])) {
 477              $systemaccount->set('refreshtoken', $receivedtokens['refresh_token']->token);
 478              $systemaccount->update();
 479          }
 480  
 481          return true;
 482      }
 483  
 484      /**
 485       * Fetch the user info from the user info endpoint and map all
 486       * the fields back into moodle fields.
 487       *
 488       * @return array|false Moodle user fields for the logged in user (or false if request failed)
 489       */
 490      public function get_userinfo() {
 491          $url = $this->get_issuer()->get_endpoint_url('userinfo');
 492          $response = $this->get($url);
 493          if (!$response) {
 494              return false;
 495          }
 496          $userinfo = new stdClass();
 497          try {
 498              $userinfo = json_decode($response);
 499          } catch (\Exception $e) {
 500              return false;
 501          }
 502  
 503          return $this->map_userinfo_to_fields($userinfo);
 504      }
 505  
 506      /**
 507       * Maps the oauth2 response to userfields.
 508       *
 509       * @param stdClass $userinfo
 510       * @return array
 511       */
 512      protected function map_userinfo_to_fields(stdClass $userinfo): array {
 513          $map = $this->get_userinfo_mapping();
 514  
 515          $user = new stdClass();
 516          foreach ($map as $openidproperty => $moodleproperty) {
 517              // We support nested objects via a-b-c syntax.
 518              $getfunc = function($obj, $prop) use (&$getfunc) {
 519                  $proplist = explode('-', $prop, 2);
 520  
 521                  // The value of proplist[0] can be falsey, so just check if not set.
 522                  if (empty($obj) || !isset($proplist[0])) {
 523                      return false;
 524                  }
 525  
 526                  if (preg_match('/^(.*)\[([0-9]*)\]$/', $proplist[0], $matches)
 527                          && count($matches) == 3) {
 528                      $property = $matches[1];
 529                      $index = $matches[2];
 530                      $obj = $obj->{$property}[$index] ?? null;
 531                  } else if (!empty($obj->{$proplist[0]})) {
 532                      $obj = $obj->{$proplist[0]};
 533                  } else if (is_array($obj) && !empty($obj[$proplist[0]])) {
 534                      $obj = $obj[$proplist[0]];
 535                  } else {
 536                      // Nothing found after checking all possible valid combinations, return false.
 537                      return false;
 538                  }
 539  
 540                  if (count($proplist) > 1) {
 541                      return $getfunc($obj, $proplist[1]);
 542                  }
 543                  return $obj;
 544              };
 545  
 546              $resolved = $getfunc($userinfo, $openidproperty);
 547              if (!empty($resolved)) {
 548                  $user->$moodleproperty = $resolved;
 549              }
 550          }
 551  
 552          if (empty($user->username) && !empty($user->email)) {
 553              $user->username = $user->email;
 554          }
 555  
 556          if (!empty($user->picture)) {
 557              $user->picture = download_file_content($user->picture, null, null, false, 10, 10, true, null, false);
 558          } else {
 559              $pictureurl = $this->issuer->get_endpoint_url('userpicture');
 560              if (!empty($pictureurl)) {
 561                  $user->picture = $this->get($pictureurl);
 562              }
 563          }
 564  
 565          if (!empty($user->picture)) {
 566              // If it doesn't look like a picture lets unset it.
 567              if (function_exists('imagecreatefromstring')) {
 568                  $img = @imagecreatefromstring($user->picture);
 569                  if (empty($img)) {
 570                      unset($user->picture);
 571                  } else {
 572                      imagedestroy($img);
 573                  }
 574              }
 575          }
 576  
 577          return (array)$user;
 578      }
 579  }