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