From 6502c13b4f61472826dceb882b4ec622dc3c7ce1 Mon Sep 17 00:00:00 2001 From: Jake Dallimore Date: Wed, 16 Sep 2020 10:50:48 +0800 Subject: [PATCH 1/4] MDL-59510 core_oauth2: add oauth2_refresh_token table --- lib/db/install.xml | 19 +++++++++++++++++++ lib/db/upgrade.php | 30 ++++++++++++++++++++++++++++++ version.php | 2 +- 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/lib/db/install.xml b/lib/db/install.xml index 0643897ce64..a78c07b9b99 100644 --- a/lib/db/install.xml +++ b/lib/db/install.xml @@ -4300,5 +4300,24 @@ + + + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/lib/db/upgrade.php b/lib/db/upgrade.php index 41b2f5c5967..2a27ce60697 100644 --- a/lib/db/upgrade.php +++ b/lib/db/upgrade.php @@ -2708,5 +2708,35 @@ function xmldb_main_upgrade($oldversion) { upgrade_main_savepoint(true, 2021052500.15); } + if ($oldversion < 2021052500.19) { + // Define table oauth2_refresh_token to be created. + $table = new xmldb_table('oauth2_refresh_token'); + + // Adding fields to table oauth2_refresh_token. + $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null); + $table->add_field('timecreated', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null); + $table->add_field('timemodified', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null); + $table->add_field('userid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null); + $table->add_field('issuerid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null); + $table->add_field('token', XMLDB_TYPE_TEXT, null, null, XMLDB_NOTNULL, null, null); + $table->add_field('scopehash', XMLDB_TYPE_CHAR, 40, null, XMLDB_NOTNULL, null, null); + + // Adding keys to table oauth2_refresh_token. + $table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']); + $table->add_key('issueridkey', XMLDB_KEY_FOREIGN, ['issuerid'], 'oauth2_issuer', ['id']); + $table->add_key('useridkey', XMLDB_KEY_FOREIGN, ['userid'], 'user', ['id']); + + // Adding indexes to table oauth2_refresh_token. + $table->add_index('userid-issuerid-scopehash', XMLDB_INDEX_UNIQUE, array('userid', 'issuerid', 'scopehash')); + + // Conditionally launch create table for oauth2_refresh_token. + if (!$dbman->table_exists($table)) { + $dbman->create_table($table); + } + + // Main savepoint reached. + upgrade_main_savepoint(true, 2021052500.19); + } + return true; } diff --git a/version.php b/version.php index 6f90f7172e8..c18016b3e90 100644 --- a/version.php +++ b/version.php @@ -29,7 +29,7 @@ defined('MOODLE_INTERNAL') || die(); -$version = 2021052500.18; // YYYYMMDD = weekly release date of this DEV branch. +$version = 2021052500.19; // YYYYMMDD = weekly release date of this DEV branch. // RR = release increments - 00 in DEV branches. // .XX = incremental changes. $release = '4.0dev (Build: 20201002)'; // Human-friendly version name From 01320374271f886b18848b2b681cd81483d017dc Mon Sep 17 00:00:00 2001 From: Jake Dallimore Date: Wed, 16 Sep 2020 15:11:42 +0800 Subject: [PATCH 2/4] MDL-59510 core_oauth2: add autorefresh mode to oauth2\client Changes: - The client can now be instantiated with the new mode enabled. - A user refresh token will be stored if returned as part of the authorization_code grant but only if the user is authenticated and only if the mode is enabled. - If the mode permits it, attempt to exchange a stored refresh token for a new access token during login checks, allowing access to continue across sessions without the need to re-grant consent every time. - Purge all refresh tokens for a given user, issuer and scope on client log out. This makes sure logout persists across logins. --- lib/classes/oauth2/api.php | 6 +- lib/classes/oauth2/client.php | 225 +++++++++++++++++++++++++++++++--- lib/moodlelib.php | 3 + 3 files changed, 217 insertions(+), 17 deletions(-) diff --git a/lib/classes/oauth2/api.php b/lib/classes/oauth2/api.php index 95d69a8e730..165dc40afe9 100644 --- a/lib/classes/oauth2/api.php +++ b/lib/classes/oauth2/api.php @@ -484,10 +484,12 @@ class api { * @param \core\oauth2\issuer $issuer The desired OAuth issuer * @param moodle_url $currenturl The url to the current page. * @param string $additionalscopes The additional scopes required for authorization. + * @param bool $autorefresh Should the client support the use of refresh tokens to persist access across sessions. * @return \core\oauth2\client */ - public static function get_user_oauth_client(issuer $issuer, moodle_url $currenturl, $additionalscopes = '') { - $client = new \core\oauth2\client($issuer, $currenturl, $additionalscopes); + public static function get_user_oauth_client(issuer $issuer, moodle_url $currenturl, $additionalscopes = '', + $autorefresh = false) { + $client = new \core\oauth2\client($issuer, $currenturl, $additionalscopes, false, $autorefresh); return $client; } diff --git a/lib/classes/oauth2/client.php b/lib/classes/oauth2/client.php index 553e8fe3129..574ea943438 100644 --- a/lib/classes/oauth2/client.php +++ b/lib/classes/oauth2/client.php @@ -46,6 +46,9 @@ class client extends \oauth2_client { /** @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. * @@ -53,10 +56,12 @@ class client extends \oauth2_client { * @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); @@ -98,15 +103,22 @@ class client extends \oauth2_client { */ 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 + // vanilla login params. if (!empty($this->issuer->get('loginparams'))) { $params = $this->issuer->get('loginparams'); } } + if (empty($params)) { return []; } @@ -121,9 +133,14 @@ class client extends \oauth2_client { * @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 + // vanilla login scopes. return $this->issuer->get('loginscopes'); } } @@ -224,15 +241,148 @@ class client extends \oauth2_client { } /** - * 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; + } + + /** + * Override which in addition to auth code upgrade, also attempts to exchange a refresh token for an access token. + * + * @return bool true if the user is logged in as a result, false otherwise. + */ + public function is_logged_in() { + global $DB, $USER; + + $isloggedin = parent::is_logged_in(); + + // Attempt to exchange a user refresh token, but only if required and supported. + if ($isloggedin || !$this->can_autorefresh()) { + return $isloggedin; + } + + // Autorefresh is supported. Try to negotiate a login by exchanging a stored refresh token for an access token. + $issuerid = $this->issuer->get('id'); + $refreshtoken = $DB->get_record('oauth2_refresh_token', ['userid' => $USER->id, 'issuerid' => $issuerid]); + if ($refreshtoken) { + try { + $tokensreceived = $this->exchange_refresh_token($refreshtoken->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]); + return false; + } + + // Otherwise, save the access token and, if provided, the new refresh token. + $this->store_token($tokensreceived['access_token']); + if (!empty($tokensreceived['refresh_token'])) { + $this->store_user_refresh_token($tokensreceived['refresh_token']); + } + return true; + } catch (\moodle_exception $e) { + // The refresh attempt failed either due to an error or a bad request. A bad request could be received + // for a number of reasons including expired refresh token (lifetime is not specified in OAuth 2 spec), + // scope change or if app access has been revoked manually by the user (tokens revoked). + // Remove the refresh token and suppress the exception, allowing the user to be taken through the + // authorization_code flow again. + $DB->delete_records('oauth2_refresh_token', ['id' => $refreshtoken->id]); + } + } + + return false; + } + + /** + * Whether this client should automatically exchange a refresh token for an access token as part of login checks. + * + * @return bool true if supported, false otherwise. + */ + protected function can_autorefresh(): bool { + global $USER; + + // Auto refresh is only supported when the follow criteria are met: + // a) The client is not a system client. The exchange process for system client refresh tokens is handled + // externally, via a call to client->upgrade_refresh_token(). + // b) The user is authenticated. + // c) The client has been configured with autorefresh enabled. + return !$this->system && ($this->autorefresh && !empty($USER->id)); + } + + /** + * Store the user's refresh token for later use. + * + * @param stdClass $token a refresh token. + */ + protected function store_user_refresh_token(stdClass $token): void { + global $DB, $USER; + + $id = $DB->get_field('oauth2_refresh_token', 'id', ['userid' => $USER->id, + 'scopehash' => sha1($token->scope), 'issuerid' => $this->issuer->get('id')]); + $time = time(); + if ($id) { + $record = [ + 'id' => $id, + 'timemodified' => $time, + 'token' => $token->token + ]; + $DB->update_record('oauth2_refresh_token', $record); + } else { + $record = [ + 'timecreated' => $time, + 'timemodified' => $time, + 'userid' => $USER->id, + 'issuerid' => $this->issuer->get('id'), + 'token' => $token->token, + 'scopehash' => sha1($token->scope) + ]; + $DB->insert_record('oauth2_refresh_token', $record); + } + } + + /** + * Attempt to exchange a refresh token for a new access token. + * + * If successful, will return an array of token objects in the form: + * Array + * ( + * [access_token] => stdClass object + * ( + * [token] => 'the_token_string' + * [expires] => 123456789 + * [scope] => 'openid files etc' + * ) + * [refresh_token] => stdClass object + * ( + * [token] => 'the_refresh_token_string' + * [scope] => 'openid files etc' + * ) + * ) + * where the 'refresh_token' will only be provided if supplied by the auth server in the response. + * + * @param string $refreshtoken the refresh token to exchange. + * @return null|array array containing access token and refresh token if provided, null if the exchange was denied. + * @throws moodle_exception if an invalid response is received or if the response contains errors. + */ + protected function exchange_refresh_token(string $refreshtoken): ?array { $params = array('refresh_token' => $refreshtoken, 'grant_type' => 'refresh_token' ); @@ -263,24 +413,69 @@ class client extends \oauth2_client { } if (!isset($r->access_token)) { - return false; + return null; } // Store the token an expiry time. - $accesstoken = new stdClass; + $accesstoken = new stdClass(); $accesstoken->token = $r->access_token; if (isset($r->expires_in)) { // Expires 10 seconds before actual expiry. $accesstoken->expires = (time() + ($r->expires_in - 10)); } $accesstoken->scope = $this->scope; - // Also add the scopes. - $this->store_token($accesstoken); + + $tokens = ['access_token' => $accesstoken]; if (isset($r->refresh_token)) { - $systemaccount->set('refreshtoken', $r->refresh_token); - $systemaccount->update(); $this->refreshtoken = $r->refresh_token; + $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['refreshtoken'])) { + $systemaccount->set('refreshtoken', $receivedtokens['refresh_token']->token); + $systemaccount->update(); } return true; diff --git a/lib/moodlelib.php b/lib/moodlelib.php index 8afd063cdc8..8634bfb2382 100644 --- a/lib/moodlelib.php +++ b/lib/moodlelib.php @@ -4306,6 +4306,9 @@ function delete_user(stdClass $user) { // Remove users customised pages. $DB->delete_records('my_pages', array('userid' => $user->id, 'private' => 1)); + // Remove user's oauth2 refresh tokens, if present. + $DB->delete_records('oauth2_refresh_token', array('userid' => $user->id)); + // Delete user from $SESSION->bulk_users. if (isset($SESSION->bulk_users[$user->id])) { unset($SESSION->bulk_users[$user->id]); From f5046a5a1dbc6eeaf37e93806d5dd15cc3e3a32a Mon Sep 17 00:00:00 2001 From: Jake Dallimore Date: Tue, 22 Sep 2020 11:18:17 +0800 Subject: [PATCH 3/4] MDL-59510 core: report oauth2_refresh_token table in core provider --- lang/en/moodle.php | 7 +++++++ lib/classes/privacy/provider.php | 11 +++++++++++ 2 files changed, 18 insertions(+) diff --git a/lang/en/moodle.php b/lang/en/moodle.php index bf203b59069..41b2cee1ec0 100644 --- a/lang/en/moodle.php +++ b/lang/en/moodle.php @@ -1630,6 +1630,13 @@ $string['privacy:metadata:log:module'] = 'module'; $string['privacy:metadata:log:time'] = 'The time when the action took place'; $string['privacy:metadata:log:url'] = 'The URL related to the event'; $string['privacy:metadata:log:userid'] = 'The ID of the user who performed the action'; +$string['privacy:metadata:oauth2_refresh_token'] = 'Refresh token used in OAuth 2.0 communication'; +$string['privacy:metadata:oauth2_refresh_token:issuerid'] = 'The ID of the issuer to which the token corresponds'; +$string['privacy:metadata:oauth2_refresh_token:scopehash'] = 'The ID of the user to whom the token corresponds'; +$string['privacy:metadata:oauth2_refresh_token:token'] = 'The refresh token for the respective scopes and user'; +$string['privacy:metadata:oauth2_refresh_token:timecreated'] = 'The time when the token was created'; +$string['privacy:metadata:oauth2_refresh_token:timemodified'] = 'The time when the token was last updated'; +$string['privacy:metadata:oauth2_refresh_token:userid'] = 'The ID of the user to whom the token corresponds'; $string['privacy:metadata:task_adhoc'] = 'The status of ad hoc tasks.'; $string['privacy:metadata:task_adhoc:component'] = 'The component owning the task.'; $string['privacy:metadata:task_adhoc:nextruntime'] = 'The earliest time to run this task.'; diff --git a/lib/classes/privacy/provider.php b/lib/classes/privacy/provider.php index bc9b8f8cf78..e49598b8e55 100644 --- a/lib/classes/privacy/provider.php +++ b/lib/classes/privacy/provider.php @@ -114,6 +114,17 @@ class provider implements 'info' => 'privacy:metadata:log:info' ], 'privacy:metadata:log'); + // The oauth2_refresh_token stores refresh tokens, allowing ongoing access to select oauth2 services. + // Such tokens are not considered to be user data. + $collection->add_database_table('oauth2_refresh_token', [ + 'timecreated' => 'privacy:metadata:oauth2_refresh_token:timecreated', + 'timemodified' => 'privacy:metadata:oauth2_refresh_token:timemodified', + 'userid' => 'privacy:metadata:oauth2_refresh_token:userid', + 'issuerid' => 'privacy:metadata:oauth2_refresh_token:issuerid', + 'token' => 'privacy:metadata:oauth2_refresh_token:token', + 'scopehash' => 'privacy:metadata:oauth2_refresh_token:scopehash' + ], 'privacy:metadata:oauth2_refresh_token'); + return $collection; } From 255a910c92150eed2924a3578b1ef3678e3c09d8 Mon Sep 17 00:00:00 2001 From: Jake Dallimore Date: Fri, 18 Sep 2020 17:24:40 +0800 Subject: [PATCH 4/4] MDL-59510 core_oauth2: autorefresh google drive, nextcloud and onedrive Request additional offline access, allowing the repository to sign in automatically each time, including across different user sessions, until either the refresh token is invalidated or the user logs out of the repository. --- repository/googledocs/lib.php | 2 +- repository/nextcloud/lib.php | 2 +- repository/onedrive/lib.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/repository/googledocs/lib.php b/repository/googledocs/lib.php index 88e5c9a3f3c..0f62e8ea65c 100644 --- a/repository/googledocs/lib.php +++ b/repository/googledocs/lib.php @@ -97,7 +97,7 @@ class repository_googledocs extends repository { $returnurl->param('sesskey', sesskey()); } - $this->client = \core\oauth2\api::get_user_oauth_client($this->issuer, $returnurl, self::SCOPES); + $this->client = \core\oauth2\api::get_user_oauth_client($this->issuer, $returnurl, self::SCOPES, true); return $this->client; } diff --git a/repository/nextcloud/lib.php b/repository/nextcloud/lib.php index e2ad61f27c7..62f38c0a1e4 100644 --- a/repository/nextcloud/lib.php +++ b/repository/nextcloud/lib.php @@ -574,7 +574,7 @@ class repository_nextcloud extends repository { $returnurl->param('repo_id', $this->id); $returnurl->param('sesskey', sesskey()); } - $this->client = \core\oauth2\api::get_user_oauth_client($this->issuer, $returnurl, self::SCOPES); + $this->client = \core\oauth2\api::get_user_oauth_client($this->issuer, $returnurl, self::SCOPES, true); return $this->client; } diff --git a/repository/onedrive/lib.php b/repository/onedrive/lib.php index f03c8dcaa30..b2c6d807b2f 100644 --- a/repository/onedrive/lib.php +++ b/repository/onedrive/lib.php @@ -93,7 +93,7 @@ class repository_onedrive extends repository { $returnurl->param('sesskey', sesskey()); } - $this->client = \core\oauth2\api::get_user_oauth_client($this->issuer, $returnurl, self::SCOPES); + $this->client = \core\oauth2\api::get_user_oauth_client($this->issuer, $returnurl, self::SCOPES, true); return $this->client; }