client.php 21.3 KB
Newer Older
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.

/**
 * Configurable oauth2 client class.
 *
Damyon Wiese's avatar
Damyon Wiese committed
20
 * @package    core
21
22
23
24
25
26
27
28
29
30
31
 * @copyright  2017 Damyon Wiese
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */
namespace core\oauth2;

defined('MOODLE_INTERNAL') || die();

require_once($CFG->libdir . '/oauthlib.php');
require_once($CFG->libdir . '/filelib.php');

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
39
40
 *
 * @copyright  2017 Damyon Wiese
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */
41
class client extends \oauth2_client {
42
43
44
45
46
47
48

    /** @var \core\oauth2\issuer $issuer */
    private $issuer;

    /** @var bool $system */
    protected $system = false;

49
50
51
    /** @var bool $autorefresh whether this client will use a refresh token to automatically renew access tokens.*/
    protected $autorefresh = false;

52
53
54
55
    /**
     * Constructor.
     *
     * @param issuer $issuer
56
57
58
     * @param moodle_url|null $returnurl
     * @param string $scopesrequired
     * @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
63
        $this->issuer = $issuer;
        $this->system = $system;
64
        $this->autorefresh = $autorefresh;
65
66
67
68
        $scopes = $this->get_login_scopes();
        $additionalscopes = explode(' ', $scopesrequired);

        foreach ($additionalscopes as $scope) {
69
70
71
72
            if (!empty($scope)) {
                if (strpos(' ' . $scopes . ' ', ' ' . $scope . ' ') === false) {
                    $scopes .= ' ' . $scope;
                }
73
74
            }
        }
75
76
77
        if (empty($returnurl)) {
            $returnurl = new moodle_url('/');
        }
78
        $this->basicauth = $issuer->get('basicauth');
79
80
81
82
83
84
85
86
87
88
89
        parent::__construct($issuer->get('clientid'), $issuer->get('clientsecret'), $returnurl, $scopes);
    }

    /**
     * Returns the auth url for OAuth 2.0 request
     * @return string the auth url
     */
    protected function auth_url() {
        return $this->issuer->get_endpoint_url('authorization');
    }

90
91
92
93
94
95
    /**
     * Get the oauth2 issuer for this client.
     *
     * @return \core\oauth2\issuer Issuer
     */
    public function get_issuer() {
96
97
98
        return $this->issuer;
    }

99
100
101
102
103
    /**
     * Override to append additional params to a authentication request.
     *
     * @return array (name value pairs).
     */
104
    public function get_additional_login_parameters() {
105
        $params = '';
106
107
108
109
110

        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.
111
112
113
114
            if (!empty($this->issuer->get('loginparamsoffline'))) {
                $params = $this->issuer->get('loginparamsoffline');
            }
        } else {
115
116
            // This is not a system client, nor a client supporting the refresh_token grant type, so just return the
            // vanilla login params.
117
118
119
120
            if (!empty($this->issuer->get('loginparams'))) {
                $params = $this->issuer->get('loginparams');
            }
        }
121

122
123
124
125
126
127
        if (empty($params)) {
            return [];
        }
        $result = [];
        parse_str($params, $result);
        return $result;
128
129
    }

130
131
132
133
134
    /**
     * Override to change the scopes requested with an authentiction request.
     *
     * @return string
     */
135
    protected function get_login_scopes() {
136
137
138
139
        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.
140
141
            return $this->issuer->get('loginscopesoffline');
        } else {
142
143
            // This is not a system client, nor a client supporting the refresh_token grant type, so just return the
            // vanilla login scopes.
144
145
            return $this->issuer->get('loginscopes');
        }
146
147
148
149
    }

    /**
     * Returns the token url for OAuth 2.0 request
150
151
152
     *
     * We are overriding the parent function so we get this from the configured endpoint.
     *
153
154
155
156
157
158
     * @return string the auth url
     */
    protected function token_url() {
        return $this->issuer->get_endpoint_url('token');
    }

159
160
161
162
163
    /**
     * We want a unique key for each issuer / and a different key for system vs user oauth.
     *
     * @return string The unique key for the session value.
     */
164
    protected function get_tokenname() {
165
        $name = 'oauth2-state-' . $this->issuer->get('id');
166
167
168
169
170
171
        if ($this->system) {
            $name .= '-system';
        }
        return $name;
    }

172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
    /**
     * Store a token between requests. Uses session named by get_tokenname for user account tokens
     * and a database record for system account tokens.
     *
     * @param stdClass|null $token token object to store or null to clear
     */
    protected function store_token($token) {
        if (!$this->system) {
            parent::store_token($token);
            return;
        }

        $this->accesstoken = $token;

        // Create or update a DB record with the new token.
        $persistedtoken = access_token::get_record(['issuerid' => $this->issuer->get('id')]);
        if ($token !== null) {
            if (!$persistedtoken) {
                $persistedtoken = new access_token();
                $persistedtoken->set('issuerid', $this->issuer->get('id'));
            }
            // Update values from $token. Don't use from_record because that would skip validation.
            $persistedtoken->set('token', $token->token);
195
196
197
198
199
200
201
            if (isset($token->expires)) {
                $persistedtoken->set('expires', $token->expires);
            } else {
                // Assume an arbitrary time span of 1 week for access tokens without expiration.
                // The "refresh_system_tokens_task" is run hourly (by default), so the token probably won't last that long.
                $persistedtoken->set('expires', time() + WEEKSECS);
            }
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
            $persistedtoken->set('scope', $token->scope);
            $persistedtoken->save();
        } else {
            if ($persistedtoken) {
                $persistedtoken->delete();
            }
        }
    }

    /**
     * Retrieve a stored token from session (user accounts) or database (system accounts).
     *
     * @return stdClass|null token object
     */
    protected function get_stored_token() {
        if ($this->system) {
            $token = access_token::get_record(['issuerid' => $this->issuer->get('id')]);
            if ($token !== false) {
                return $token->to_record();
            }
            return null;
        }

        return parent::get_stored_token();
    }

228
229
230
231
232
    /**
     * Get a list of the mapping user fields in an associative array.
     *
     * @return array
     */
233
234
235
236
237
238
239
240
241
242
    protected function get_userinfo_mapping() {
        $fields = user_field_mapping::get_records(['issuerid' => $this->issuer->get('id')]);

        $map = [];
        foreach ($fields as $field) {
            $map[$field->get('externalfield')] = $field->get('internalfield');
        }
        return $map;
    }

243
    /**
244
     * Override which upgrades the authorization code to an access token and stores any refresh token in the DB.
245
     *
246
247
248
     * @param string $code the authorisation code
     * @return bool true if the token could be upgraded
     * @throws moodle_exception
249
     */
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
    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();
277

278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
        // 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 {
386
387
388
389
        $params = array('refresh_token' => $refreshtoken,
            'grant_type' => 'refresh_token'
        );

390
391
392
393
394
395
396
397
        if ($this->basicauth) {
            $idsecret = urlencode($this->issuer->get('clientid')) . ':' . urlencode($this->issuer->get('clientsecret'));
            $this->setHeader('Authorization: Basic ' . base64_encode($idsecret));
        } else {
            $params['client_id'] = $this->issuer->get('clientid');
            $params['client_secret'] = $this->issuer->get('clientsecret');
        }

398
399
400
401
402
403
404
        // Requests can either use http GET or POST.
        if ($this->use_http_get()) {
            $response = $this->get($this->token_url(), $params);
        } else {
            $response = $this->post($this->token_url(), $this->build_post_data($params));
        }

405
        if ($this->info['http_code'] !== 200) {
406
407
            $debuginfo = !empty($this->error) ? $this->error : $response;
            throw new moodle_exception('oauth2refreshtokenerror', 'core_error', '', $this->info['http_code'], $debuginfo);
408
409
410
411
412
413
414
415
416
        }

        $r = json_decode($response);

        if (!empty($r->error)) {
            throw new moodle_exception($r->error . ' ' . $r->error_description);
        }

        if (!isset($r->access_token)) {
417
            return null;
418
419
420
        }

        // Store the token an expiry time.
421
        $accesstoken = new stdClass();
422
423
424
425
426
        $accesstoken->token = $r->access_token;
        if (isset($r->expires_in)) {
            // Expires 10 seconds before actual expiry.
            $accesstoken->expires = (time() + ($r->expires_in - 10));
        }
427
        $accesstoken->scope = $this->scope;
428
429

        $tokens = ['access_token' => $accesstoken];
430

431
        if (isset($r->refresh_token)) {
432
            $this->refreshtoken = $r->refresh_token;
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
            $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']);
477
        if (isset($receivedtokens['refresh_token'])) {
478
479
            $systemaccount->set('refreshtoken', $receivedtokens['refresh_token']->token);
            $systemaccount->update();
480
481
        }

482
483
484
        return true;
    }

485
486
487
488
    /**
     * Fetch the user info from the user info endpoint and map all
     * the fields back into moodle fields.
     *
489
     * @return array|false Moodle user fields for the logged in user (or false if request failed)
490
     */
491
    public function get_userinfo() {
g.avgeris's avatar
g.avgeris committed
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
        //$url = $this->get_issuer()->get_endpoint_url('userinfo');
		$url = $this->get_issuer()->get_endpoint_url('userinfo');
		
        $params = array(
            "user_oauth_approval" =>"true",
            "scope.read" => "true"
        );
		
		// ------ DEBUG ----------- //
//        echo "<br/><hr /><strong>get_userinfo:</strong><br/>";
//		echo "url:<br /><pre>";
//		print_r($url);
//		echo "</pre><br/>";
		// ------------------------ //

		$response = $this->post($url, $params, 'application/xml' );
		//$response = $this->post( $url, $this->build_post_data($params) );
		
		// ------ DEBUG ----------- //
//		echo "response:<br /><pre>";
//		var_dump( $response );
//		echo "</pre>";
		// ------------------------ //

516
517
518
        if (!$response) {
            return false;
        }
g.avgeris's avatar
g.avgeris committed
519
520
521
522
523
524
525
526
        $response = new \SimpleXMLElement($response); // FOTIS
        $response = json_encode($response); // FOTIS
        $response = json_decode($response, TRUE); // FOTIS
        $response = $response ["userinfo"]["@attributes"];
        $response = json_encode($response);
        //die(0);


527
528
529
        $userinfo = new stdClass();
        try {
            $userinfo = json_decode($response);
530
        } catch (\Exception $e) {
531
532
            return false;
        }
g.avgeris's avatar
g.avgeris committed
533
534
535
536
537
        // Αν ο χρήστης που κάνει login δεν είναι δημόσιος υπάλληλος τότε η ΓΓΠΣ επιστρέφει JSON με taxid = "null"
        // Τον κάνω αυτόματα logout για να μπορεί να συνδεθεί ξανά με διαφορετικά στοιχεία
        if ($userinfo->taxid == "null") {
            return false;
        }        // Simple taxisnet login has taxid = null. Don't allow these logins
538
539
540
541
542

        $map = $this->get_userinfo_mapping();

        $user = new stdClass();
        foreach ($map as $openidproperty => $moodleproperty) {
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
            // We support nested objects via a-b-c syntax.
            $getfunc = function($obj, $prop) use (&$getfunc) {
                $proplist = explode('-', $prop, 2);
                if (empty($proplist[0]) || empty($obj->{$proplist[0]})) {
                    return false;
                }
                $obj = $obj->{$proplist[0]};

                if (count($proplist) > 1) {
                    return $getfunc($obj, $proplist[1]);
                }
                return $obj;
            };

            $resolved = $getfunc($userinfo, $openidproperty);
            if (!empty($resolved)) {
                $user->$moodleproperty = $resolved;
560
561
562
            }
        }

563
564
565
566
        if (empty($user->username) && !empty($user->email)) {
            $user->username = $user->email;
        }

567
568
569
570
571
572
573
574
575
        if (!empty($user->picture)) {
            $user->picture = download_file_content($user->picture, null, null, false, 10, 10, true, null, false);
        } else {
            $pictureurl = $this->issuer->get_endpoint_url('userpicture');
            if (!empty($pictureurl)) {
                $user->picture = $this->get($pictureurl);
            }
        }

576
577
578
579
580
581
582
583
584
585
586
587
        if (!empty($user->picture)) {
            // If it doesn't look like a picture lets unset it.
            if (function_exists('imagecreatefromstring')) {
                $img = @imagecreatefromstring($user->picture);
                if (empty($img)) {
                    unset($user->picture);
                } else {
                    imagedestroy($img);
                }
            }
        }

588
589
        return (array)$user;
    }
590
}