See Release Notes
Long Term Support Release
Differences Between: [Versions 310 and 401] [Versions 311 and 401] [Versions 39 and 401] [Versions 400 and 401]
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 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body