Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.0.x will end 8 May 2023 (12 months).
  • Bug fixes for security issues in 4.0.x will end 13 November 2023 (18 months).
  • PHP version: minimum PHP 7.3.0 Note: the minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is also supported.
   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  namespace enrol_lti\local\ltiadvantage\service;
  18  
  19  use enrol_lti\helper;
  20  use enrol_lti\local\ltiadvantage\entity\context;
  21  use enrol_lti\local\ltiadvantage\entity\deployment;
  22  use enrol_lti\local\ltiadvantage\entity\migration_claim;
  23  use enrol_lti\local\ltiadvantage\entity\resource_link;
  24  use enrol_lti\local\ltiadvantage\entity\user;
  25  use enrol_lti\local\ltiadvantage\repository\application_registration_repository;
  26  use enrol_lti\local\ltiadvantage\repository\context_repository;
  27  use enrol_lti\local\ltiadvantage\repository\deployment_repository;
  28  use enrol_lti\local\ltiadvantage\repository\legacy_consumer_repository;
  29  use enrol_lti\local\ltiadvantage\repository\resource_link_repository;
  30  use enrol_lti\local\ltiadvantage\repository\user_repository;
  31  use Packback\Lti1p3\LtiMessageLaunch;
  32  
  33  /**
  34   * Class tool_launch_service.
  35   *
  36   * This class handles the launch of a resource by a user, using the LTI Advantage Resource Link Launch.
  37   *
  38   * See http://www.imsglobal.org/spec/lti/v1p3/#launch-from-a-resource-link
  39   *
  40   * @package enrol_lti
  41   * @copyright 2021 Jake Dallimore <jrhdallimore@gmail.com>
  42   * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  43   */
  44  class tool_launch_service {
  45  
  46      /** @var deployment_repository $deploymentrepo instance of a deployment repository. */
  47      private $deploymentrepo;
  48  
  49      /** @var application_registration_repository instance of a application_registration repository */
  50      private $registrationrepo;
  51  
  52      /** @var resource_link_repository instance of a resource_link repository */
  53      private $resourcelinkrepo;
  54  
  55      /** @var user_repository instance of a user repository*/
  56      private $userrepo;
  57  
  58      /** @var context_repository instance of a context repository  */
  59      private $contextrepo;
  60  
  61      /**
  62       * The tool_launch_service constructor.
  63       *
  64       * @param deployment_repository $deploymentrepo instance of a deployment_repository.
  65       * @param application_registration_repository $registrationrepo instance of an application_registration_repository.
  66       * @param resource_link_repository $resourcelinkrepo instance of a resource_link_repository.
  67       * @param user_repository $userrepo instance of a user_repository.
  68       * @param context_repository $contextrepo instance of a context_repository.
  69       */
  70      public function __construct(deployment_repository $deploymentrepo,
  71              application_registration_repository $registrationrepo, resource_link_repository $resourcelinkrepo,
  72              user_repository $userrepo, context_repository $contextrepo) {
  73  
  74          $this->deploymentrepo = $deploymentrepo;
  75          $this->registrationrepo = $registrationrepo;
  76          $this->resourcelinkrepo = $resourcelinkrepo;
  77          $this->userrepo = $userrepo;
  78          $this->contextrepo = $contextrepo;
  79      }
  80  
  81      /** Get the launch data from the launch.
  82       *
  83       * @param LtiMessageLaunch $launch the launch instance.
  84       * @return \stdClass the launch data.
  85       */
  86      private function get_launch_data(LtiMessageLaunch $launch): \stdClass {
  87          $launchdata = $launch->getLaunchData();
  88          $data = [
  89              'platform' => $launchdata['iss'],
  90              // The 'aud' property may be an array with one or more values, but can be a string if there is only one value.
  91              // https://www.imsglobal.org/spec/security/v1p1#id-token.
  92              'clientid' => is_array($launchdata['aud']) ? $launchdata['aud'][0] : $launchdata['aud'],
  93              'exp' => $launchdata['exp'],
  94              'nonce' => $launchdata['nonce'],
  95              'sub' => $launchdata['sub'],
  96              'roles' => $launchdata['https://purl.imsglobal.org/spec/lti/claim/roles'],
  97              'deploymentid' => $launchdata['https://purl.imsglobal.org/spec/lti/claim/deployment_id'],
  98              'context' => !empty($launchdata['https://purl.imsglobal.org/spec/lti/claim/context']) ?
  99                  $launchdata['https://purl.imsglobal.org/spec/lti/claim/context'] : null,
 100              'resourcelink' => $launchdata['https://purl.imsglobal.org/spec/lti/claim/resource_link'],
 101              'targetlinkuri' => $launchdata['https://purl.imsglobal.org/spec/lti/claim/target_link_uri'],
 102              'custom' => $launchdata['https://purl.imsglobal.org/spec/lti/claim/custom'] ?? null,
 103              'launchid' => $launch->getLaunchId(),
 104              'user' => [
 105                  'givenname' => !empty($launchdata['given_name']) ? $launchdata['given_name'] : null,
 106                  'familyname' => !empty($launchdata['family_name']) ? $launchdata['family_name'] : null,
 107                  'name' => !empty($launchdata['name']) ? $launchdata['name'] : null,
 108                  'email' => !empty($launchdata['email']) ? $launchdata['email'] : null,
 109                  'picture' => !empty($launchdata['picture']) ? $launchdata['picture'] : null,
 110              ],
 111              'ags' => $launchdata['https://purl.imsglobal.org/spec/lti-ags/claim/endpoint'] ?? null,
 112              'nrps' => $launchdata['https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice'] ?? null,
 113              'lti1p1' => $launchdata['https://purl.imsglobal.org/spec/lti/claim/lti1p1'] ?? null
 114          ];
 115  
 116          return (object) $data;
 117      }
 118  
 119      /**
 120       * Get a context instance from the launch data.
 121       *
 122       * @param \stdClass $launchdata launch data.
 123       * @param deployment $deployment the deployment to which the context belongs.
 124       * @return context the context instance.
 125       */
 126      private function context_from_launchdata(\stdClass $launchdata, deployment $deployment): context {
 127          if ($context = $this->contextrepo->find_by_contextid($launchdata->context['id'], $deployment->get_id())) {
 128              // The context has been mapped, just update it.
 129              $context->set_types($launchdata->context['type']);
 130          } else {
 131              // Map a new context.
 132              $context = $deployment->add_context($launchdata->context['id'], $launchdata->context['type']);
 133          }
 134          return $context;
 135      }
 136  
 137      /**
 138       * Get a resource_link from the launch data.
 139       *
 140       * @param \stdClass $launchdata the launch data.
 141       * @param \stdClass $resource the resource to which the resource link refers.
 142       * @param deployment $deployment the deployment to which the resource_link belongs.
 143       * @param context|null $context optional context in which the resource_link lives, null if not needed.
 144       * @return resource_link the resource_link instance.
 145       */
 146      private function resource_link_from_launchdata(\stdClass $launchdata, \stdClass $resource, deployment $deployment,
 147              ?context $context): resource_link {
 148  
 149          if ($resourcelink = $this->resourcelinkrepo->find_by_deployment($deployment, $launchdata->resourcelink['id'])) {
 150              // Resource link exists, so update it.
 151              if (isset($context)) {
 152                  $resourcelink->set_contextid($context->get_id());
 153              }
 154              // A resource link may have been updated, via content item selection, to refer to a different resource.
 155              if ($resourcelink->get_resourceid() != $resource->id) {
 156                  $resourcelink->set_resourceid($resource->id);
 157              }
 158          } else {
 159              // Create a new resource link.
 160              $resourcelink = $deployment->add_resource_link(
 161                  $launchdata->resourcelink['id'],
 162                  $resource->id,
 163                  $context ? $context->get_id() : null
 164              );
 165          }
 166          // Add the AGS configuration for the resource link.
 167          // See: http://www.imsglobal.org/spec/lti-ags/v2p0#assignment-and-grade-service-claim.
 168          if ($launchdata->ags && (!empty($launchdata->ags['lineitems']) || !empty($launchdata->ags['lineitem']))) {
 169              $resourcelink->add_grade_service(
 170                  !empty($launchdata->ags['lineitems']) ? new \moodle_url($launchdata->ags['lineitems']) : null,
 171                  !empty($launchdata->ags['lineitem']) ? new \moodle_url($launchdata->ags['lineitem']) : null,
 172                  $launchdata->ags['scope']
 173              );
 174          }
 175  
 176          // NRPS.
 177          if ($launchdata->nrps) {
 178              $resourcelink->add_names_and_roles_service(
 179                  new \moodle_url($launchdata->nrps['context_memberships_url']),
 180                  $launchdata->nrps['service_versions']
 181              );
 182          }
 183          return $resourcelink;
 184      }
 185  
 186      /**
 187       * Get an lti user instance from the launch data.
 188       *
 189       * @param \stdClass $user the moodle user object.
 190       * @param \stdClass $launchdata the launch data.
 191       * @param \stdClass $resource the resource to which the user belongs.
 192       * @param resource_link $resourcelink the resource_link from which the user originated.
 193       * @return user the user instance.
 194       */
 195      private function lti_user_from_launchdata(\stdClass $user, \stdClass $launchdata, \stdClass $resource,
 196              resource_link $resourcelink): user {
 197  
 198          // Find the user based on the unique-to-the-issuer 'sub' value.
 199          if ($ltiuser = $this->userrepo->find_single_user_by_resource($user->id, $resource->id)) {
 200              // User exists, so update existing based on resource data which may have changed.
 201              $ltiuser->set_resourcelinkid($resourcelink->get_id());
 202              $ltiuser->set_lang($resource->lang);
 203              $ltiuser->set_city($resource->city);
 204              $ltiuser->set_country($resource->country);
 205              $ltiuser->set_institution($resource->institution);
 206              $ltiuser->set_timezone($resource->timezone);
 207              $ltiuser->set_maildisplay($resource->maildisplay);
 208          } else {
 209              // Create the lti user.
 210              $ltiuser = $resourcelink->add_user(
 211                  $user->id,
 212                  $launchdata->sub,
 213                  $resource->lang,
 214                  $resource->city ?? '',
 215                  $resource->country ?? '',
 216                  $resource->institution ?? '',
 217                  $resource->timezone ?? '',
 218                  $resource->maildisplay ?? null,
 219              );
 220          }
 221          $ltiuser->set_lastaccess(time());
 222          return $ltiuser;
 223      }
 224  
 225      /**
 226       * Get the migration claim from the launch data, or null if not found.
 227       *
 228       * @param \stdClass $launchdata the launch data.
 229       * @return migration_claim|null the claim instance if present in the launch data, else null.
 230       */
 231      private function migration_claim_from_launchdata(\stdClass $launchdata): ?migration_claim {
 232          if (!isset($launchdata->lti1p1)) {
 233              return null;
 234          }
 235  
 236          // Despite the spec requiring the oauth_consumer_key field be present in the migration claim:
 237          // (see https://www.imsglobal.org/spec/lti/v1p3/migr#oauth_consumer_key),
 238          // Platforms may omit this field making migration impossible.
 239          // E.g. for Canvas launches taking place after an assignment_selection placement.
 240          if (empty($launchdata->lti1p1['oauth_consumer_key'])) {
 241              return null;
 242          }
 243  
 244          return new migration_claim($launchdata->lti1p1, $launchdata->deploymentid,
 245              $launchdata->platform, $launchdata->clientid, $launchdata->exp, $launchdata->nonce,
 246              new legacy_consumer_repository());
 247      }
 248  
 249      /**
 250       * Check whether the launch user has an admin role.
 251       *
 252       * @param \stdClass $launchdata the launch data.
 253       * @return bool true if the user is admin, false otherwise.
 254       */
 255      private function user_is_admin(\stdClass $launchdata): bool {
 256          // See: http://www.imsglobal.org/spec/lti/v1p3/#role-vocabularies.
 257          if ($launchdata->roles) {
 258              $adminroles = [
 259                  'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Administrator',
 260                  'http://purl.imsglobal.org/vocab/lis/v2/system/person#Administrator'
 261              ];
 262  
 263              foreach ($adminroles as $validrole) {
 264                  if (in_array($validrole, $launchdata->roles)) {
 265                      return true;
 266                  }
 267              }
 268          }
 269          return false;
 270      }
 271  
 272      /**
 273       * Check whether the launch user is an instructor.
 274       *
 275       * @param \stdClass $launchdata the launch data.
 276       * @param bool $includelegacy whether to also consider legacy simple names as valid roles.
 277       * @return bool true if the user is an instructor, false otherwise.
 278       */
 279      private function user_is_staff(\stdClass $launchdata, bool $includelegacy = false): bool {
 280          // See: http://www.imsglobal.org/spec/lti/v1p3/#role-vocabularies.
 281          // This method also provides support for (legacy, deprecated) simple names for context roles.
 282          // I.e. 'ContentDeveloper' may be supported.
 283          if ($launchdata->roles) {
 284              $staffroles = [
 285                  'http://purl.imsglobal.org/vocab/lis/v2/membership#ContentDeveloper',
 286                  'http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor',
 287                  'http://purl.imsglobal.org/vocab/lis/v2/membership/Instructor#TeachingAssistant'
 288              ];
 289  
 290              if ($includelegacy) {
 291                  $staffroles[] = 'ContentDeveloper';
 292                  $staffroles[] = 'Instructor';
 293                  $staffroles[] = 'Instructor#TeachingAssistant';
 294              }
 295  
 296              foreach ($staffroles as $validrole) {
 297                  if (in_array($validrole, $launchdata->roles)) {
 298                      return true;
 299                  }
 300              }
 301          }
 302          return false;
 303      }
 304  
 305      /**
 306       * Handles the use case "A user launches the tool so they can view an external resource".
 307       *
 308       * @param \stdClass $user the Moodle user record, obtained via the auth_lti authentication process.
 309       * @param LtiMessageLaunch $launch the launch data.
 310       * @return array array containing [int $userid, \stdClass $resource]
 311       * @throws \moodle_exception if launch problems are encountered.
 312       */
 313      public function user_launches_tool(\stdClass $user, LtiMessageLaunch $launch): array {
 314  
 315          $launchdata = $this->get_launch_data($launch);
 316  
 317          if (!$registration = $this->registrationrepo->find_by_platform($launchdata->platform, $launchdata->clientid)) {
 318              throw new \moodle_exception('ltiadvlauncherror:invalidregistration', 'enrol_lti', '',
 319                  [$launchdata->platform, $launchdata->clientid]);
 320          }
 321  
 322          if (!$deployment = $this->deploymentrepo->find_by_registration($registration->get_id(),
 323              $launchdata->deploymentid)) {
 324              throw new \moodle_exception('ltiadvlauncherror:invaliddeployment', 'enrol_lti', '',
 325                  [$launchdata->deploymentid]);
 326          }
 327  
 328          $resourceuuid = $launchdata->custom['id'] ?? null;
 329          if (empty($resourceuuid)) {
 330              throw new \moodle_exception('ltiadvlauncherror:missingid', 'enrol_lti');
 331          }
 332  
 333          $resource = array_values(helper::get_lti_tools(['uuid' => $resourceuuid]));
 334          $resource = $resource[0] ?? null;
 335          if (empty($resource) || $resource->status != ENROL_INSTANCE_ENABLED) {
 336              throw new \moodle_exception('ltiadvlauncherror:invalidid', 'enrol_lti', '', $resourceuuid);
 337          }
 338  
 339          // Update the deployment with the legacy consumer_key information, allowing migration of users to take place in future
 340          // names and roles syncs.
 341          if ($migrationclaim = $this->migration_claim_from_launchdata($launchdata)) {
 342              $deployment->set_legacy_consumer_key($migrationclaim->get_consumer_key());
 343              $this->deploymentrepo->save($deployment);
 344          }
 345  
 346          // Save the context, if that claim is present.
 347          $context = null;
 348          if ($launchdata->context) {
 349              $context = $this->context_from_launchdata($launchdata, $deployment);
 350              $context = $this->contextrepo->save($context);
 351          }
 352  
 353          // Save the resource link for the tool deployment.
 354          $resourcelink = $this->resource_link_from_launchdata($launchdata, $resource, $deployment, $context);
 355          $resourcelink = $this->resourcelinkrepo->save($resourcelink);
 356  
 357          // Save the user launching the resource link.
 358          $ltiuser = $this->lti_user_from_launchdata($user, $launchdata, $resource, $resourcelink);
 359          $ltiuser = $this->userrepo->save($ltiuser);
 360  
 361          // Set the frame embedding mode, which controls the display of blocks and nav when launching.
 362          global $SESSION;
 363          $context = \context::instance_by_id($resource->contextid);
 364          $isforceembed = $launchdata->custom['force_embed'] ?? false;
 365          $isinstructor = $this->user_is_staff($launchdata, true) || $this->user_is_admin($launchdata);
 366          $isforceembed = $isforceembed || ($context->contextlevel == CONTEXT_MODULE && !$isinstructor);
 367          if ($isforceembed) {
 368              $SESSION->forcepagelayout = 'embedded';
 369          } else {
 370              unset($SESSION->forcepagelayout);
 371          }
 372  
 373          // Enrol the user in the course with no role.
 374          $result = helper::enrol_user($resource, $ltiuser->get_localid());
 375          if ($result !== helper::ENROLMENT_SUCCESSFUL) {
 376              throw new \moodle_exception($result, 'enrol_lti');
 377          }
 378  
 379          // Give the user the role in the given context.
 380          $roleid = $isinstructor ? $resource->roleinstructor : $resource->rolelearner;
 381          role_assign($roleid, $ltiuser->get_localid(), $resource->contextid);
 382  
 383          return [$ltiuser->get_localid(), $resource];
 384      }
 385  
 386  }