Proyectos de Subversion Moodle

Rev

Rev 1 | | Comparar con el anterior | Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 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
 *
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;
25
 
26
defined('MOODLE_INTERNAL') || die();
27
 
28
require_once($CFG->libdir . '/oauthlib.php');
29
require_once($CFG->libdir . '/filelib.php');
30
 
31
use moodle_url;
32
use moodle_exception;
33
use stdClass;
34
 
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 {
42
 
43
    /** @var \core\oauth2\issuer $issuer */
44
    private $issuer;
45
 
46
    /** @var bool $system */
47
    protected $system = false;
48
 
49
    /** @var bool $autorefresh whether this client will use a refresh token to automatically renew access tokens.*/
50
    protected $autorefresh = false;
51
 
52
    /** @var array $rawuserinfo Keep rawuserinfo from . */
53
    protected $rawuserinfo = [];
54
 
55
    /**
56
     * Constructor.
57
     *
58
     * @param issuer $issuer
59
     * @param moodle_url|null $returnurl
60
     * @param string $scopesrequired
61
     * @param boolean $system
62
     * @param boolean $autorefresh whether refresh_token grants are used to allow continued access across sessions.
63
     */
64
    public function __construct(issuer $issuer, $returnurl, $scopesrequired, $system = false, $autorefresh = false) {
65
        $this->issuer = $issuer;
66
        $this->system = $system;
67
        $this->autorefresh = $autorefresh;
68
        $scopes = $this->get_login_scopes();
69
        $additionalscopes = explode(' ', $scopesrequired);
70
 
71
        foreach ($additionalscopes as $scope) {
72
            if (!empty($scope)) {
73
                if (strpos(' ' . $scopes . ' ', ' ' . $scope . ' ') === false) {
74
                    $scopes .= ' ' . $scope;
75
                }
76
            }
77
        }
78
        if (empty($returnurl)) {
79
            $returnurl = new moodle_url('/');
80
        }
81
        $this->basicauth = $issuer->get('basicauth');
82
        parent::__construct($issuer->get('clientid'), $issuer->get('clientsecret'), $returnurl, $scopes);
83
    }
84
 
85
    /**
86
     * Returns the auth url for OAuth 2.0 request
87
     * @return string the auth url
88
     */
89
    protected function auth_url() {
90
        return $this->issuer->get_endpoint_url('authorization');
91
    }
92
 
93
    /**
94
     * Get the oauth2 issuer for this client.
95
     *
96
     * @return \core\oauth2\issuer Issuer
97
     */
98
    public function get_issuer() {
99
        return $this->issuer;
100
    }
101
 
102
    /**
103
     * Override to append additional params to a authentication request.
104
     *
105
     * @return array (name value pairs).
106
     */
107
    public function get_additional_login_parameters() {
108
        $params = '';
109
 
110
        if ($this->system || $this->can_autorefresh()) {
111
            // System clients and clients supporting the refresh_token grant (provided the user is authenticated) add
112
            // extra params to the login request, depending on the issuer settings. The extra params allow a refresh
113
            // token to be returned during the authorization_code flow.
114
            if (!empty($this->issuer->get('loginparamsoffline'))) {
115
                $params = $this->issuer->get('loginparamsoffline');
116
            }
117
        } else {
118
            // This is not a system client, nor a client supporting the refresh_token grant type, so just return the
119
            // vanilla login params.
120
            if (!empty($this->issuer->get('loginparams'))) {
121
                $params = $this->issuer->get('loginparams');
122
            }
123
        }
124
 
125
        if (empty($params)) {
126
            return [];
127
        }
128
        $result = [];
1441 ariadna 129
 
130
        // Replace the language tag if it appears in the string.
131
        $lang = current_language();
132
        $tags = ["{lang}", "{LANG}", "{language}", "{LANGUAGE}", '{lan-guage}', '{LAN-GUAGE}'];
133
        $langcode = [
134
            strtolower(substr($lang, 0, 2)),
135
            strtoupper(substr($lang, 0, 2)),
136
            strtolower($lang),
137
            strtoupper($lang),
138
            str_replace('_', '-', strtolower($lang)),
139
            str_replace('_', '-', strtoupper($lang)),
140
        ];
141
        $params = str_replace($tags, $langcode, $params);
142
 
1 efrain 143
        parse_str($params, $result);
144
        return $result;
145
    }
146
 
147
    /**
148
     * Override to change the scopes requested with an authentiction request.
149
     *
150
     * @return string
151
     */
152
    protected function get_login_scopes() {
153
        if ($this->system || $this->can_autorefresh()) {
154
            // System clients and clients supporting the refresh_token grant (provided the user is authenticated) add
155
            // extra scopes to the login request, depending on the issuer settings. The extra params allow a refresh
156
            // token to be returned during the authorization_code flow.
157
            return $this->issuer->get('loginscopesoffline');
158
        } else {
159
            // This is not a system client, nor a client supporting the refresh_token grant type, so just return the
160
            // vanilla login scopes.
161
            return $this->issuer->get('loginscopes');
162
        }
163
    }
164
 
165
    /**
166
     * Returns the token url for OAuth 2.0 request
167
     *
168
     * We are overriding the parent function so we get this from the configured endpoint.
169
     *
170
     * @return string the auth url
171
     */
172
    protected function token_url() {
173
        return $this->issuer->get_endpoint_url('token');
174
    }
175
 
176
    /**
177
     * We want a unique key for each issuer / and a different key for system vs user oauth.
178
     *
179
     * @return string The unique key for the session value.
180
     */
181
    protected function get_tokenname() {
182
        $name = 'oauth2-state-' . $this->issuer->get('id');
183
        if ($this->system) {
184
            $name .= '-system';
185
        }
186
        return $name;
187
    }
188
 
189
    /**
190
     * Store a token between requests. Uses session named by get_tokenname for user account tokens
191
     * and a database record for system account tokens.
192
     *
193
     * @param stdClass|null $token token object to store or null to clear
194
     */
195
    protected function store_token($token) {
196
        if (!$this->system) {
197
            parent::store_token($token);
198
            return;
199
        }
200
 
201
        $this->accesstoken = $token;
202
 
203
        // Create or update a DB record with the new token.
204
        $persistedtoken = access_token::get_record(['issuerid' => $this->issuer->get('id')]);
205
        if ($token !== null) {
206
            if (!$persistedtoken) {
207
                $persistedtoken = new access_token();
208
                $persistedtoken->set('issuerid', $this->issuer->get('id'));
209
            }
210
            // Update values from $token. Don't use from_record because that would skip validation.
211
            $persistedtoken->set('token', $token->token);
212
            if (isset($token->expires)) {
213
                $persistedtoken->set('expires', $token->expires);
214
            } else {
215
                // Assume an arbitrary time span of 1 week for access tokens without expiration.
216
                // The "refresh_system_tokens_task" is run hourly (by default), so the token probably won't last that long.
217
                $persistedtoken->set('expires', time() + WEEKSECS);
218
            }
219
            $persistedtoken->set('scope', $token->scope);
220
            $persistedtoken->save();
221
        } else {
222
            if ($persistedtoken) {
223
                $persistedtoken->delete();
224
            }
225
        }
226
    }
227
 
228
    /**
229
     * Retrieve a stored token from session (user accounts) or database (system accounts).
230
     *
231
     * @return stdClass|null token object
232
     */
233
    protected function get_stored_token() {
234
        if ($this->system) {
235
            $token = access_token::get_record(['issuerid' => $this->issuer->get('id')]);
236
            if ($token !== false) {
237
                return $token->to_record();
238
            }
239
            return null;
240
        }
241
 
242
        return parent::get_stored_token();
243
    }
244
 
245
    /**
246
     * Get a list of the mapping user fields in an associative array.
247
     *
248
     * @return array
249
     */
250
    protected function get_userinfo_mapping() {
251
        $fields = user_field_mapping::get_records(['issuerid' => $this->issuer->get('id')]);
252
 
253
        $map = [];
254
        foreach ($fields as $field) {
255
            $map[$field->get('externalfield')] = $field->get('internalfield');
256
        }
257
        return $map;
258
    }
259
 
260
    /**
261
     * Override which upgrades the authorization code to an access token and stores any refresh token in the DB.
262
     *
263
     * @param string $code the authorisation code
264
     * @return bool true if the token could be upgraded
265
     * @throws moodle_exception
266
     */
267
    public function upgrade_token($code) {
268
        $upgraded = parent::upgrade_token($code);
269
        if (!$this->can_autorefresh()) {
270
            return $upgraded;
271
        }
272
 
273
        // For clients supporting auto-refresh, try to store a refresh token.
274
        if (!empty($this->refreshtoken)) {
275
            $refreshtoken = (object) [
276
                'token' => $this->refreshtoken,
277
                'scope' => $this->scope
278
            ];
279
            $this->store_user_refresh_token($refreshtoken);
280
        }
281
 
282
        return $upgraded;
283
    }
284
 
285
    /**
286
     * Override which in addition to auth code upgrade, also attempts to exchange a refresh token for an access token.
287
     *
288
     * @return bool true if the user is logged in as a result, false otherwise.
289
     */
290
    public function is_logged_in() {
291
        global $DB, $USER;
292
 
293
        $isloggedin = parent::is_logged_in();
294
 
295
        // Attempt to exchange a user refresh token, but only if required and supported.
296
        if ($isloggedin || !$this->can_autorefresh()) {
297
            return $isloggedin;
298
        }
299
 
300
        // Autorefresh is supported. Try to negotiate a login by exchanging a stored refresh token for an access token.
301
        $issuerid = $this->issuer->get('id');
302
        $refreshtoken = $DB->get_record('oauth2_refresh_token', ['userid' => $USER->id, 'issuerid' => $issuerid]);
303
        if ($refreshtoken) {
304
            try {
305
                $tokensreceived = $this->exchange_refresh_token($refreshtoken->token);
306
                if (empty($tokensreceived)) {
307
                    // No access token was returned, so invalidate the refresh token and return false.
308
                    $DB->delete_records('oauth2_refresh_token', ['id' => $refreshtoken->id]);
309
                    return false;
310
                }
311
 
312
                // Otherwise, save the access token and, if provided, the new refresh token.
313
                $this->store_token($tokensreceived['access_token']);
314
                if (!empty($tokensreceived['refresh_token'])) {
315
                    $this->store_user_refresh_token($tokensreceived['refresh_token']);
316
                }
317
                return true;
318
            } catch (\moodle_exception $e) {
319
                // The refresh attempt failed either due to an error or a bad request. A bad request could be received
320
                // for a number of reasons including expired refresh token (lifetime is not specified in OAuth 2 spec),
321
                // scope change or if app access has been revoked manually by the user (tokens revoked).
322
                // Remove the refresh token and suppress the exception, allowing the user to be taken through the
323
                // authorization_code flow again.
324
                $DB->delete_records('oauth2_refresh_token', ['id' => $refreshtoken->id]);
325
            }
326
        }
327
 
328
        return false;
329
    }
330
 
331
    /**
332
     * Whether this client should automatically exchange a refresh token for an access token as part of login checks.
333
     *
334
     * @return bool true if supported, false otherwise.
335
     */
336
    protected function can_autorefresh(): bool {
337
        global $USER;
338
 
339
        // Auto refresh is only supported when the follow criteria are met:
340
        // a) The client is not a system client. The exchange process for system client refresh tokens is handled
341
        // externally, via a call to client->upgrade_refresh_token().
342
        // b) The user is authenticated.
343
        // c) The client has been configured with autorefresh enabled.
344
        return !$this->system && ($this->autorefresh && !empty($USER->id));
345
    }
346
 
347
    /**
348
     * Store the user's refresh token for later use.
349
     *
350
     * @param stdClass $token a refresh token.
351
     */
352
    protected function store_user_refresh_token(stdClass $token): void {
353
        global $DB, $USER;
354
 
355
        $id = $DB->get_field('oauth2_refresh_token', 'id', ['userid' => $USER->id,
356
            'scopehash' => sha1($token->scope), 'issuerid' => $this->issuer->get('id')]);
357
        $time = time();
358
        if ($id) {
359
            $record = [
360
                'id' => $id,
361
                'timemodified' => $time,
362
                'token' => $token->token
363
            ];
364
            $DB->update_record('oauth2_refresh_token', $record);
365
        } else {
366
            $record = [
367
                'timecreated' => $time,
368
                'timemodified' => $time,
369
                'userid' => $USER->id,
370
                'issuerid' => $this->issuer->get('id'),
371
                'token' => $token->token,
372
                'scopehash' => sha1($token->scope)
373
            ];
374
            $DB->insert_record('oauth2_refresh_token', $record);
375
        }
376
    }
377
 
378
    /**
379
     * Attempt to exchange a refresh token for a new access token.
380
     *
381
     * If successful, will return an array of token objects in the form:
382
     * Array
383
     * (
384
     *     [access_token] => stdClass object
385
     *         (
386
     *             [token] => 'the_token_string'
387
     *             [expires] => 123456789
388
     *             [scope] => 'openid files etc'
389
     *         )
390
     *     [refresh_token] => stdClass object
391
     *         (
392
     *             [token] => 'the_refresh_token_string'
393
     *             [scope] => 'openid files etc'
394
     *         )
395
     *  )
396
     * where the 'refresh_token' will only be provided if supplied by the auth server in the response.
397
     *
398
     * @param string $refreshtoken the refresh token to exchange.
399
     * @return null|array array containing access token and refresh token if provided, null if the exchange was denied.
400
     * @throws moodle_exception if an invalid response is received or if the response contains errors.
401
     */
402
    protected function exchange_refresh_token(string $refreshtoken): ?array {
403
        $params = array('refresh_token' => $refreshtoken,
404
            'grant_type' => 'refresh_token'
405
        );
406
 
407
        if ($this->basicauth) {
408
            $idsecret = urlencode($this->issuer->get('clientid')) . ':' . urlencode($this->issuer->get('clientsecret'));
409
            $this->setHeader('Authorization: Basic ' . base64_encode($idsecret));
410
        } else {
411
            $params['client_id'] = $this->issuer->get('clientid');
412
            $params['client_secret'] = $this->issuer->get('clientsecret');
413
        }
414
 
415
        // Requests can either use http GET or POST.
416
        if ($this->use_http_get()) {
417
            $response = $this->get($this->token_url(), $params);
418
        } else {
419
            $response = $this->post($this->token_url(), $this->build_post_data($params));
420
        }
421
 
422
        if ($this->info['http_code'] !== 200) {
423
            $debuginfo = !empty($this->error) ? $this->error : $response;
424
            throw new moodle_exception('oauth2refreshtokenerror', 'core_error', '', $this->info['http_code'], $debuginfo);
425
        }
426
 
427
        $r = json_decode($response);
428
 
429
        if (!empty($r->error)) {
430
            throw new moodle_exception($r->error . ' ' . $r->error_description);
431
        }
432
 
433
        if (!isset($r->access_token)) {
434
            return null;
435
        }
436
 
437
        // Store the token an expiry time.
438
        $accesstoken = new stdClass();
439
        $accesstoken->token = $r->access_token;
440
        if (isset($r->expires_in)) {
441
            // Expires 10 seconds before actual expiry.
442
            $accesstoken->expires = (time() + ($r->expires_in - 10));
443
        }
444
        $accesstoken->scope = $this->scope;
445
 
446
        $tokens = ['access_token' => $accesstoken];
447
 
448
        if (isset($r->refresh_token)) {
449
            $this->refreshtoken = $r->refresh_token;
450
            $newrefreshtoken = new stdClass();
451
            $newrefreshtoken->token = $this->refreshtoken;
452
            $newrefreshtoken->scope = $this->scope;
453
            $tokens['refresh_token'] = $newrefreshtoken;
454
        }
455
 
456
        return $tokens;
457
    }
458
 
459
    /**
460
     * Override which, in addition to deleting access tokens, also deletes any stored refresh token.
461
     */
462
    public function log_out() {
463
        global $DB, $USER;
464
        parent::log_out();
465
        if (!$this->can_autorefresh()) {
466
            return;
467
        }
468
 
469
        // For clients supporting autorefresh, delete the stored refresh token too.
470
        $issuerid = $this->issuer->get('id');
471
        $refreshtoken = $DB->get_record('oauth2_refresh_token', ['userid' => $USER->id, 'issuerid' => $issuerid,
472
            'scopehash' => sha1($this->scope)]);
473
        if ($refreshtoken) {
474
            $DB->delete_records('oauth2_refresh_token', ['id' => $refreshtoken->id]);
475
        }
476
    }
477
 
478
    /**
479
     * Upgrade a refresh token from oauth 2.0 to an access token, for system clients only.
480
     *
481
     * @param \core\oauth2\system_account $systemaccount
482
     * @return boolean true if token is upgraded succesfully
483
     */
484
    public function upgrade_refresh_token(system_account $systemaccount) {
485
        $receivedtokens = $this->exchange_refresh_token($systemaccount->get('refreshtoken'));
486
 
487
        // No access token received, so return false.
488
        if (empty($receivedtokens)) {
489
            return false;
490
        }
491
 
492
        // Store the access token and, if provided by the server, the new refresh token.
493
        $this->store_token($receivedtokens['access_token']);
494
        if (isset($receivedtokens['refresh_token'])) {
495
            $systemaccount->set('refreshtoken', $receivedtokens['refresh_token']->token);
496
            $systemaccount->update();
497
        }
498
 
499
        return true;
500
    }
501
 
502
    /**
503
     * Fetch the user info from the user info endpoint.
504
     *
505
     * @return stdClass|false Moodle user fields for the logged in user (or false if request failed)
506
     * @throws moodle_exception if the response is empty after decoding it.
507
     */
508
    public function get_raw_userinfo() {
509
        if (!empty($this->rawuserinfo)) {
510
            return $this->rawuserinfo;
511
        }
512
        $url = $this->get_issuer()->get_endpoint_url('userinfo');
513
        if (empty($url)) {
514
            return false;
515
        }
516
 
517
        $response = $this->get($url);
518
        if (!$response) {
519
            return false;
520
        }
521
        $userinfo = new stdClass();
522
        try {
523
            $userinfo = json_decode($response);
524
        } catch (\Exception $e) {
525
            return false;
526
        }
527
 
528
        if (is_null($userinfo)) {
529
            // Throw an exception displaying the original response, because, at this point, $userinfo shouldn't be empty.
530
            throw new moodle_exception($response);
531
        }
532
        $this->rawuserinfo = $userinfo;
533
        return $userinfo;
534
    }
535
 
536
    /**
537
     * Fetch the user info from the user info endpoint and map all
538
     * the fields back into moodle fields.
539
     *
540
     * @return stdClass|false Moodle user fields for the logged in user (or false if request failed)
541
     * @throws moodle_exception if the response is empty after decoding it.
542
     */
543
    public function get_userinfo() {
544
        $userinfo = $this->get_raw_userinfo();
545
        if ($userinfo === false) {
546
            return false;
547
        }
548
 
549
        return $this->map_userinfo_to_fields($userinfo);
550
    }
551
 
552
    /**
553
     * Maps the oauth2 response to userfields.
554
     *
555
     * @param stdClass $userinfo
556
     * @return array
557
     */
558
    protected function map_userinfo_to_fields(stdClass $userinfo): array {
559
        $map = $this->get_userinfo_mapping();
560
 
561
        $user = new stdClass();
562
        foreach ($map as $openidproperty => $moodleproperty) {
563
            // We support nested objects via a-b-c syntax.
564
            $getfunc = function($obj, $prop) use (&$getfunc) {
565
                $proplist = explode('-', $prop, 2);
566
 
567
                // The value of proplist[0] can be falsey, so just check if not set.
568
                if (empty($obj) || !isset($proplist[0])) {
569
                    return false;
570
                }
571
 
572
                if (preg_match('/^(.*)\[([0-9]*)\]$/', $proplist[0], $matches)
573
                        && count($matches) == 3) {
574
                    $property = $matches[1];
575
                    $index = $matches[2];
576
                    $obj = $obj->{$property}[$index] ?? null;
577
                } else if (!empty($obj->{$proplist[0]})) {
578
                    $obj = $obj->{$proplist[0]};
579
                } else if (is_array($obj) && !empty($obj[$proplist[0]])) {
580
                    $obj = $obj[$proplist[0]];
581
                } else {
582
                    // Nothing found after checking all possible valid combinations, return false.
583
                    return false;
584
                }
585
 
586
                if (count($proplist) > 1) {
587
                    return $getfunc($obj, $proplist[1]);
588
                }
589
                return $obj;
590
            };
591
 
592
            $resolved = $getfunc($userinfo, $openidproperty);
593
            if (!empty($resolved)) {
594
                $user->$moodleproperty = $resolved;
595
            }
596
        }
597
 
598
        if (empty($user->username) && !empty($user->email)) {
599
            $user->username = $user->email;
600
        }
601
 
602
        if (!empty($user->picture)) {
603
            $user->picture = download_file_content($user->picture, null, null, false, 10, 10, true, null, false);
604
        } else {
605
            $pictureurl = $this->issuer->get_endpoint_url('userpicture');
606
            if (!empty($pictureurl)) {
607
                $user->picture = $this->get($pictureurl);
608
            }
609
        }
610
 
611
        if (!empty($user->picture)) {
612
            // If it doesn't look like a picture lets unset it.
613
            if (function_exists('imagecreatefromstring')) {
614
                $img = @imagecreatefromstring($user->picture);
615
                if (empty($img)) {
616
                    unset($user->picture);
617
                } else {
618
                    imagedestroy($img);
619
                }
620
            }
621
        }
622
 
623
        return (array)$user;
624
    }
625
}