MDL-59510 core_oauth2: add autorefresh mode to oauth2\client
[moodle.git] / lib / classes / oauth2 / client.php
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/>.
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;
26 defined('MOODLE_INTERNAL') || die();
28 require_once($CFG->libdir . '/oauthlib.php');
29 require_once($CFG->libdir . '/filelib.php');
31 use moodle_url;
32 use moodle_exception;
33 use stdClass;
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 {
43     /** @var \core\oauth2\issuer $issuer */
44     private $issuer;
46     /** @var bool $system */
47     protected $system = false;
49     /** @var bool $autorefresh whether this client will use a refresh token to automatically renew access tokens.*/
50     protected $autorefresh = false;
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);
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     }
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     }
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     }
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 = '';
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         }
122         if (empty($params)) {
123             return [];
124         }
125         $result = [];
126         parse_str($params, $result);
127         return $result;
128     }
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     }
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     }
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     }
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         }
184         $this->accesstoken = $token;
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     }
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         }
225         return parent::get_stored_token();
226     }
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')]);
236         $map = [];
237         foreach ($fields as $field) {
238             $map[$field->get('externalfield')] = $field->get('internalfield');
239         }
240         return $map;
241     }
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         }
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         }
265         return $upgraded;
266     }
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;
276         $isloggedin = parent::is_logged_in();
278         // Attempt to exchange a user refresh token, but only if required and supported.
279         if ($isloggedin || !$this->can_autorefresh()) {
280             return $isloggedin;
281         }
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                 }
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         }
311         return false;
312     }
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;
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     }
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;
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     }
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         );
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         }
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         }
405         if ($this->info['http_code'] !== 200) {
406             throw new moodle_exception('Could not upgrade oauth token');
407         }
409         $r = json_decode($response);
411         if (!empty($r->error)) {
412             throw new moodle_exception($r->error . ' ' . $r->error_description);
413         }
415         if (!isset($r->access_token)) {
416             return null;
417         }
419         // Store the token an expiry time.
420         $accesstoken = new stdClass();
421         $accesstoken->token = $r->access_token;
422         if (isset($r->expires_in)) {
423             // Expires 10 seconds before actual expiry.
424             $accesstoken->expires = (time() + ($r->expires_in - 10));
425         }
426         $accesstoken->scope = $this->scope;
428         $tokens = ['access_token' => $accesstoken];
430         if (isset($r->refresh_token)) {
431             $this->refreshtoken = $r->refresh_token;
432             $newrefreshtoken = new stdClass();
433             $newrefreshtoken->token = $this->refreshtoken;
434             $newrefreshtoken->scope = $this->scope;
435             $tokens['refresh_token'] = $newrefreshtoken;
436         }
438         return $tokens;
439     }
441     /**
442      * Override which, in addition to deleting access tokens, also deletes any stored refresh token.
443      */
444     public function log_out() {
445         global $DB, $USER;
446         parent::log_out();
447         if (!$this->can_autorefresh()) {
448             return;
449         }
451         // For clients supporting autorefresh, delete the stored refresh token too.
452         $issuerid = $this->issuer->get('id');
453         $refreshtoken = $DB->get_record('oauth2_refresh_token', ['userid' => $USER->id, 'issuerid' => $issuerid,
454             'scopehash' => sha1($this->scope)]);
455         if ($refreshtoken) {
456             $DB->delete_records('oauth2_refresh_token', ['id' => $refreshtoken->id]);
457         }
458     }
460     /**
461      * Upgrade a refresh token from oauth 2.0 to an access token, for system clients only.
462      *
463      * @param \core\oauth2\system_account $systemaccount
464      * @return boolean true if token is upgraded succesfully
465      */
466     public function upgrade_refresh_token(system_account $systemaccount) {
467         $receivedtokens = $this->exchange_refresh_token($systemaccount->get('refreshtoken'));
469         // No access token received, so return false.
470         if (empty($receivedtokens)) {
471             return false;
472         }
474         // Store the access token and, if provided by the server, the new refresh token.
475         $this->store_token($receivedtokens['access_token']);
476         if (isset($receivedtokens['refreshtoken'])) {
477             $systemaccount->set('refreshtoken', $receivedtokens['refresh_token']->token);
478             $systemaccount->update();
479         }
481         return true;
482     }
484     /**
485      * Fetch the user info from the user info endpoint and map all
486      * the fields back into moodle fields.
487      *
488      * @return array|false Moodle user fields for the logged in user (or false if request failed)
489      */
490     public function get_userinfo() {
491         $url = $this->get_issuer()->get_endpoint_url('userinfo');
492         $response = $this->get($url);
493         if (!$response) {
494             return false;
495         }
496         $userinfo = new stdClass();
497         try {
498             $userinfo = json_decode($response);
499         } catch (\Exception $e) {
500             return false;
501         }
503         $map = $this->get_userinfo_mapping();
505         $user = new stdClass();
506         foreach ($map as $openidproperty => $moodleproperty) {
507             // We support nested objects via a-b-c syntax.
508             $getfunc = function($obj, $prop) use (&$getfunc) {
509                 $proplist = explode('-', $prop, 2);
510                 if (empty($proplist[0]) || empty($obj->{$proplist[0]})) {
511                     return false;
512                 }
513                 $obj = $obj->{$proplist[0]};
515                 if (count($proplist) > 1) {
516                     return $getfunc($obj, $proplist[1]);
517                 }
518                 return $obj;
519             };
521             $resolved = $getfunc($userinfo, $openidproperty);
522             if (!empty($resolved)) {
523                 $user->$moodleproperty = $resolved;
524             }
525         }
527         if (empty($user->username) && !empty($user->email)) {
528             $user->username = $user->email;
529         }
531         if (!empty($user->picture)) {
532             $user->picture = download_file_content($user->picture, null, null, false, 10, 10, true, null, false);
533         } else {
534             $pictureurl = $this->issuer->get_endpoint_url('userpicture');
535             if (!empty($pictureurl)) {
536                 $user->picture = $this->get($pictureurl);
537             }
538         }
540         if (!empty($user->picture)) {
541             // If it doesn't look like a picture lets unset it.
542             if (function_exists('imagecreatefromstring')) {
543                 $img = @imagecreatefromstring($user->picture);
544                 if (empty($img)) {
545                     unset($user->picture);
546                 } else {
547                     imagedestroy($img);
548                 }
549             }
550         }
552         return (array)$user;
553     }