Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 3.9.x will end* 10 May 2021 (12 months).
  • Bug fixes for security issues in 3.9.x will end* 8 May 2023 (36 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 39 and 310] [Versions 39 and 311] [Versions 39 and 400] [Versions 39 and 401] [Versions 39 and 402] [Versions 39 and 403]

   1  <?php
   2  // This file is part of Moodle - http://moodle.org/
   3  //
   4  // Moodle is free software: you can redistribute it and/or modify
   5  // it under the terms of the GNU General Public License as published by
   6  // the Free Software Foundation, either version 3 of the License, or
   7  // (at your option) any later version.
   8  //
   9  // Moodle is distributed in the hope that it will be useful,
  10  // but WITHOUT ANY WARRANTY; without even the implied warranty of
  11  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  12  // GNU General Public License for more details.
  13  //
  14  // You should have received a copy of the GNU General Public License
  15  // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
  16  
  17  /**
  18   * 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      /**
  50       * Constructor.
  51       *
  52       * @param issuer $issuer
  53       * @param moodle_url|null $returnurl
  54       * @param string $scopesrequired
  55       * @param boolean $system
  56       */
  57      public function __construct(issuer $issuer, $returnurl, $scopesrequired, $system = false) {
  58          $this->issuer = $issuer;
  59          $this->system = $system;
  60          $scopes = $this->get_login_scopes();
  61          $additionalscopes = explode(' ', $scopesrequired);
  62  
  63          foreach ($additionalscopes as $scope) {
  64              if (!empty($scope)) {
  65                  if (strpos(' ' . $scopes . ' ', ' ' . $scope . ' ') === false) {
  66                      $scopes .= ' ' . $scope;
  67                  }
  68              }
  69          }
  70          if (empty($returnurl)) {
  71              $returnurl = new moodle_url('/');
  72          }
  73          $this->basicauth = $issuer->get('basicauth');
  74          parent::__construct($issuer->get('clientid'), $issuer->get('clientsecret'), $returnurl, $scopes);
  75      }
  76  
  77      /**
  78       * Returns the auth url for OAuth 2.0 request
  79       * @return string the auth url
  80       */
  81      protected function auth_url() {
  82          return $this->issuer->get_endpoint_url('authorization');
  83      }
  84  
  85      /**
  86       * Get the oauth2 issuer for this client.
  87       *
  88       * @return \core\oauth2\issuer Issuer
  89       */
  90      public function get_issuer() {
  91          return $this->issuer;
  92      }
  93  
  94      /**
  95       * Override to append additional params to a authentication request.
  96       *
  97       * @return array (name value pairs).
  98       */
  99      public function get_additional_login_parameters() {
 100          $params = '';
 101          if ($this->system) {
 102              if (!empty($this->issuer->get('loginparamsoffline'))) {
 103                  $params = $this->issuer->get('loginparamsoffline');
 104              }
 105          } else {
 106              if (!empty($this->issuer->get('loginparams'))) {
 107                  $params = $this->issuer->get('loginparams');
 108              }
 109          }
 110          if (empty($params)) {
 111              return [];
 112          }
 113          $result = [];
 114          parse_str($params, $result);
 115          return $result;
 116      }
 117  
 118      /**
 119       * Override to change the scopes requested with an authentiction request.
 120       *
 121       * @return string
 122       */
 123      protected function get_login_scopes() {
 124          if ($this->system) {
 125              return $this->issuer->get('loginscopesoffline');
 126          } else {
 127              return $this->issuer->get('loginscopes');
 128          }
 129      }
 130  
 131      /**
 132       * Returns the token url for OAuth 2.0 request
 133       *
 134       * We are overriding the parent function so we get this from the configured endpoint.
 135       *
 136       * @return string the auth url
 137       */
 138      protected function token_url() {
 139          return $this->issuer->get_endpoint_url('token');
 140      }
 141  
 142      /**
 143       * We want a unique key for each issuer / and a different key for system vs user oauth.
 144       *
 145       * @return string The unique key for the session value.
 146       */
 147      protected function get_tokenname() {
 148          $name = 'oauth2-state-' . $this->issuer->get('id');
 149          if ($this->system) {
 150              $name .= '-system';
 151          }
 152          return $name;
 153      }
 154  
 155      /**
 156       * Store a token between requests. Uses session named by get_tokenname for user account tokens
 157       * and a database record for system account tokens.
 158       *
 159       * @param stdClass|null $token token object to store or null to clear
 160       */
 161      protected function store_token($token) {
 162          if (!$this->system) {
 163              parent::store_token($token);
 164              return;
 165          }
 166  
 167          $this->accesstoken = $token;
 168  
 169          // Create or update a DB record with the new token.
 170          $persistedtoken = access_token::get_record(['issuerid' => $this->issuer->get('id')]);
 171          if ($token !== null) {
 172              if (!$persistedtoken) {
 173                  $persistedtoken = new access_token();
 174                  $persistedtoken->set('issuerid', $this->issuer->get('id'));
 175              }
 176              // Update values from $token. Don't use from_record because that would skip validation.
 177              $persistedtoken->set('token', $token->token);
 178              if (isset($token->expires)) {
 179                  $persistedtoken->set('expires', $token->expires);
 180              } else {
 181                  // Assume an arbitrary time span of 1 week for access tokens without expiration.
 182                  // The "refresh_system_tokens_task" is run hourly (by default), so the token probably won't last that long.
 183                  $persistedtoken->set('expires', time() + WEEKSECS);
 184              }
 185              $persistedtoken->set('scope', $token->scope);
 186              $persistedtoken->save();
 187          } else {
 188              if ($persistedtoken) {
 189                  $persistedtoken->delete();
 190              }
 191          }
 192      }
 193  
 194      /**
 195       * Retrieve a stored token from session (user accounts) or database (system accounts).
 196       *
 197       * @return stdClass|null token object
 198       */
 199      protected function get_stored_token() {
 200          if ($this->system) {
 201              $token = access_token::get_record(['issuerid' => $this->issuer->get('id')]);
 202              if ($token !== false) {
 203                  return $token->to_record();
 204              }
 205              return null;
 206          }
 207  
 208          return parent::get_stored_token();
 209      }
 210  
 211      /**
 212       * Get a list of the mapping user fields in an associative array.
 213       *
 214       * @return array
 215       */
 216      protected function get_userinfo_mapping() {
 217          $fields = user_field_mapping::get_records(['issuerid' => $this->issuer->get('id')]);
 218  
 219          $map = [];
 220          foreach ($fields as $field) {
 221              $map[$field->get('externalfield')] = $field->get('internalfield');
 222          }
 223          return $map;
 224      }
 225  
 226      /**
 227       * Upgrade a refresh token from oauth 2.0 to an access token
 228       *
 229       * @param \core\oauth2\system_account $systemaccount
 230       * @return boolean true if token is upgraded succesfully
 231       * @throws moodle_exception Request for token upgrade failed for technical reasons
 232       */
 233      public function upgrade_refresh_token(system_account $systemaccount) {
 234          $refreshtoken = $systemaccount->get('refreshtoken');
 235  
 236          $params = array('refresh_token' => $refreshtoken,
 237              'grant_type' => 'refresh_token'
 238          );
 239  
 240          if ($this->basicauth) {
 241              $idsecret = urlencode($this->issuer->get('clientid')) . ':' . urlencode($this->issuer->get('clientsecret'));
 242              $this->setHeader('Authorization: Basic ' . base64_encode($idsecret));
 243          } else {
 244              $params['client_id'] = $this->issuer->get('clientid');
 245              $params['client_secret'] = $this->issuer->get('clientsecret');
 246          }
 247  
 248          // Requests can either use http GET or POST.
 249          if ($this->use_http_get()) {
 250              $response = $this->get($this->token_url(), $params);
 251          } else {
 252              $response = $this->post($this->token_url(), $this->build_post_data($params));
 253          }
 254  
 255          if ($this->info['http_code'] !== 200) {
 256              throw new moodle_exception('Could not upgrade oauth token');
 257          }
 258  
 259          $r = json_decode($response);
 260  
 261          if (!empty($r->error)) {
 262              throw new moodle_exception($r->error . ' ' . $r->error_description);
 263          }
 264  
 265          if (!isset($r->access_token)) {
 266              return false;
 267          }
 268  
 269          // Store the token an expiry time.
 270          $accesstoken = new stdClass;
 271          $accesstoken->token = $r->access_token;
 272          if (isset($r->expires_in)) {
 273              // Expires 10 seconds before actual expiry.
 274              $accesstoken->expires = (time() + ($r->expires_in - 10));
 275          }
 276          $accesstoken->scope = $this->scope;
 277          // Also add the scopes.
 278          $this->store_token($accesstoken);
 279  
 280          if (isset($r->refresh_token)) {
 281              $systemaccount->set('refreshtoken', $r->refresh_token);
 282              $systemaccount->update();
 283              $this->refreshtoken = $r->refresh_token;
 284          }
 285  
 286          return true;
 287      }
 288  
 289      /**
 290       * Fetch the user info from the user info endpoint and map all
 291       * the fields back into moodle fields.
 292       *
 293       * @return array|false Moodle user fields for the logged in user (or false if request failed)
 294       */
 295      public function get_userinfo() {
 296          $url = $this->get_issuer()->get_endpoint_url('userinfo');
 297          $response = $this->get($url);
 298          if (!$response) {
 299              return false;
 300          }
 301          $userinfo = new stdClass();
 302          try {
 303              $userinfo = json_decode($response);
 304          } catch (\Exception $e) {
 305              return false;
 306          }
 307  
 308          $map = $this->get_userinfo_mapping();
 309  
 310          $user = new stdClass();
 311          foreach ($map as $openidproperty => $moodleproperty) {
 312              // We support nested objects via a-b-c syntax.
 313              $getfunc = function($obj, $prop) use (&$getfunc) {
 314                  $proplist = explode('-', $prop, 2);
 315                  if (empty($proplist[0]) || empty($obj->{$proplist[0]})) {
 316                      return false;
 317                  }
 318                  $obj = $obj->{$proplist[0]};
 319  
 320                  if (count($proplist) > 1) {
 321                      return $getfunc($obj, $proplist[1]);
 322                  }
 323                  return $obj;
 324              };
 325  
 326              $resolved = $getfunc($userinfo, $openidproperty);
 327              if (!empty($resolved)) {
 328                  $user->$moodleproperty = $resolved;
 329              }
 330          }
 331  
 332          if (empty($user->username) && !empty($user->email)) {
 333              $user->username = $user->email;
 334          }
 335  
 336          if (!empty($user->picture)) {
 337              $user->picture = download_file_content($user->picture, null, null, false, 10, 10, true, null, false);
 338          } else {
 339              $pictureurl = $this->issuer->get_endpoint_url('userpicture');
 340              if (!empty($pictureurl)) {
 341                  $user->picture = $this->get($pictureurl);
 342              }
 343          }
 344  
 345          if (!empty($user->picture)) {
 346              // If it doesn't look like a picture lets unset it.
 347              if (function_exists('imagecreatefromstring')) {
 348                  $img = @imagecreatefromstring($user->picture);
 349                  if (empty($img)) {
 350                      unset($user->picture);
 351                  } else {
 352                      imagedestroy($img);
 353                  }
 354              }
 355          }
 356  
 357          return (array)$user;
 358      }
 359  }