553e8fe3129b9f517c17c4ed3e762ccd64a3b8c1
[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     /**
50      * Constructor.
51      *
52      * @param issuer $issuer
53      * @param moodle_url|null $returnurl
54      * @param string $scopesrequired
55      * @param boolean $system
56      */
57     public function __construct(issuer $issuer, $returnurl, $scopesrequired, $system = false) {
58         $this->issuer = $issuer;
59         $this->system = $system;
60         $scopes = $this->get_login_scopes();
61         $additionalscopes = explode(' ', $scopesrequired);
63         foreach ($additionalscopes as $scope) {
64             if (!empty($scope)) {
65                 if (strpos(' ' . $scopes . ' ', ' ' . $scope . ' ') === false) {
66                     $scopes .= ' ' . $scope;
67                 }
68             }
69         }
70         if (empty($returnurl)) {
71             $returnurl = new moodle_url('/');
72         }
73         $this->basicauth = $issuer->get('basicauth');
74         parent::__construct($issuer->get('clientid'), $issuer->get('clientsecret'), $returnurl, $scopes);
75     }
77     /**
78      * Returns the auth url for OAuth 2.0 request
79      * @return string the auth url
80      */
81     protected function auth_url() {
82         return $this->issuer->get_endpoint_url('authorization');
83     }
85     /**
86      * Get the oauth2 issuer for this client.
87      *
88      * @return \core\oauth2\issuer Issuer
89      */
90     public function get_issuer() {
91         return $this->issuer;
92     }
94     /**
95      * Override to append additional params to a authentication request.
96      *
97      * @return array (name value pairs).
98      */
99     public function get_additional_login_parameters() {
100         $params = '';
101         if ($this->system) {
102             if (!empty($this->issuer->get('loginparamsoffline'))) {
103                 $params = $this->issuer->get('loginparamsoffline');
104             }
105         } else {
106             if (!empty($this->issuer->get('loginparams'))) {
107                 $params = $this->issuer->get('loginparams');
108             }
109         }
110         if (empty($params)) {
111             return [];
112         }
113         $result = [];
114         parse_str($params, $result);
115         return $result;
116     }
118     /**
119      * Override to change the scopes requested with an authentiction request.
120      *
121      * @return string
122      */
123     protected function get_login_scopes() {
124         if ($this->system) {
125             return $this->issuer->get('loginscopesoffline');
126         } else {
127             return $this->issuer->get('loginscopes');
128         }
129     }
131     /**
132      * Returns the token url for OAuth 2.0 request
133      *
134      * We are overriding the parent function so we get this from the configured endpoint.
135      *
136      * @return string the auth url
137      */
138     protected function token_url() {
139         return $this->issuer->get_endpoint_url('token');
140     }
142     /**
143      * We want a unique key for each issuer / and a different key for system vs user oauth.
144      *
145      * @return string The unique key for the session value.
146      */
147     protected function get_tokenname() {
148         $name = 'oauth2-state-' . $this->issuer->get('id');
149         if ($this->system) {
150             $name .= '-system';
151         }
152         return $name;
153     }
155     /**
156      * Store a token between requests. Uses session named by get_tokenname for user account tokens
157      * and a database record for system account tokens.
158      *
159      * @param stdClass|null $token token object to store or null to clear
160      */
161     protected function store_token($token) {
162         if (!$this->system) {
163             parent::store_token($token);
164             return;
165         }
167         $this->accesstoken = $token;
169         // Create or update a DB record with the new token.
170         $persistedtoken = access_token::get_record(['issuerid' => $this->issuer->get('id')]);
171         if ($token !== null) {
172             if (!$persistedtoken) {
173                 $persistedtoken = new access_token();
174                 $persistedtoken->set('issuerid', $this->issuer->get('id'));
175             }
176             // Update values from $token. Don't use from_record because that would skip validation.
177             $persistedtoken->set('token', $token->token);
178             if (isset($token->expires)) {
179                 $persistedtoken->set('expires', $token->expires);
180             } else {
181                 // Assume an arbitrary time span of 1 week for access tokens without expiration.
182                 // The "refresh_system_tokens_task" is run hourly (by default), so the token probably won't last that long.
183                 $persistedtoken->set('expires', time() + WEEKSECS);
184             }
185             $persistedtoken->set('scope', $token->scope);
186             $persistedtoken->save();
187         } else {
188             if ($persistedtoken) {
189                 $persistedtoken->delete();
190             }
191         }
192     }
194     /**
195      * Retrieve a stored token from session (user accounts) or database (system accounts).
196      *
197      * @return stdClass|null token object
198      */
199     protected function get_stored_token() {
200         if ($this->system) {
201             $token = access_token::get_record(['issuerid' => $this->issuer->get('id')]);
202             if ($token !== false) {
203                 return $token->to_record();
204             }
205             return null;
206         }
208         return parent::get_stored_token();
209     }
211     /**
212      * Get a list of the mapping user fields in an associative array.
213      *
214      * @return array
215      */
216     protected function get_userinfo_mapping() {
217         $fields = user_field_mapping::get_records(['issuerid' => $this->issuer->get('id')]);
219         $map = [];
220         foreach ($fields as $field) {
221             $map[$field->get('externalfield')] = $field->get('internalfield');
222         }
223         return $map;
224     }
226     /**
227      * Upgrade a refresh token from oauth 2.0 to an access token
228      *
229      * @param \core\oauth2\system_account $systemaccount
230      * @return boolean true if token is upgraded succesfully
231      * @throws moodle_exception Request for token upgrade failed for technical reasons
232      */
233     public function upgrade_refresh_token(system_account $systemaccount) {
234         $refreshtoken = $systemaccount->get('refreshtoken');
236         $params = array('refresh_token' => $refreshtoken,
237             'grant_type' => 'refresh_token'
238         );
240         if ($this->basicauth) {
241             $idsecret = urlencode($this->issuer->get('clientid')) . ':' . urlencode($this->issuer->get('clientsecret'));
242             $this->setHeader('Authorization: Basic ' . base64_encode($idsecret));
243         } else {
244             $params['client_id'] = $this->issuer->get('clientid');
245             $params['client_secret'] = $this->issuer->get('clientsecret');
246         }
248         // Requests can either use http GET or POST.
249         if ($this->use_http_get()) {
250             $response = $this->get($this->token_url(), $params);
251         } else {
252             $response = $this->post($this->token_url(), $this->build_post_data($params));
253         }
255         if ($this->info['http_code'] !== 200) {
256             throw new moodle_exception('Could not upgrade oauth token');
257         }
259         $r = json_decode($response);
261         if (!empty($r->error)) {
262             throw new moodle_exception($r->error . ' ' . $r->error_description);
263         }
265         if (!isset($r->access_token)) {
266             return false;
267         }
269         // Store the token an expiry time.
270         $accesstoken = new stdClass;
271         $accesstoken->token = $r->access_token;
272         if (isset($r->expires_in)) {
273             // Expires 10 seconds before actual expiry.
274             $accesstoken->expires = (time() + ($r->expires_in - 10));
275         }
276         $accesstoken->scope = $this->scope;
277         // Also add the scopes.
278         $this->store_token($accesstoken);
280         if (isset($r->refresh_token)) {
281             $systemaccount->set('refreshtoken', $r->refresh_token);
282             $systemaccount->update();
283             $this->refreshtoken = $r->refresh_token;
284         }
286         return true;
287     }
289     /**
290      * Fetch the user info from the user info endpoint and map all
291      * the fields back into moodle fields.
292      *
293      * @return array|false Moodle user fields for the logged in user (or false if request failed)
294      */
295     public function get_userinfo() {
296         $url = $this->get_issuer()->get_endpoint_url('userinfo');
297         $response = $this->get($url);
298         if (!$response) {
299             return false;
300         }
301         $userinfo = new stdClass();
302         try {
303             $userinfo = json_decode($response);
304         } catch (\Exception $e) {
305             return false;
306         }
308         $map = $this->get_userinfo_mapping();
310         $user = new stdClass();
311         foreach ($map as $openidproperty => $moodleproperty) {
312             // We support nested objects via a-b-c syntax.
313             $getfunc = function($obj, $prop) use (&$getfunc) {
314                 $proplist = explode('-', $prop, 2);
315                 if (empty($proplist[0]) || empty($obj->{$proplist[0]})) {
316                     return false;
317                 }
318                 $obj = $obj->{$proplist[0]};
320                 if (count($proplist) > 1) {
321                     return $getfunc($obj, $proplist[1]);
322                 }
323                 return $obj;
324             };
326             $resolved = $getfunc($userinfo, $openidproperty);
327             if (!empty($resolved)) {
328                 $user->$moodleproperty = $resolved;
329             }
330         }
332         if (empty($user->username) && !empty($user->email)) {
333             $user->username = $user->email;
334         }
336         if (!empty($user->picture)) {
337             $user->picture = download_file_content($user->picture, null, null, false, 10, 10, true, null, false);
338         } else {
339             $pictureurl = $this->issuer->get_endpoint_url('userpicture');
340             if (!empty($pictureurl)) {
341                 $user->picture = $this->get($pictureurl);
342             }
343         }
345         if (!empty($user->picture)) {
346             // If it doesn't look like a picture lets unset it.
347             if (function_exists('imagecreatefromstring')) {
348                 $img = @imagecreatefromstring($user->picture);
349                 if (empty($img)) {
350                     unset($user->picture);
351                 } else {
352                     imagedestroy($img);
353                 }
354             }
355         }
357         return (array)$user;
358     }