See Release Notes
Long Term Support Release
Differences Between: [Versions 310 and 401] [Versions 311 and 401] [Versions 39 and 401] [Versions 401 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 use auth_lti\local\ltiadvantage\entity\user_migration_claim; 18 19 defined('MOODLE_INTERNAL') || die(); 20 21 require_once($CFG->libdir.'/authlib.php'); 22 require_once($CFG->libdir.'/accesslib.php'); 23 24 /** 25 * LTI Authentication plugin. 26 * 27 * @package auth_lti 28 * @copyright 2016 Mark Nelson <markn@moodle.com> 29 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 30 */ 31 class auth_plugin_lti extends \auth_plugin_base { 32 33 /** 34 * @var int constant representing the automatic account provisioning mode. 35 * On first launch, for a previously unbound user, this mode dictates that a new Moodle account will be created automatically 36 * for the user and bound to their platform credentials {iss, sub}. 37 */ 38 public const PROVISIONING_MODE_AUTO_ONLY = 1; 39 40 /** 41 * @var int constant representing the prompt for new or existing provisioning mode. 42 * On first launch, for a previously unbound user, the mode dictates that the launch user will be presented with an options 43 * view, allowing them to select either 'link an existing account' or 'create a new account for me'. 44 */ 45 public const PROVISIONING_MODE_PROMPT_NEW_EXISTING = 2; 46 47 /** 48 * @var int constant representing the prompt for existing only provisioning mode. 49 * On first launch, for a previously unbound user, the mode dictates that the launch user will be presented with a view allowing 50 * them to link an existing account only. This is useful for situations like deep linking, where an existing account is needed. 51 */ 52 public const PROVISIONING_MODE_PROMPT_EXISTING_ONLY = 3; 53 54 /** 55 * Constructor. 56 */ 57 public function __construct() { 58 $this->authtype = 'lti'; 59 } 60 61 /** 62 * Users can not log in via the traditional login form. 63 * 64 * @param string $username The username 65 * @param string $password The password 66 * @return bool Authentication success or failure 67 */ 68 public function user_login($username, $password) { 69 return false; 70 } 71 72 /** 73 * Authenticate the user based on the unique {iss, sub} tuple present in the OIDC JWT. 74 * 75 * This method ensures a Moodle user account has been found or is created, that the user is linked to the relevant 76 * LTI Advantage credentials (iss, sub) and that the user account is logged in. 77 * 78 * Launch code can therefore rely on this method to get a session before doing things like calling require_login(). 79 * 80 * This method supports two workflows: 81 * 1. Automatic account provisioning - where the complete_login() call will ALWAYS create/find a user and return to 82 * calling code directly. No user interaction is required. 83 * 84 * 2. Manual account provisioning - where the complete_login() call will redirect ONLY ONCE to a login page, 85 * where the user can decide whether to use an automatically provisioned account, or bind an existing user account. 86 * When the decision has been made, the relevant account is bound and the user is redirected back to $returnurl. 87 * Once an account has been bound via this selection process, subsequent calls to complete_login() will return to 88 * calling code directly. Any calling code must provide its $returnurl to support the return from the account 89 * selection process and must also take care to cache any JWT data appropriately, since the return will not inlude 90 * that information. 91 * 92 * Which workflow is chosen depends on the roles present in the JWT. 93 * For teachers/admins, manual provisioning will take place. These user type are likely to have existing accounts. 94 * For learners, automatic provisioning will take place. 95 * 96 * Migration of legacy users is supported, however, only for the Learner role (automatic provisioning). Admins and 97 * teachers are likely to have existing accounts and we want them to be able to select and bind these, rather than 98 * binding an automatically provisioned legacy account which doesn't represent their real user account. 99 * 100 * The JWT data must be verified elsewhere. The code here assumes its integrity/authenticity. 101 * 102 * @param array $launchdata the JWT data provided in the link launch. 103 * @param moodle_url $returnurl the local URL to return to if authentication workflows are required. 104 * @param int $provisioningmode the desired account provisioning mode, which controls the auth flow for unbound users. 105 * @param array $legacyconsumersecrets an array of secrets used by the legacy consumer if a migration claim exists. 106 * @throws coding_exception if the specified provisioning mode is invalid. 107 */ 108 public function complete_login(array $launchdata, moodle_url $returnurl, int $provisioningmode, 109 array $legacyconsumersecrets = []): void { 110 111 // The platform user is already linked with a user account. 112 if ($this->get_user_binding($launchdata['iss'], $launchdata['sub'])) { 113 // Always sync the PII, regardless of whether we're already authenticated as this user or not. 114 $user = $this->find_or_create_user_from_launch($launchdata, true); 115 116 if (isloggedin()) { 117 // If a different user is currently logged in, authenticate the linked user instead. 118 global $USER; 119 if ((int) $USER->id !== $user->id) { 120 complete_user_login($user); 121 } 122 // If the linked user is already logged in, skip the call to complete_user_login() because this affects deep linking 123 // workflows on sites publishing and consuming resources on the same site, due to the regenerated sesskey. 124 return; 125 } else { 126 complete_user_login($user); 127 return; 128 } 129 } 130 131 // The platform user is not bound to a user account, check provisioning mode now. 132 if (!$this->is_valid_provisioning_mode($provisioningmode)) { 133 throw new coding_exception('Invalid account provisioning mode provided to complete_login().'); 134 } 135 136 switch ($provisioningmode) { 137 case self::PROVISIONING_MODE_AUTO_ONLY: 138 // Automatic provisioning - this will create/migrate a user account and log the user in. 139 complete_user_login($this->find_or_create_user_from_launch($launchdata, true, $legacyconsumersecrets)); 140 break; 141 case self::PROVISIONING_MODE_PROMPT_NEW_EXISTING: 142 case self::PROVISIONING_MODE_PROMPT_EXISTING_ONLY: 143 default: 144 // Allow linking an existing account or creation of a new account via an intermediate account options page. 145 // Cache the relevant data and take the user to the account options page. 146 // Note: This mode also depends on the global auth config 'authpreventaccountcreation'. If set, only existing 147 // accounts can be bound in this provisioning mode. 148 global $SESSION; 149 $SESSION->auth_lti = (object)[ 150 'launchdata' => $launchdata, 151 'returnurl' => $returnurl, 152 'provisioningmode' => $provisioningmode 153 ]; 154 redirect(new moodle_url('/auth/lti/login.php', [ 155 'sesskey' => sesskey(), 156 ])); 157 break; 158 } 159 } 160 161 /** 162 * Get a Moodle user account for the LTI user based on the user details returned by a NRPS 2 membership call. 163 * 164 * This method expects a single member structure, in array format, as defined here: 165 * See: https://www.imsglobal.org/spec/lti-nrps/v2p0#membership-container-media-type. 166 * 167 * This method supports migration of user accounts used in legacy launches, provided the legacy consumerkey corresponding to 168 * to the legacy consumer is provided. Calling code will have verified the migration claim during initial launches and should 169 * have the consumer key mapped to the deployment, ready to pass in. 170 * 171 * @param array $member the member data, in array format. 172 * @param string $iss the issuer to which the member relates. 173 * @param string $legacyconsumerkey optional consumer key mapped to the deployment to facilitate user migration. 174 * @return stdClass a Moodle user record. 175 */ 176 public function find_or_create_user_from_membership(array $member, string $iss, 177 string $legacyconsumerkey = ''): stdClass { 178 179 // Picture is not synced with membership-based auths because sync tasks may wish to perform slow operations like this a 180 // different way. 181 unset($member['picture']); 182 183 if ($binduser = $this->get_user_binding($iss, $member['user_id'])) { 184 $user = \core_user::get_user((int) $binduser); 185 $this->update_user_account($user, $member, $iss); 186 return \core_user::get_user($user->id); 187 } else { 188 if (!empty($legacyconsumerkey)) { 189 // Consumer key is required to attempt user migration because legacy users were identified by a 190 // username consisting of the consumer key and user id. 191 // See the legacy \enrol_lti\helper::create_username() for details. 192 $legacyuserid = $member['lti11_legacy_user_id'] ?? $member['user_id']; 193 $username = 'enrol_lti' . 194 sha1($legacyconsumerkey . '::' . $legacyconsumerkey . ':' . $legacyuserid); 195 if ($user = \core_user::get_user_by_username($username)) { 196 $this->create_user_binding($iss, $member['user_id'], $user->id); 197 $this->update_user_account($user, $member, $iss); 198 return \core_user::get_user($user->id); 199 } 200 } 201 $user = $this->create_new_account($member, $iss); 202 return \core_user::get_user($user->id); 203 } 204 } 205 206 /** 207 * Get a Moodle user account for the LTI user corresponding to the user defined in a link launch. 208 * 209 * This method supports migration of user accounts used in legacy launches, provided the legacy consumer secrets corresponding 210 * to the legacy consumer are provided. If calling code wishes migration to be role-specific, it should check roles accordingly 211 * itself and pass relevant data in - as auth_plugin_lti::complete_login() does. 212 * 213 * @param array $launchdata all data in the decoded JWT including iss and sub. 214 * @param bool $syncpicture whether to sync the user's picture with the picture sent in the launch. 215 * @param array $legacyconsumersecrets all secrets found for the legacy consumer, facilitating user migration. 216 * @return stdClass the Moodle user who is mapped to the platform user identified in the JWT data. 217 */ 218 public function find_or_create_user_from_launch(array $launchdata, bool $syncpicture = false, 219 array $legacyconsumersecrets = []): stdClass { 220 221 if (!$syncpicture) { 222 unset($launchdata['picture']); 223 } 224 225 if ($binduser = $this->get_user_binding($launchdata['iss'], $launchdata['sub'])) { 226 $user = \core_user::get_user((int) $binduser); 227 $this->update_user_account($user, $launchdata, $launchdata['iss']); 228 return \core_user::get_user($user->id); 229 } else { 230 // Is the intent to migrate a user account used in legacy launches? 231 if (!empty($legacyconsumersecrets)) { 232 try { 233 // Validate the migration claim and try to find a legacy user. 234 $usermigrationclaim = new user_migration_claim($launchdata, $legacyconsumersecrets); 235 $username = 'enrol_lti' . 236 sha1($usermigrationclaim->get_consumer_key() . '::' . 237 $usermigrationclaim->get_consumer_key() .':' .$usermigrationclaim->get_user_id()); 238 if ($user = \core_user::get_user_by_username($username)) { 239 $this->create_user_binding($launchdata['iss'], $launchdata['sub'], $user->id); 240 $this->update_user_account($user, $launchdata, $launchdata['iss']); 241 return \core_user::get_user($user->id); 242 } 243 } catch (Exception $e) { 244 // There was an issue validating the user migration claim. We don't want to fail auth entirely though. 245 // Rather, we want to fall back to new account creation and log the attempt. 246 debugging("There was a problem migrating the LTI user '{$launchdata['sub']}' on the platform " . 247 "'{$launchdata['iss']}'. The migration claim could not be validated. A new account will be created."); 248 } 249 } 250 $user = $this->create_new_account($launchdata, $launchdata['iss']); 251 $this->update_user_account($user, $launchdata, $launchdata['iss']); 252 return \core_user::get_user($user->id); 253 } 254 } 255 256 /** 257 * Create a binding between the LTI user, as identified by {iss, sub} tuple and the user id. 258 * 259 * @param string $iss the issuer URL identifying the platform to which to user belongs. 260 * @param string $sub the sub string identifying the user on the platform. 261 * @param int $userid the id of the Moodle user account to bind. 262 */ 263 public function create_user_binding(string $iss, string $sub, int $userid): void { 264 global $DB; 265 266 $timenow = time(); 267 $issuer256 = hash('sha256', $iss); 268 $sub256 = hash('sha256', $sub); 269 if ($DB->record_exists('auth_lti_linked_login', ['issuer256' => $issuer256, 'sub256' => $sub256])) { 270 return; 271 } 272 $rec = [ 273 'userid' => $userid, 274 'issuer' => $iss, 275 'issuer256' => $issuer256, 276 'sub' => $sub, 277 'sub256' => $sub256, 278 'timecreated' => $timenow, 279 'timemodified' => $timenow 280 ]; 281 $DB->insert_record('auth_lti_linked_login', $rec); 282 } 283 284 /** 285 * Gets the id of the linked Moodle user account for an LTI user, or null if not found. 286 * 287 * @param string $issuer the issuer to which the user belongs. 288 * @param string $sub the sub string identifying the user on the issuer. 289 * @return int|null the id of the corresponding Moodle user record, or null if not found. 290 */ 291 public function get_user_binding(string $issuer, string $sub): ?int { 292 global $DB; 293 $issuer256 = hash('sha256', $issuer); 294 $sub256 = hash('sha256', $sub); 295 try { 296 $binduser = $DB->get_field('auth_lti_linked_login', 'userid', 297 ['issuer256' => $issuer256, 'sub256' => $sub256], MUST_EXIST); 298 } catch (\dml_exception $e) { 299 $binduser = null; 300 } 301 return $binduser; 302 } 303 304 /** 305 * Check whether a provisioning mode is valid or not. 306 * 307 * @param int $mode the mode 308 * @return bool true if valid for use, false otherwise. 309 */ 310 protected function is_valid_provisioning_mode(int $mode): bool { 311 $validmodes = [ 312 self::PROVISIONING_MODE_AUTO_ONLY, 313 self::PROVISIONING_MODE_PROMPT_NEW_EXISTING, 314 self::PROVISIONING_MODE_PROMPT_EXISTING_ONLY 315 ]; 316 return in_array($mode, $validmodes); 317 } 318 319 /** 320 * Create a new user account based on the user data either in the launch JWT or from a membership call. 321 * 322 * @param array $userdata the user data coming from either a launch or membership service call. 323 * @param string $iss the issuer to which the user belongs. 324 * @return stdClass a complete Moodle user record. 325 */ 326 protected function create_new_account(array $userdata, string $iss): stdClass { 327 328 global $CFG; 329 require_once($CFG->dirroot.'/user/lib.php'); 330 331 // Launches and membership calls handle the user id differently. 332 // Launch uses 'sub', whereas member uses 'user_id'. 333 $userid = !empty($userdata['sub']) ? $userdata['sub'] : $userdata['user_id']; 334 335 $user = new stdClass(); 336 $user->username = 'enrol_lti_13_' . sha1($iss . '_' . $userid); 337 // If the email was stripped/not set then fill it with a default one. 338 // This stops the user from being redirected to edit their profile page. 339 $email = !empty($userdata['email']) ? $userdata['email'] : 340 'enrol_lti_13_' . sha1($iss . '_' . $userid) . "@example.com"; 341 $email = \core_user::clean_field($email, 'email'); 342 $user->email = $email; 343 $user->auth = 'lti'; 344 $user->mnethostid = $CFG->mnet_localhost_id; 345 $user->firstname = $userdata['given_name'] ?? $userid; 346 $user->lastname = $userdata['family_name'] ?? $iss; 347 $user->password = ''; 348 $user->confirmed = 1; 349 $user->id = user_create_user($user, false); 350 351 // Link this user with the LTI credentials for future use. 352 $this->create_user_binding($iss, $userid, $user->id); 353 354 return (object) get_complete_user_data('id', $user->id); 355 } 356 357 /** 358 * Update the personal fields of the user account, based on data present in either a launch of member sync call. 359 * 360 * @param stdClass $user the Moodle user account to update. 361 * @param array $userdata the user data coming from either a launch or membership service call. 362 * @param string $iss the issuer to which the user belongs. 363 */ 364 protected function update_user_account(stdClass $user, array $userdata, string $iss): void { 365 global $CFG; 366 require_once($CFG->dirroot.'/user/lib.php'); 367 if ($user->auth !== 'lti') { 368 return; 369 } 370 371 // Launches and membership calls handle the user id differently. 372 // Launch uses 'sub', whereas member uses 'user_id'. 373 $platformuserid = !empty($userdata['sub']) ? $userdata['sub'] : $userdata['user_id']; 374 $email = !empty($userdata['email']) ? $userdata['email'] : 375 'enrol_lti_13_' . sha1($iss . '_' . $platformuserid) . "@example.com"; 376 $email = \core_user::clean_field($email, 'email'); 377 $update = [ 378 'id' => $user->id, 379 'firstname' => $userdata['given_name'] ?? $platformuserid, 380 'lastname' => $userdata['family_name'] ?? $iss, 381 'email' => $email 382 ]; 383 $userfieldstocompare = array_intersect_key((array) $user, $update); 384 385 if (!empty(array_diff($update, $userfieldstocompare))) { 386 user_update_user($update); // Only update if there's a change. 387 } 388 389 if (!empty($userdata['picture'])) { 390 try { 391 $this->update_user_picture($user->id, $userdata['picture']); 392 } catch (Exception $e) { 393 debugging("Error syncing the profile picture for user '$user->id' during LTI authentication."); 394 } 395 } 396 } 397 398 /** 399 * Update the user's picture with the image stored at $url. 400 * 401 * @param int $userid the id of the user to update. 402 * @param string $url the string URL where the new image can be found. 403 * @throws moodle_exception if there were any problems updating the picture. 404 */ 405 protected function update_user_picture(int $userid, string $url): void { 406 global $CFG, $DB; 407 408 require_once($CFG->libdir . '/filelib.php'); 409 require_once($CFG->libdir . '/gdlib.php'); 410 411 $fs = get_file_storage(); 412 413 $context = \context_user::instance($userid, MUST_EXIST); 414 $fs->delete_area_files($context->id, 'user', 'newicon'); 415 416 $filerecord = array( 417 'contextid' => $context->id, 418 'component' => 'user', 419 'filearea' => 'newicon', 420 'itemid' => 0, 421 'filepath' => '/' 422 ); 423 424 $urlparams = array( 425 'calctimeout' => false, 426 'timeout' => 5, 427 'skipcertverify' => true, 428 'connecttimeout' => 5 429 ); 430 431 try { 432 $fs->create_file_from_url($filerecord, $url, $urlparams); 433 } catch (\file_exception $e) { 434 throw new moodle_exception(get_string($e->errorcode, $e->module, $e->a)); 435 } 436 437 $iconfile = $fs->get_area_files($context->id, 'user', 'newicon', false, 'itemid', false); 438 439 // There should only be one. 440 $iconfile = reset($iconfile); 441 442 // Something went wrong while creating temp file - remove the uploaded file. 443 if (!$iconfile = $iconfile->copy_content_to_temp()) { 444 $fs->delete_area_files($context->id, 'user', 'newicon'); 445 throw new moodle_exception('There was a problem copying the profile picture to temp.'); 446 } 447 448 // Copy file to temporary location and the send it for processing icon. 449 $newpicture = (int) process_new_icon($context, 'user', 'icon', 0, $iconfile); 450 // Delete temporary file. 451 @unlink($iconfile); 452 // Remove uploaded file. 453 $fs->delete_area_files($context->id, 'user', 'newicon'); 454 // Set the user's picture. 455 $DB->set_field('user', 'picture', $newpicture, array('id' => $userid)); 456 } 457 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body