MDL-59510 core_oauth2: add autorefresh mode to oauth2\client
[moodle.git] / lib / classes / oauth2 / client.php
CommitLineData
60237253
DW
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 *
72fd103a 20 * @package core
60237253
DW
21 * @copyright 2017 Damyon Wiese
22 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23 */
24namespace core\oauth2;
25
26defined('MOODLE_INTERNAL') || die();
27
28require_once($CFG->libdir . '/oauthlib.php');
29require_once($CFG->libdir . '/filelib.php');
30
31use moodle_url;
8381b05d 32use moodle_exception;
8445556b 33use stdClass;
60237253
DW
34
35/**
f11a7d6a 36 * Configurable oauth2 client class. URLs come from DB and access tokens from either DB (system accounts) or session (users').
60237253
DW
37 *
38 * @copyright 2017 Damyon Wiese
39 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
40 */
485a22fc 41class client extends \oauth2_client {
60237253
DW
42
43 /** @var \core\oauth2\issuer $issuer */
44 private $issuer;
45
46 /** @var bool $system */
47 protected $system = false;
48
8b098533
JD
49 /** @var bool $autorefresh whether this client will use a refresh token to automatically renew access tokens.*/
50 protected $autorefresh = false;
51
60237253
DW
52 /**
53 * Constructor.
54 *
55 * @param issuer $issuer
931c0234
DW
56 * @param moodle_url|null $returnurl
57 * @param string $scopesrequired
58 * @param boolean $system
8b098533 59 * @param boolean $autorefresh whether refresh_token grants are used to allow continued access across sessions.
60237253 60 */
8b098533 61 public function __construct(issuer $issuer, $returnurl, $scopesrequired, $system = false, $autorefresh = false) {
60237253
DW
62 $this->issuer = $issuer;
63 $this->system = $system;
8b098533 64 $this->autorefresh = $autorefresh;
60237253
DW
65 $scopes = $this->get_login_scopes();
66 $additionalscopes = explode(' ', $scopesrequired);
67
68 foreach ($additionalscopes as $scope) {
485a22fc
DW
69 if (!empty($scope)) {
70 if (strpos(' ' . $scopes . ' ', ' ' . $scope . ' ') === false) {
71 $scopes .= ' ' . $scope;
72 }
60237253
DW
73 }
74 }
931c0234
DW
75 if (empty($returnurl)) {
76 $returnurl = new moodle_url('/');
77 }
b3235085 78 $this->basicauth = $issuer->get('basicauth');
60237253
DW
79 parent::__construct($issuer->get('clientid'), $issuer->get('clientsecret'), $returnurl, $scopes);
80 }
81
60237253
DW
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
8445556b
DW
90 /**
91 * Get the oauth2 issuer for this client.
92 *
93 * @return \core\oauth2\issuer Issuer
94 */
95 public function get_issuer() {
60237253
DW
96 return $this->issuer;
97 }
98
8445556b
DW
99 /**
100 * Override to append additional params to a authentication request.
101 *
102 * @return array (name value pairs).
103 */
60237253 104 public function get_additional_login_parameters() {
485a22fc 105 $params = '';
8b098533
JD
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.
485a22fc
DW
111 if (!empty($this->issuer->get('loginparamsoffline'))) {
112 $params = $this->issuer->get('loginparamsoffline');
113 }
114 } else {
8b098533
JD
115 // This is not a system client, nor a client supporting the refresh_token grant type, so just return the
116 // vanilla login params.
485a22fc
DW
117 if (!empty($this->issuer->get('loginparams'))) {
118 $params = $this->issuer->get('loginparams');
119 }
120 }
8b098533 121
485a22fc
DW
122 if (empty($params)) {
123 return [];
124 }
125 $result = [];
126 parse_str($params, $result);
127 return $result;
60237253
DW
128 }
129
8445556b
DW
130 /**
131 * Override to change the scopes requested with an authentiction request.
132 *
133 * @return string
134 */
60237253 135 protected function get_login_scopes() {
8b098533
JD
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.
485a22fc
DW
140 return $this->issuer->get('loginscopesoffline');
141 } else {
8b098533
JD
142 // This is not a system client, nor a client supporting the refresh_token grant type, so just return the
143 // vanilla login scopes.
485a22fc
DW
144 return $this->issuer->get('loginscopes');
145 }
60237253
DW
146 }
147
148 /**
149 * Returns the token url for OAuth 2.0 request
8445556b
DW
150 *
151 * We are overriding the parent function so we get this from the configured endpoint.
152 *
60237253
DW
153 * @return string the auth url
154 */
155 protected function token_url() {
156 return $this->issuer->get_endpoint_url('token');
157 }
158
8445556b
DW
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 */
60237253 164 protected function get_tokenname() {
8445556b 165 $name = 'oauth2-state-' . $this->issuer->get('id');
60237253
DW
166 if ($this->system) {
167 $name .= '-system';
168 }
169 return $name;
170 }
171
f11a7d6a
JD
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);
121fa438
JD
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 }
f11a7d6a
JD
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
f9f243f9
DW
228 /**
229 * Get a list of the mapping user fields in an associative array.
230 *
231 * @return array
232 */
8445556b
DW
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
237fd80c 243 /**
8b098533 244 * Override which upgrades the authorization code to an access token and stores any refresh token in the DB.
237fd80c 245 *
8b098533
JD
246 * @param string $code the authorisation code
247 * @return bool true if the token could be upgraded
248 * @throws moodle_exception
237fd80c 249 */
8b098533
JD
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();
237fd80c 277
8b098533
JD
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 {
237fd80c 386 $params = array('refresh_token' => $refreshtoken,
237fd80c
DW
387 'grant_type' => 'refresh_token'
388 );
389
b3235085 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
237fd80c
DW
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
eb4ab7c4 405 if ($this->info['http_code'] !== 200) {
237fd80c
DW
406 throw new moodle_exception('Could not upgrade oauth token');
407 }
408
409 $r = json_decode($response);
410
411 if (!empty($r->error)) {
412 throw new moodle_exception($r->error . ' ' . $r->error_description);
413 }
414
415 if (!isset($r->access_token)) {
8b098533 416 return null;
237fd80c
DW
417 }
418
237fd80c 419 // Store the token an expiry time.
8b098533 420 $accesstoken = new stdClass();
237fd80c
DW
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 }
3ba79ff1 426 $accesstoken->scope = $this->scope;
8b098533
JD
427
428 $tokens = ['access_token' => $accesstoken];
237fd80c 429
3ba79ff1 430 if (isset($r->refresh_token)) {
99e632c4 431 $this->refreshtoken = $r->refresh_token;
8b098533
JD
432 $newrefreshtoken = new stdClass();
433 $newrefreshtoken->token = $this->refreshtoken;
434 $newrefreshtoken->scope = $this->scope;
435 $tokens['refresh_token'] = $newrefreshtoken;
436 }
437
438 return $tokens;
439 }
440
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 }
450
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 }
459
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'));
468
469 // No access token received, so return false.
470 if (empty($receivedtokens)) {
471 return false;
472 }
473
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();
3ba79ff1
DW
479 }
480
237fd80c
DW
481 return true;
482 }
483
f9f243f9
DW
484 /**
485 * Fetch the user info from the user info endpoint and map all
486 * the fields back into moodle fields.
487 *
bd0b9873 488 * @return array|false Moodle user fields for the logged in user (or false if request failed)
f9f243f9 489 */
8445556b
DW
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);
bd0b9873 499 } catch (\Exception $e) {
8445556b
DW
500 return false;
501 }
502
503 $map = $this->get_userinfo_mapping();
504
505 $user = new stdClass();
506 foreach ($map as $openidproperty => $moodleproperty) {
485a22fc
DW
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]};
514
515 if (count($proplist) > 1) {
516 return $getfunc($obj, $proplist[1]);
517 }
518 return $obj;
519 };
520
521 $resolved = $getfunc($userinfo, $openidproperty);
522 if (!empty($resolved)) {
523 $user->$moodleproperty = $resolved;
8445556b
DW
524 }
525 }
526
7f158660
DW
527 if (empty($user->username) && !empty($user->email)) {
528 $user->username = $user->email;
529 }
530
8445556b
DW
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 }
539
14cfd280
DW
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 }
551
8445556b
DW
552 return (array)$user;
553 }
60237253 554}