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 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body