Proyectos de Subversion Moodle

Rev

| 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
 * A Helper for LTI Dynamic Registration.
19
 *
20
 * @package    mod_lti
21
 * @copyright  2020 Claude Vervoort (Cengage), Carlos Costa, Adrian Hutchinson (Macgraw Hill)
22
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23
 */
24
namespace mod_lti\local\ltiopenid;
25
 
26
defined('MOODLE_INTERNAL') || die;
27
 
28
require_once($CFG->dirroot . '/mod/lti/locallib.php');
29
use Firebase\JWT\JWK;
30
use Firebase\JWT\JWT;
31
use Firebase\JWT\Key;
32
use stdClass;
33
 
34
/**
35
 * This class exposes functions for LTI Dynamic Registration.
36
 *
37
 * @package    mod_lti
38
 * @copyright  2020 Claude Vervoort (Cengage), Carlos Costa, Adrian Hutchinson (Macgraw Hill)
39
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
40
 */
41
class registration_helper {
42
    /** score scope */
43
    const SCOPE_SCORE = 'https://purl.imsglobal.org/spec/lti-ags/scope/score';
44
    /** result scope */
45
    const SCOPE_RESULT = 'https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly';
46
    /** lineitem read-only scope */
47
    const SCOPE_LINEITEM_RO = 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly';
48
    /** lineitem full access scope */
49
    const SCOPE_LINEITEM = 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem';
50
    /** Names and Roles (membership) scope */
51
    const SCOPE_NRPS = 'https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly';
52
    /** Tool Settings scope */
53
    const SCOPE_TOOL_SETTING = 'https://purl.imsglobal.org/spec/lti-ts/scope/toolsetting';
54
 
55
    /** Indicates the token is to create a new registration */
56
    const REG_TOKEN_OP_NEW_REG = 'reg';
57
    /** Indicates the token is to update an existing registration */
58
    const REG_TOKEN_OP_UPDATE_REG = 'reg-update';
59
 
60
    /**
61
     * Get an instance of this helper
62
     *
63
     * @return object
64
     */
65
    public static function get() {
66
        return new registration_helper();
67
    }
68
 
69
    /**
70
     * Function used to validate parameters.
71
     *
72
     * This function is needed because the payload contains nested
73
     * objects, and optional_param() does not support arrays of arrays.
74
     *
75
     * @param array $payload that may contain the parameter key
76
     * @param string $key the key of the value to be looked for in the payload
77
     * @param bool $required if required, not finding a value will raise a registration_exception
78
     *
79
     * @return mixed
80
     */
81
    private function get_parameter(array $payload, string $key, bool $required) {
82
        if (!isset($payload[$key]) || empty($payload[$key])) {
83
            if ($required) {
84
                throw new registration_exception('missing required attribute '.$key, 400);
85
            }
86
            return null;
87
        }
88
        $parameter = $payload[$key];
89
        // Cleans parameters to avoid XSS and other issues.
90
        if (is_array($parameter)) {
91
            return clean_param_array($parameter, PARAM_TEXT, true);
92
        }
93
        return clean_param($parameter, PARAM_TEXT);
94
    }
95
 
96
    /**
97
     * Transforms an LTI 1.3 Registration to a Moodle LTI Config.
98
     *
99
     * @param array $registrationpayload the registration data received from the tool.
100
     * @param string $clientid the clientid to be issued for that tool.
101
     *
102
     * @return object the Moodle LTI config.
103
     */
104
    public function registration_to_config(array $registrationpayload, string $clientid): object {
105
        $responsetypes = $this->get_parameter($registrationpayload, 'response_types', true);
106
        $initiateloginuri = $this->get_parameter($registrationpayload, 'initiate_login_uri', true);
107
        $redirecturis = $this->get_parameter($registrationpayload, 'redirect_uris', true);
108
        $clientname = $this->get_parameter($registrationpayload, 'client_name', true);
109
        $jwksuri = $this->get_parameter($registrationpayload, 'jwks_uri', true);
110
        $tokenendpointauthmethod = $this->get_parameter($registrationpayload, 'token_endpoint_auth_method', true);
111
 
112
        $applicationtype = $this->get_parameter($registrationpayload, 'application_type', false);
113
        $logouri = $this->get_parameter($registrationpayload, 'logo_uri', false);
114
 
115
        $ltitoolconfiguration = $this->get_parameter($registrationpayload,
116
            'https://purl.imsglobal.org/spec/lti-tool-configuration', true);
117
 
118
        $domain = $this->get_parameter($ltitoolconfiguration, 'domain', false);
119
        $targetlinkuri = $this->get_parameter($ltitoolconfiguration, 'target_link_uri', false);
120
        $customparameters = $this->get_parameter($ltitoolconfiguration, 'custom_parameters', false);
121
        $scopes = explode(" ", $this->get_parameter($registrationpayload, 'scope', false) ?? '');
122
        $claims = $this->get_parameter($ltitoolconfiguration, 'claims', false);
123
        $messages = $ltitoolconfiguration['messages'] ?? [];
124
        $description = $this->get_parameter($ltitoolconfiguration, 'description', false);
125
 
126
        // Validate domain and target link.
127
        if (empty($domain)) {
128
            throw new registration_exception('missing_domain', 400);
129
        }
130
 
131
        $targetlinkuri = $targetlinkuri ?: 'https://'.$domain;
132
        // Stripping www as this is ignored for domain matching.
133
        $domain = lti_get_domain_from_url($domain);
134
        if ($domain !== lti_get_domain_from_url($targetlinkuri)) {
135
            throw new registration_exception('domain_targetlinkuri_mismatch', 400);
136
        }
137
 
138
        // Validate response type.
139
        // According to specification, for this scenario, id_token must be explicitly set.
140
        if (!in_array('id_token', $responsetypes)) {
141
            throw new registration_exception('invalid_response_types', 400);
142
        }
143
 
144
        // According to specification, this parameter needs to be an array.
145
        if (!is_array($redirecturis)) {
146
            throw new registration_exception('invalid_redirect_uris', 400);
147
        }
148
 
149
        // According to specification, for this scenario private_key_jwt must be explicitly set.
150
        if ($tokenendpointauthmethod !== 'private_key_jwt') {
151
            throw new registration_exception('invalid_token_endpoint_auth_method', 400);
152
        }
153
 
154
        if (!empty($applicationtype) && $applicationtype !== 'web') {
155
            throw new registration_exception('invalid_application_type', 400);
156
        }
157
 
158
        $config = new stdClass();
159
        $config->lti_clientid = $clientid;
160
        $config->lti_toolurl = $targetlinkuri;
161
        $config->lti_tooldomain = $domain;
162
        $config->lti_typename = $clientname;
163
        $config->lti_description = $description;
164
        $config->lti_ltiversion = LTI_VERSION_1P3;
165
        $config->lti_organizationid_default = LTI_DEFAULT_ORGID_SITEID;
166
        $config->lti_icon = $logouri;
167
        $config->lti_coursevisible = LTI_COURSEVISIBLE_PRECONFIGURED;
168
        $config->lti_contentitem = 0;
169
        // Sets Content Item.
170
        if (!empty($messages)) {
171
            $messagesresponse = [];
172
            foreach ($messages as $value) {
173
                if ($value['type'] === 'LtiDeepLinkingRequest') {
174
                    $config->lti_contentitem = 1;
175
                    $config->lti_toolurl_ContentItemSelectionRequest = $value['target_link_uri'] ?? '';
176
                    array_push($messagesresponse, $value);
177
                }
178
            }
179
        }
180
 
181
        $config->lti_keytype = 'JWK_KEYSET';
182
        $config->lti_publickeyset = $jwksuri;
183
        $config->lti_initiatelogin = $initiateloginuri;
184
        $config->lti_redirectionuris = implode(PHP_EOL, $redirecturis);
185
        $config->lti_customparameters = '';
186
        // Sets custom parameters.
187
        if (isset($customparameters)) {
188
            $paramssarray = [];
189
            foreach ($customparameters as $key => $value) {
190
                array_push($paramssarray, $key . '=' . $value);
191
            }
192
            $config->lti_customparameters = implode(PHP_EOL, $paramssarray);
193
        }
194
        // Sets launch container.
195
        $config->lti_launchcontainer = LTI_LAUNCH_CONTAINER_EMBED_NO_BLOCKS;
196
 
197
        // Sets Service info based on scopes.
198
        $config->lti_acceptgrades = LTI_SETTING_NEVER;
199
        $config->ltiservice_gradesynchronization = 0;
200
        $config->ltiservice_memberships = 0;
201
        $config->ltiservice_toolsettings = 0;
202
        if (isset($scopes)) {
203
            // Sets Assignment and Grade Services info.
204
 
205
            if (in_array(self::SCOPE_SCORE, $scopes)) {
206
                $config->lti_acceptgrades = LTI_SETTING_DELEGATE;
207
                $config->ltiservice_gradesynchronization = 1;
208
            }
209
            if (in_array(self::SCOPE_RESULT, $scopes)) {
210
                $config->lti_acceptgrades = LTI_SETTING_DELEGATE;
211
                $config->ltiservice_gradesynchronization = 1;
212
            }
213
            if (in_array(self::SCOPE_LINEITEM_RO, $scopes)) {
214
                $config->lti_acceptgrades = LTI_SETTING_DELEGATE;
215
                $config->ltiservice_gradesynchronization = 1;
216
            }
217
            if (in_array(self::SCOPE_LINEITEM, $scopes)) {
218
                $config->lti_acceptgrades = LTI_SETTING_DELEGATE;
219
                $config->ltiservice_gradesynchronization = 2;
220
            }
221
 
222
            // Sets Names and Role Provisioning info.
223
            if (in_array(self::SCOPE_NRPS, $scopes)) {
224
                $config->ltiservice_memberships = 1;
225
            }
226
 
227
            // Sets Tool Settings info.
228
            if (in_array(self::SCOPE_TOOL_SETTING, $scopes)) {
229
                $config->ltiservice_toolsettings = 1;
230
            }
231
        }
232
 
233
        // Sets privacy settings.
234
        $config->lti_sendname = LTI_SETTING_NEVER;
235
        $config->lti_sendemailaddr = LTI_SETTING_NEVER;
236
        if (isset($claims)) {
237
            // Sets name privacy settings.
238
 
239
            if (in_array('name', $claims)) {
240
                $config->lti_sendname = LTI_SETTING_ALWAYS;
241
            }
242
            if (in_array('given_name', $claims)) {
243
                $config->lti_sendname = LTI_SETTING_ALWAYS;
244
            }
245
            if (in_array('family_name', $claims)) {
246
                $config->lti_sendname = LTI_SETTING_ALWAYS;
247
            }
248
 
249
            // Sets email privacy settings.
250
            if (in_array('email', $claims)) {
251
                $config->lti_sendemailaddr = LTI_SETTING_ALWAYS;
252
            }
253
        }
254
        return $config;
255
    }
256
 
257
    /**
258
     * Adds to the config the LTI 1.1 key and sign it with the 1.1 secret.
259
     *
260
     * @param array $lticonfig reference to lticonfig to which to add the 1.1 OAuth info.
261
     * @param string $key - LTI 1.1 OAuth Key
262
     * @param string $secret - LTI 1.1 OAuth Secret
263
     *
264
     */
265
    private function add_previous_key_claim(array &$lticonfig, string $key, string $secret) {
266
        if ($key) {
267
            $oauthconsumer = [];
268
            $oauthconsumer['key'] = $key;
269
            $oauthconsumer['nonce'] = random_string(random_int(10, 20));
270
            $oauthconsumer['sign'] = hash('sha256', $key.$secret.$oauthconsumer['nonce']);
271
            $lticonfig['oauth_consumer'] = $oauthconsumer;
272
        }
273
    }
274
 
275
    /**
276
     * Transforms a moodle LTI 1.3 Config to an OAuth/LTI Client Registration.
277
     *
278
     * @param object $config Moodle LTI Config.
279
     * @param int $typeid which is the LTI deployment id.
280
     * @param object $type tool instance in case the tool already exists.
281
     *
282
     * @return array the Client Registration as an associative array.
283
     */
284
    public function config_to_registration(object $config, int $typeid, object $type = null): array {
285
        $configarray = [];
286
        foreach ((array)$config as $k => $v) {
287
            if (substr($k, 0, 4) == 'lti_') {
288
                $k = substr($k, 4);
289
            }
290
            $configarray[$k] = $v;
291
        }
292
        $config = (object) $configarray;
293
        $registrationresponse = [];
294
        $lticonfigurationresponse = [];
295
        $ltiversion = $type ? $type->ltiversion : $config->ltiversion;
296
        $lticonfigurationresponse['version'] = $ltiversion;
297
        if ($ltiversion === LTI_VERSION_1P3) {
298
            $registrationresponse['client_id'] = $type ? $type->clientid : $config->clientid;
299
            $registrationresponse['response_types'] = ['id_token'];
300
            $registrationresponse['jwks_uri'] = $config->publickeyset;
301
            $registrationresponse['initiate_login_uri'] = $config->initiatelogin;
302
            $registrationresponse['grant_types'] = ['client_credentials', 'implicit'];
303
            $registrationresponse['redirect_uris'] = explode(PHP_EOL, $config->redirectionuris);
304
            $registrationresponse['application_type'] = 'web';
305
            $registrationresponse['token_endpoint_auth_method'] = 'private_key_jwt';
306
        } else if ($ltiversion === LTI_VERSION_1 && $type) {
307
            $this->add_previous_key_claim($lticonfigurationresponse, $config->resourcekey, $config->password);
308
        } else if ($ltiversion === LTI_VERSION_2 && $type) {
309
            $toolproxy = $this->get_tool_proxy($type->toolproxyid);
310
            $this->add_previous_key_claim($lticonfigurationresponse, $toolproxy['guid'], $toolproxy['secret']);
311
        }
312
        $registrationresponse['client_name'] = $type ? $type->name : $config->typename;
313
        $registrationresponse['logo_uri'] = $type ? ($type->secureicon ?? $type->icon ?? '') : $config->icon ?? '';
314
        $lticonfigurationresponse['deployment_id'] = strval($typeid);
315
        $lticonfigurationresponse['target_link_uri'] = $type ? $type->baseurl : $config->toolurl ?? '';
316
        $lticonfigurationresponse['domain'] = $type ? $type->tooldomain : $config->tooldomain ?? '';
317
        $lticonfigurationresponse['description'] = $type ? $type->description ?? '' : $config->description ?? '';
318
        if ($config->contentitem ?? 0 == 1) {
319
            $contentitemmessage = [];
320
            $contentitemmessage['type'] = 'LtiDeepLinkingRequest';
321
            if (isset($config->toolurl_ContentItemSelectionRequest)) {
322
                $contentitemmessage['target_link_uri'] = $config->toolurl_ContentItemSelectionRequest;
323
            }
324
            $lticonfigurationresponse['messages'] = [$contentitemmessage];
325
        }
326
        if (isset($config->customparameters) && !empty($config->customparameters)) {
327
            $params = [];
328
            foreach (explode(PHP_EOL, $config->customparameters) as $param) {
329
                $split = explode('=', $param);
330
                $params[$split[0]] = $split[1];
331
            }
332
            $lticonfigurationresponse['custom_parameters'] = $params;
333
        }
334
        $scopesresponse = [];
335
        if ($config->ltiservice_gradesynchronization ?? 0 > 0) {
336
            $scopesresponse[] = self::SCOPE_SCORE;
337
            $scopesresponse[] = self::SCOPE_RESULT;
338
            $scopesresponse[] = self::SCOPE_LINEITEM_RO;
339
        }
340
        if ($config->ltiservice_gradesynchronization ?? 0 == 2) {
341
            $scopesresponse[] = self::SCOPE_LINEITEM;
342
        }
343
        if ($config->ltiservice_memberships ?? 0 == 1) {
344
            $scopesresponse[] = self::SCOPE_NRPS;
345
        }
346
        if ($config->ltiservice_toolsettings ?? 0 == 1) {
347
            $scopesresponse[] = self::SCOPE_TOOL_SETTING;
348
        }
349
        $registrationresponse['scope'] = implode(' ', $scopesresponse);
350
 
351
        $claimsresponse = ['sub', 'iss'];
352
        if ($config->sendname ?? '' == LTI_SETTING_ALWAYS) {
353
            $claimsresponse[] = 'name';
354
            $claimsresponse[] = 'family_name';
355
            $claimsresponse[] = 'given_name';
356
        }
357
        if ($config->sendemailaddr ?? '' == LTI_SETTING_ALWAYS) {
358
            $claimsresponse[] = 'email';
359
        }
360
        $lticonfigurationresponse['claims'] = $claimsresponse;
361
        $registrationresponse['https://purl.imsglobal.org/spec/lti-tool-configuration'] = $lticonfigurationresponse;
362
        return $registrationresponse;
363
    }
364
 
365
    /**
366
     * Validates the registration token is properly signed and not used yet.
367
     * Return the client id to use for this registration.
368
     *
369
     * @param string $registrationtokenjwt registration token
370
     *
371
     * @return array with 2 keys: clientid for the registration, type but only if it's an update
372
     */
373
    public function validate_registration_token(string $registrationtokenjwt): array {
374
        global $DB;
375
        // JWK::parseKeySet uses RS256 algorithm by default.
376
        $keys = JWK::parseKeySet(jwks_helper::get_jwks());
377
        $registrationtoken = JWT::decode($registrationtokenjwt, $keys);
378
        $response = [];
379
        // Get clientid from registrationtoken.
380
        $clientid = $registrationtoken->sub;
381
        if ($registrationtoken->scope == self::REG_TOKEN_OP_NEW_REG) {
382
            // Checks if clientid is already registered.
383
            if (!empty($DB->get_record('lti_types', array('clientid' => $clientid)))) {
384
                throw new registration_exception("token_already_used", 401);
385
            }
386
            $response['clientid'] = $clientid;
387
        } else if ($registrationtoken->scope == self::REG_TOKEN_OP_UPDATE_REG) {
388
            $tool = lti_get_type($registrationtoken->sub);
389
            if (!$tool) {
390
                throw new registration_exception("Unknown client", 400);
391
            }
392
            $response['clientid'] = $tool->clientid ?? $this->new_clientid();
393
            $response['type'] = $tool;
394
        } else {
395
            throw new registration_exception("Incorrect scope", 403);
396
        }
397
        return $response;
398
    }
399
 
400
    /**
401
     * Initializes an array with the scopes for services supported by the LTI module
402
     *
403
     * @return array List of scopes
404
     */
405
    public function lti_get_service_scopes() {
406
 
407
        $services = lti_get_services();
408
        $scopes = array();
409
        foreach ($services as $service) {
410
            $servicescopes = $service->get_scopes();
411
            if (!empty($servicescopes)) {
412
                $scopes = array_merge($scopes, $servicescopes);
413
            }
414
        }
415
        return $scopes;
416
    }
417
 
418
    /**
419
     * Generates a new client id string.
420
     *
421
     * @return string generated client id
422
     */
423
    public function new_clientid(): string {
424
        return random_string(15);
425
    }
426
 
427
    /**
428
     * Base64 encoded signature for LTI 1.1 migration.
429
     * @param string $key LTI 1.1 key
430
     * @param string $salt Salt value
431
     * @param string $secret LTI 1.1 secret
432
     *
433
     * @return string base64encoded hash
434
     */
435
    public function sign(string $key, string $salt, string $secret): string {
436
        return base64_encode(hash_hmac('sha-256', $key.$salt, $secret, true));
437
    }
438
 
439
    /**
440
     * Returns a tool proxy
441
     *
442
     * @param int $proxyid
443
     *
444
     * @return mixed Tool Proxy details
445
     */
446
    public function get_tool_proxy(int $proxyid): array {
447
        return lti_get_tool_proxy($proxyid);
448
    }
449
}