Search moodle.org's
Developer Documentation

See Release Notes

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

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

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