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.
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.

/**
 * Configurable oauth2 client class.
 *
 * @package    core
 * @copyright  2017 Damyon Wiese
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */
namespace core\oauth2;

defined('MOODLE_INTERNAL') || die();

require_once($CFG->libdir . '/oauthlib.php');
require_once($CFG->libdir . '/filelib.php');

use moodle_url;
use moodle_exception;
use stdClass;

/**
 * Configurable oauth2 client class. URLs come from DB and access tokens from either DB (system accounts) or session (users').
 *
 * @copyright  2017 Damyon Wiese
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */
class client extends \oauth2_client {

    /** @var \core\oauth2\issuer $issuer */
    private $issuer;

    /** @var bool $system */
    protected $system = false;

> /** @var bool $autorefresh whether this client will use a refresh token to automatically renew access tokens.*/ /** > protected $autorefresh = false; * Constructor. > * > /** @var array $rawuserinfo Keep rawuserinfo from . */ * @param issuer $issuer > protected $rawuserinfo = []; * @param moodle_url|null $returnurl >
* @param string $scopesrequired * @param boolean $system
> * @param boolean $autorefresh whether refresh_token grants are used to allow continued access across sessions.
*/
< public function __construct(issuer $issuer, $returnurl, $scopesrequired, $system = false) {
> public function __construct(issuer $issuer, $returnurl, $scopesrequired, $system = false, $autorefresh = false) {
$this->issuer = $issuer; $this->system = $system;
> $this->autorefresh = $autorefresh;
$scopes = $this->get_login_scopes(); $additionalscopes = explode(' ', $scopesrequired); foreach ($additionalscopes as $scope) { if (!empty($scope)) { if (strpos(' ' . $scopes . ' ', ' ' . $scope . ' ') === false) { $scopes .= ' ' . $scope; } } } if (empty($returnurl)) { $returnurl = new moodle_url('/'); } $this->basicauth = $issuer->get('basicauth'); parent::__construct($issuer->get('clientid'), $issuer->get('clientsecret'), $returnurl, $scopes); } /** * Returns the auth url for OAuth 2.0 request * @return string the auth url */ protected function auth_url() { return $this->issuer->get_endpoint_url('authorization'); } /** * Get the oauth2 issuer for this client. * * @return \core\oauth2\issuer Issuer */ public function get_issuer() { return $this->issuer; } /** * Override to append additional params to a authentication request. * * @return array (name value pairs). */ public function get_additional_login_parameters() { $params = '';
< if ($this->system) {
> > if ($this->system || $this->can_autorefresh()) { > // System clients and clients supporting the refresh_token grant (provided the user is authenticated) add > // extra params to the login request, depending on the issuer settings. The extra params allow a refresh > // token to be returned during the authorization_code flow.
if (!empty($this->issuer->get('loginparamsoffline'))) { $params = $this->issuer->get('loginparamsoffline'); } } else {
> // This is not a system client, nor a client supporting the refresh_token grant type, so just return the if (!empty($this->issuer->get('loginparams'))) { > // vanilla login params.
$params = $this->issuer->get('loginparams'); } }
>
if (empty($params)) { return []; } $result = []; parse_str($params, $result); return $result; } /** * Override to change the scopes requested with an authentiction request. * * @return string */ protected function get_login_scopes() {
< if ($this->system) {
> if ($this->system || $this->can_autorefresh()) { > // System clients and clients supporting the refresh_token grant (provided the user is authenticated) add > // extra scopes to the login request, depending on the issuer settings. The extra params allow a refresh > // token to be returned during the authorization_code flow.
return $this->issuer->get('loginscopesoffline'); } else {
> // This is not a system client, nor a client supporting the refresh_token grant type, so just return the return $this->issuer->get('loginscopes'); > // vanilla login scopes.
} } /** * Returns the token url for OAuth 2.0 request * * We are overriding the parent function so we get this from the configured endpoint. * * @return string the auth url */ protected function token_url() { return $this->issuer->get_endpoint_url('token'); } /** * We want a unique key for each issuer / and a different key for system vs user oauth. * * @return string The unique key for the session value. */ protected function get_tokenname() { $name = 'oauth2-state-' . $this->issuer->get('id'); if ($this->system) { $name .= '-system'; } return $name; } /** * Store a token between requests. Uses session named by get_tokenname for user account tokens * and a database record for system account tokens. * * @param stdClass|null $token token object to store or null to clear */ protected function store_token($token) { if (!$this->system) { parent::store_token($token); return; } $this->accesstoken = $token; // Create or update a DB record with the new token. $persistedtoken = access_token::get_record(['issuerid' => $this->issuer->get('id')]); if ($token !== null) { if (!$persistedtoken) { $persistedtoken = new access_token(); $persistedtoken->set('issuerid', $this->issuer->get('id')); } // Update values from $token. Don't use from_record because that would skip validation. $persistedtoken->set('token', $token->token); if (isset($token->expires)) { $persistedtoken->set('expires', $token->expires); } else { // Assume an arbitrary time span of 1 week for access tokens without expiration. // The "refresh_system_tokens_task" is run hourly (by default), so the token probably won't last that long. $persistedtoken->set('expires', time() + WEEKSECS); } $persistedtoken->set('scope', $token->scope); $persistedtoken->save(); } else { if ($persistedtoken) { $persistedtoken->delete(); } } } /** * Retrieve a stored token from session (user accounts) or database (system accounts). * * @return stdClass|null token object */ protected function get_stored_token() { if ($this->system) { $token = access_token::get_record(['issuerid' => $this->issuer->get('id')]); if ($token !== false) { return $token->to_record(); } return null; } return parent::get_stored_token(); } /** * Get a list of the mapping user fields in an associative array. * * @return array */ protected function get_userinfo_mapping() { $fields = user_field_mapping::get_records(['issuerid' => $this->issuer->get('id')]); $map = []; foreach ($fields as $field) { $map[$field->get('externalfield')] = $field->get('internalfield'); } return $map; } /**
< * Upgrade a refresh token from oauth 2.0 to an access token
> * Override which upgrades the authorization code to an access token and stores any refresh token in the DB.
*
< * @param \core\oauth2\system_account $systemaccount < * @return boolean true if token is upgraded succesfully < * @throws moodle_exception Request for token upgrade failed for technical reasons
> * @param string $code the authorisation code > * @return bool true if the token could be upgraded > * @throws moodle_exception
*/
< public function upgrade_refresh_token(system_account $systemaccount) { < $refreshtoken = $systemaccount->get('refreshtoken');
> public function upgrade_token($code) { > $upgraded = parent::upgrade_token($code); > if (!$this->can_autorefresh()) { > return $upgraded; > } > > // For clients supporting auto-refresh, try to store a refresh token. > if (!empty($this->refreshtoken)) { > $refreshtoken = (object) [ > 'token' => $this->refreshtoken, > 'scope' => $this->scope > ]; > $this->store_user_refresh_token($refreshtoken); > } > > return $upgraded; > }
> /** $params = array('refresh_token' => $refreshtoken, > * Override which in addition to auth code upgrade, also attempts to exchange a refresh token for an access token. 'grant_type' => 'refresh_token' > * ); > * @return bool true if the user is logged in as a result, false otherwise. > */ if ($this->basicauth) { > public function is_logged_in() { $idsecret = urlencode($this->issuer->get('clientid')) . ':' . urlencode($this->issuer->get('clientsecret')); > global $DB, $USER; $this->setHeader('Authorization: Basic ' . base64_encode($idsecret)); > } else { > $isloggedin = parent::is_logged_in(); $params['client_id'] = $this->issuer->get('clientid'); > $params['client_secret'] = $this->issuer->get('clientsecret'); > // Attempt to exchange a user refresh token, but only if required and supported. } > if ($isloggedin || !$this->can_autorefresh()) { > return $isloggedin; // Requests can either use http GET or POST. > } if ($this->use_http_get()) { > $response = $this->get($this->token_url(), $params); > // Autorefresh is supported. Try to negotiate a login by exchanging a stored refresh token for an access token. } else { > $issuerid = $this->issuer->get('id'); $response = $this->post($this->token_url(), $this->build_post_data($params)); > $refreshtoken = $DB->get_record('oauth2_refresh_token', ['userid' => $USER->id, 'issuerid' => $issuerid]); } > if ($refreshtoken) { > try { if ($this->info['http_code'] !== 200) { > $tokensreceived = $this->exchange_refresh_token($refreshtoken->token); throw new moodle_exception('Could not upgrade oauth token'); > if (empty($tokensreceived)) { } > // No access token was returned, so invalidate the refresh token and return false. > $DB->delete_records('oauth2_refresh_token', ['id' => $refreshtoken->id]); $r = json_decode($response); > return false; > } if (!empty($r->error)) { > throw new moodle_exception($r->error . ' ' . $r->error_description); > // Otherwise, save the access token and, if provided, the new refresh token. } > $this->store_token($tokensreceived['access_token']); > if (!empty($tokensreceived['refresh_token'])) { if (!isset($r->access_token)) { > $this->store_user_refresh_token($tokensreceived['refresh_token']); return false; > } } > return true; > } catch (\moodle_exception $e) { // Store the token an expiry time. > // The refresh attempt failed either due to an error or a bad request. A bad request could be received $accesstoken = new stdClass; > // for a number of reasons including expired refresh token (lifetime is not specified in OAuth 2 spec), $accesstoken->token = $r->access_token; > // scope change or if app access has been revoked manually by the user (tokens revoked). if (isset($r->expires_in)) { > // Remove the refresh token and suppress the exception, allowing the user to be taken through the // Expires 10 seconds before actual expiry. > // authorization_code flow again. $accesstoken->expires = (time() + ($r->expires_in - 10)); > $DB->delete_records('oauth2_refresh_token', ['id' => $refreshtoken->id]); } > } $accesstoken->scope = $this->scope; > } // Also add the scopes. > $this->store_token($accesstoken); > return false; > } if (isset($r->refresh_token)) { > $systemaccount->set('refreshtoken', $r->refresh_token); > /** $systemaccount->update(); > * Whether this client should automatically exchange a refresh token for an access token as part of login checks. $this->refreshtoken = $r->refresh_token; > * } > * @return bool true if supported, false otherwise. > */ return true; > protected function can_autorefresh(): bool { } > global $USER; > /** > // Auto refresh is only supported when the follow criteria are met: * Fetch the user info from the user info endpoint and map all > // a) The client is not a system client. The exchange process for system client refresh tokens is handled * the fields back into moodle fields. > // externally, via a call to client->upgrade_refresh_token(). * > // b) The user is authenticated. * @return array|false Moodle user fields for the logged in user (or false if request failed) > // c) The client has been configured with autorefresh enabled. */ > return !$this->system && ($this->autorefresh && !empty($USER->id)); public function get_userinfo() { > } $url = $this->get_issuer()->get_endpoint_url('userinfo'); > $response = $this->get($url); > /** if (!$response) { > * Store the user's refresh token for later use. return false; > * } > * @param stdClass $token a refresh token. $userinfo = new stdClass(); > */ try { > protected function store_user_refresh_token(stdClass $token): void { $userinfo = json_decode($response); > global $DB, $USER; } catch (\Exception $e) { > return false; > $id = $DB->get_field('oauth2_refresh_token', 'id', ['userid' => $USER->id, } > 'scopehash' => sha1($token->scope), 'issuerid' => $this->issuer->get('id')]); > $time = time(); $map = $this->get_userinfo_mapping(); > if ($id) { > $record = [ $user = new stdClass(); > 'id' => $id, foreach ($map as $openidproperty => $moodleproperty) { > 'timemodified' => $time, // We support nested objects via a-b-c syntax. > 'token' => $token->token $getfunc = function($obj, $prop) use (&$getfunc) { > ]; $proplist = explode('-', $prop, 2); > $DB->update_record('oauth2_refresh_token', $record); if (empty($proplist[0]) || empty($obj->{$proplist[0]})) { > } else { return false; > $record = [ } > 'timecreated' => $time, $obj = $obj->{$proplist[0]}; > 'timemodified' => $time, > 'userid' => $USER->id, if (count($proplist) > 1) { > 'issuerid' => $this->issuer->get('id'), return $getfunc($obj, $proplist[1]); > 'token' => $token->token, } > 'scopehash' => sha1($token->scope) return $obj; > ]; }; > $DB->insert_record('oauth2_refresh_token', $record); > } $resolved = $getfunc($userinfo, $openidproperty); > } if (!empty($resolved)) { > $user->$moodleproperty = $resolved; > /** } > * Attempt to exchange a refresh token for a new access token. } > * > * If successful, will return an array of token objects in the form: if (empty($user->username) && !empty($user->email)) { > * Array $user->username = $user->email; > * ( } > * [access_token] => stdClass object > * ( if (!empty($user->picture)) { > * [token] => 'the_token_string' $user->picture = download_file_content($user->picture, null, null, false, 10, 10, true, null, false); > * [expires] => 123456789 } else { > * [scope] => 'openid files etc' $pictureurl = $this->issuer->get_endpoint_url('userpicture'); > * ) if (!empty($pictureurl)) { > * [refresh_token] => stdClass object $user->picture = $this->get($pictureurl); > * ( } > * [token] => 'the_refresh_token_string' } > * [scope] => 'openid files etc' > * ) if (!empty($user->picture)) { > * ) // If it doesn't look like a picture lets unset it. > * where the 'refresh_token' will only be provided if supplied by the auth server in the response. if (function_exists('imagecreatefromstring')) { > * $img = @imagecreatefromstring($user->picture); > * @param string $refreshtoken the refresh token to exchange. if (empty($img)) { > * @return null|array array containing access token and refresh token if provided, null if the exchange was denied. unset($user->picture); > * @throws moodle_exception if an invalid response is received or if the response contains errors. } else { > */ imagedestroy($img); > protected function exchange_refresh_token(string $refreshtoken): ?array {
< throw new moodle_exception('Could not upgrade oauth token');
> $debuginfo = !empty($this->error) ? $this->error : $response; > throw new moodle_exception('oauth2refreshtokenerror', 'core_error', '', $this->info['http_code'], $debuginfo);
< return false;
> return null;
< $accesstoken = new stdClass;
> $accesstoken = new stdClass();
< // Also add the scopes. < $this->store_token($accesstoken);
> > $tokens = ['access_token' => $accesstoken];
< $systemaccount->set('refreshtoken', $r->refresh_token); < $systemaccount->update();
> $newrefreshtoken = new stdClass(); > $newrefreshtoken->token = $this->refreshtoken; > $newrefreshtoken->scope = $this->scope; > $tokens['refresh_token'] = $newrefreshtoken; > } > > return $tokens; > } > > /** > * Override which, in addition to deleting access tokens, also deletes any stored refresh token. > */ > public function log_out() { > global $DB, $USER; > parent::log_out(); > if (!$this->can_autorefresh()) { > return; > } > > // For clients supporting autorefresh, delete the stored refresh token too. > $issuerid = $this->issuer->get('id'); > $refreshtoken = $DB->get_record('oauth2_refresh_token', ['userid' => $USER->id, 'issuerid' => $issuerid, > 'scopehash' => sha1($this->scope)]); > if ($refreshtoken) { > $DB->delete_records('oauth2_refresh_token', ['id' => $refreshtoken->id]); > } > } > > /** > * Upgrade a refresh token from oauth 2.0 to an access token, for system clients only. > * > * @param \core\oauth2\system_account $systemaccount > * @return boolean true if token is upgraded succesfully > */ > public function upgrade_refresh_token(system_account $systemaccount) { > $receivedtokens = $this->exchange_refresh_token($systemaccount->get('refreshtoken')); > > // No access token received, so return false. > if (empty($receivedtokens)) { > return false; > } > > // Store the access token and, if provided by the server, the new refresh token. > $this->store_token($receivedtokens['access_token']); > if (isset($receivedtokens['refresh_token'])) { > $systemaccount->set('refreshtoken', $receivedtokens['refresh_token']->token); > $systemaccount->update();
< * Fetch the user info from the user info endpoint and map all < * the fields back into moodle fields.
> * Fetch the user info from the user info endpoint.
< * @return array|false Moodle user fields for the logged in user (or false if request failed)
> * @return stdClass|false Moodle user fields for the logged in user (or false if request failed) > * @throws moodle_exception if the response is empty after decoding it.
< public function get_userinfo() {
> public function get_raw_userinfo() { > if (!empty($this->rawuserinfo)) { > return $this->rawuserinfo; > }
> if (empty($url)) { > return false; > } >
> if (is_null($userinfo)) { > // Throw an exception displaying the original response, because, at this point, $userinfo shouldn't be empty. > throw new moodle_exception($response); > } > $this->rawuserinfo = $userinfo; > return $userinfo; > } > > /** > * Fetch the user info from the user info endpoint and map all > * the fields back into moodle fields. > * > * @return stdClass|false Moodle user fields for the logged in user (or false if request failed) > * @throws moodle_exception if the response is empty after decoding it. > */ > public function get_userinfo() { > $userinfo = $this->get_raw_userinfo(); > if ($userinfo === false) { > return false; > } > > return $this->map_userinfo_to_fields($userinfo); > } > > /** > * Maps the oauth2 response to userfields. > * > * @param stdClass $userinfo > * @return array > */ > protected function map_userinfo_to_fields(stdClass $userinfo): array {
< if (empty($proplist[0]) || empty($obj->{$proplist[0]})) {
> > // The value of proplist[0] can be falsey, so just check if not set. > if (empty($obj) || !isset($proplist[0])) {
> > if (preg_match('/^(.*)\[([0-9]*)\]$/', $proplist[0], $matches) > && count($matches) == 3) { > $property = $matches[1]; > $index = $matches[2]; > $obj = $obj->{$property}[$index] ?? null; > } else if (!empty($obj->{$proplist[0]})) {
> } else if (is_array($obj) && !empty($obj[$proplist[0]])) { > $obj = $obj[$proplist[0]]; > } else { > // Nothing found after checking all possible valid combinations, return false. > return false; > }