diff --git a/.eslintignore b/.eslintignore index 14e635f1d25..a262404db97 100644 --- a/.eslintignore +++ b/.eslintignore @@ -64,6 +64,7 @@ lib/amd/src/adapter.js lib/validateurlsyntax.php lib/amd/src/popper.js lib/geopattern-php/ +lib/php-jwt/ media/player/videojs/amd/src/video-lazy.js media/player/videojs/amd/src/Youtube-lazy.js media/player/videojs/videojs/ diff --git a/.stylelintignore b/.stylelintignore index 128c0382de2..6bcaefed8cf 100644 --- a/.stylelintignore +++ b/.stylelintignore @@ -65,6 +65,7 @@ lib/amd/src/adapter.js lib/validateurlsyntax.php lib/amd/src/popper.js lib/geopattern-php/ +lib/php-jwt/ media/player/videojs/amd/src/video-lazy.js media/player/videojs/amd/src/Youtube-lazy.js media/player/videojs/videojs/ diff --git a/lib/classes/plugin_manager.php b/lib/classes/plugin_manager.php index 96c4537d97a..e84c049b5a8 100644 --- a/lib/classes/plugin_manager.php +++ b/lib/classes/plugin_manager.php @@ -1741,10 +1741,6 @@ class core_plugin_manager { 'gregorian' ), - 'customfield' => array( - 'checkbox', 'date', 'select', 'text', 'textarea' - ), - 'coursereport' => array( // Deprecated! ), @@ -1810,7 +1806,7 @@ class core_plugin_manager { ), 'ltiservice' => array( - 'gradebookservices', 'memberships', 'profile', 'toolproxy', 'toolsettings' + 'gradebookservices', 'memberships', 'profile', 'toolproxy', 'toolsettings', 'basicoutcomes' ), 'mlbackend' => array( diff --git a/lib/php-jwt/LICENSE b/lib/php-jwt/LICENSE new file mode 100644 index 00000000000..cb0c49b3311 --- /dev/null +++ b/lib/php-jwt/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2011, Neuman Vong + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Neuman Vong nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/lib/php-jwt/README.md b/lib/php-jwt/README.md new file mode 100644 index 00000000000..b1a7a3a2063 --- /dev/null +++ b/lib/php-jwt/README.md @@ -0,0 +1,200 @@ +[![Build Status](https://travis-ci.org/firebase/php-jwt.png?branch=master)](https://travis-ci.org/firebase/php-jwt) +[![Latest Stable Version](https://poser.pugx.org/firebase/php-jwt/v/stable)](https://packagist.org/packages/firebase/php-jwt) +[![Total Downloads](https://poser.pugx.org/firebase/php-jwt/downloads)](https://packagist.org/packages/firebase/php-jwt) +[![License](https://poser.pugx.org/firebase/php-jwt/license)](https://packagist.org/packages/firebase/php-jwt) + +PHP-JWT +======= +A simple library to encode and decode JSON Web Tokens (JWT) in PHP, conforming to [RFC 7519](https://tools.ietf.org/html/rfc7519). + +Installation +------------ + +Use composer to manage your dependencies and download PHP-JWT: + +```bash +composer require firebase/php-jwt +``` + +Example +------- +```php + "http://example.org", + "aud" => "http://example.com", + "iat" => 1356999524, + "nbf" => 1357000000 +); + +/** + * IMPORTANT: + * You must specify supported algorithms for your application. See + * https://tools.ietf.org/html/draft-ietf-jose-json-web-algorithms-40 + * for a list of spec-compliant algorithms. + */ +$jwt = JWT::encode($token, $key); +$decoded = JWT::decode($jwt, $key, array('HS256')); + +print_r($decoded); + +/* + NOTE: This will now be an object instead of an associative array. To get + an associative array, you will need to cast it as such: +*/ + +$decoded_array = (array) $decoded; + +/** + * You can add a leeway to account for when there is a clock skew times between + * the signing and verifying servers. It is recommended that this leeway should + * not be bigger than a few minutes. + * + * Source: http://self-issued.info/docs/draft-ietf-oauth-json-web-token.html#nbfDef + */ +JWT::$leeway = 60; // $leeway in seconds +$decoded = JWT::decode($jwt, $key, array('HS256')); + +?> +``` +Example with RS256 (openssl) +---------------------------- +```php + "example.org", + "aud" => "example.com", + "iat" => 1356999524, + "nbf" => 1357000000 +); + +$jwt = JWT::encode($token, $privateKey, 'RS256'); +echo "Encode:\n" . print_r($jwt, true) . "\n"; + +$decoded = JWT::decode($jwt, $publicKey, array('RS256')); + +/* + NOTE: This will now be an object instead of an associative array. To get + an associative array, you will need to cast it as such: +*/ + +$decoded_array = (array) $decoded; +echo "Decode:\n" . print_r($decoded_array, true) . "\n"; +?> +``` + +Changelog +--------- + +#### 5.0.0 / 2017-06-26 +- Support RS384 and RS512. + See [#117](https://github.com/firebase/php-jwt/pull/117). Thanks [@joostfaassen](https://github.com/joostfaassen)! +- Add an example for RS256 openssl. + See [#125](https://github.com/firebase/php-jwt/pull/125). Thanks [@akeeman](https://github.com/akeeman)! +- Detect invalid Base64 encoding in signature. + See [#162](https://github.com/firebase/php-jwt/pull/162). Thanks [@psignoret](https://github.com/psignoret)! +- Update `JWT::verify` to handle OpenSSL errors. + See [#159](https://github.com/firebase/php-jwt/pull/159). Thanks [@bshaffer](https://github.com/bshaffer)! +- Add `array` type hinting to `decode` method + See [#101](https://github.com/firebase/php-jwt/pull/101). Thanks [@hywak](https://github.com/hywak)! +- Add all JSON error types. + See [#110](https://github.com/firebase/php-jwt/pull/110). Thanks [@gbalduzzi](https://github.com/gbalduzzi)! +- Bugfix 'kid' not in given key list. + See [#129](https://github.com/firebase/php-jwt/pull/129). Thanks [@stampycode](https://github.com/stampycode)! +- Miscellaneous cleanup, documentation and test fixes. + See [#107](https://github.com/firebase/php-jwt/pull/107), [#115](https://github.com/firebase/php-jwt/pull/115), + [#160](https://github.com/firebase/php-jwt/pull/160), [#161](https://github.com/firebase/php-jwt/pull/161), and + [#165](https://github.com/firebase/php-jwt/pull/165). Thanks [@akeeman](https://github.com/akeeman), + [@chinedufn](https://github.com/chinedufn), and [@bshaffer](https://github.com/bshaffer)! + +#### 4.0.0 / 2016-07-17 +- Add support for late static binding. See [#88](https://github.com/firebase/php-jwt/pull/88) for details. Thanks to [@chappy84](https://github.com/chappy84)! +- Use static `$timestamp` instead of `time()` to improve unit testing. See [#93](https://github.com/firebase/php-jwt/pull/93) for details. Thanks to [@josephmcdermott](https://github.com/josephmcdermott)! +- Fixes to exceptions classes. See [#81](https://github.com/firebase/php-jwt/pull/81) for details. Thanks to [@Maks3w](https://github.com/Maks3w)! +- Fixes to PHPDoc. See [#76](https://github.com/firebase/php-jwt/pull/76) for details. Thanks to [@akeeman](https://github.com/akeeman)! + +#### 3.0.0 / 2015-07-22 +- Minimum PHP version updated from `5.2.0` to `5.3.0`. +- Add `\Firebase\JWT` namespace. See +[#59](https://github.com/firebase/php-jwt/pull/59) for details. Thanks to +[@Dashron](https://github.com/Dashron)! +- Require a non-empty key to decode and verify a JWT. See +[#60](https://github.com/firebase/php-jwt/pull/60) for details. Thanks to +[@sjones608](https://github.com/sjones608)! +- Cleaner documentation blocks in the code. See +[#62](https://github.com/firebase/php-jwt/pull/62) for details. Thanks to +[@johanderuijter](https://github.com/johanderuijter)! + +#### 2.2.0 / 2015-06-22 +- Add support for adding custom, optional JWT headers to `JWT::encode()`. See +[#53](https://github.com/firebase/php-jwt/pull/53/files) for details. Thanks to +[@mcocaro](https://github.com/mcocaro)! + +#### 2.1.0 / 2015-05-20 +- Add support for adding a leeway to `JWT:decode()` that accounts for clock skew +between signing and verifying entities. Thanks to [@lcabral](https://github.com/lcabral)! +- Add support for passing an object implementing the `ArrayAccess` interface for +`$keys` argument in `JWT::decode()`. Thanks to [@aztech-dev](https://github.com/aztech-dev)! + +#### 2.0.0 / 2015-04-01 +- **Note**: It is strongly recommended that you update to > v2.0.0 to address + known security vulnerabilities in prior versions when both symmetric and + asymmetric keys are used together. +- Update signature for `JWT::decode(...)` to require an array of supported + algorithms to use when verifying token signatures. + + +Tests +----- +Run the tests using phpunit: + +```bash +$ pear install PHPUnit +$ phpunit --configuration phpunit.xml.dist +PHPUnit 3.7.10 by Sebastian Bergmann. +..... +Time: 0 seconds, Memory: 2.50Mb +OK (5 tests, 5 assertions) +``` + +New Lines in private keys +----- + +If your private key contains `\n` characters, be sure to wrap it in double quotes `""` +and not single quotes `''` in order to properly interpret the escaped characters. + +License +------- +[3-Clause BSD](http://opensource.org/licenses/BSD-3-Clause). diff --git a/lib/php-jwt/composer.json b/lib/php-jwt/composer.json new file mode 100644 index 00000000000..b76ffd19106 --- /dev/null +++ b/lib/php-jwt/composer.json @@ -0,0 +1,29 @@ +{ + "name": "firebase/php-jwt", + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/firebase/php-jwt", + "authors": [ + { + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" + }, + { + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "license": "BSD-3-Clause", + "require": { + "php": ">=5.3.0" + }, + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "require-dev": { + "phpunit/phpunit": " 4.8.35" + } +} diff --git a/lib/php-jwt/readme_moodle.txt b/lib/php-jwt/readme_moodle.txt new file mode 100644 index 00000000000..6366cf86f2b --- /dev/null +++ b/lib/php-jwt/readme_moodle.txt @@ -0,0 +1,10 @@ +Description of php-jwt library import into Moodle + +Instructions +------------ +1. Visit [https://github.com/firebase/php-jwt]. +2. Click on 'X releases'. +3. Download the latest release. +4. Unzip it in lib as php-jwt. +5. Update entry for this library in lib/thirdpartylibs.xml. + diff --git a/lib/php-jwt/src/BeforeValidException.php b/lib/php-jwt/src/BeforeValidException.php new file mode 100644 index 00000000000..a6ee2f7c690 --- /dev/null +++ b/lib/php-jwt/src/BeforeValidException.php @@ -0,0 +1,7 @@ + + * @author Anant Narayanan + * @license http://opensource.org/licenses/BSD-3-Clause 3-clause BSD + * @link https://github.com/firebase/php-jwt + */ +class JWT +{ + + /** + * When checking nbf, iat or expiration times, + * we want to provide some extra leeway time to + * account for clock skew. + */ + public static $leeway = 180; + + /** + * Allow the current timestamp to be specified. + * Useful for fixing a value within unit testing. + * + * Will default to PHP time() value if null. + */ + public static $timestamp = null; + + public static $supported_algs = array( + 'HS256' => array('hash_hmac', 'SHA256'), + 'HS512' => array('hash_hmac', 'SHA512'), + 'HS384' => array('hash_hmac', 'SHA384'), + 'RS256' => array('openssl', 'SHA256'), + 'RS384' => array('openssl', 'SHA384'), + 'RS512' => array('openssl', 'SHA512'), + ); + + /** + * Decodes a JWT string into a PHP object. + * + * @param string $jwt The JWT + * @param string|array $key The key, or map of keys. + * If the algorithm used is asymmetric, this is the public key + * @param array $allowed_algs List of supported verification algorithms + * Supported algorithms are 'HS256', 'HS384', 'HS512' and 'RS256' + * + * @return object The JWT's payload as a PHP object + * + * @throws UnexpectedValueException Provided JWT was invalid + * @throws SignatureInvalidException Provided JWT was invalid because the signature verification failed + * @throws BeforeValidException Provided JWT is trying to be used before it's eligible as defined by 'nbf' + * @throws BeforeValidException Provided JWT is trying to be used before it's been created as defined by 'iat' + * @throws ExpiredException Provided JWT has since expired, as defined by the 'exp' claim + * + * @uses jsonDecode + * @uses urlsafeB64Decode + */ + public static function decode($jwt, $key, array $allowed_algs = array()) + { + $timestamp = is_null(static::$timestamp) ? time() : static::$timestamp; + + if (empty($key)) { + throw new InvalidArgumentException('Key may not be empty'); + } + $tks = explode('.', $jwt); + if (count($tks) != 3) { + throw new UnexpectedValueException('Wrong number of segments'); + } + list($headb64, $bodyb64, $cryptob64) = $tks; + if (null === ($header = static::jsonDecode(static::urlsafeB64Decode($headb64)))) { + throw new UnexpectedValueException('Invalid header encoding'); + } + if (null === $payload = static::jsonDecode(static::urlsafeB64Decode($bodyb64))) { + throw new UnexpectedValueException('Invalid claims encoding'); + } + if (false === ($sig = static::urlsafeB64Decode($cryptob64))) { + throw new UnexpectedValueException('Invalid signature encoding'); + } + if (empty($header->alg)) { + throw new UnexpectedValueException('Empty algorithm'); + } + if (empty(static::$supported_algs[$header->alg])) { + throw new UnexpectedValueException('Algorithm not supported'); + } + if (!in_array($header->alg, $allowed_algs)) { + throw new UnexpectedValueException('Algorithm not allowed'); + } + if (is_array($key) || $key instanceof \ArrayAccess) { + if (isset($header->kid)) { + if (!isset($key[$header->kid])) { + throw new UnexpectedValueException('"kid" invalid, unable to lookup correct key'); + } + $key = $key[$header->kid]; + } else { + throw new UnexpectedValueException('"kid" empty, unable to lookup correct key'); + } + } + + // Check the signature + if (!static::verify("$headb64.$bodyb64", $sig, $key, $header->alg)) { + throw new SignatureInvalidException('Signature verification failed'); + } + + // Check if the nbf if it is defined. This is the time that the + // token can actually be used. If it's not yet that time, abort. + if (isset($payload->nbf) && $payload->nbf > ($timestamp + static::$leeway)) { + throw new BeforeValidException( + 'Cannot handle token prior to ' . date(DateTime::ISO8601, $payload->nbf) + ); + } + + // Check that this token has been created before 'now'. This prevents + // using tokens that have been created for later use (and haven't + // correctly used the nbf claim). + if (isset($payload->iat) && $payload->iat > ($timestamp + static::$leeway)) { + throw new BeforeValidException( + 'Cannot handle token prior to ' . date(DateTime::ISO8601, $payload->iat) + ); + } + + // Check if this token has expired. + if (isset($payload->exp) && ($timestamp - static::$leeway) >= $payload->exp) { + throw new ExpiredException('Expired token'); + } + + return $payload; + } + + /** + * Converts and signs a PHP object or array into a JWT string. + * + * @param object|array $payload PHP object or array + * @param string $key The secret key. + * If the algorithm used is asymmetric, this is the private key + * @param string $alg The signing algorithm. + * Supported algorithms are 'HS256', 'HS384', 'HS512' and 'RS256' + * @param mixed $keyId + * @param array $head An array with header elements to attach + * + * @return string A signed JWT + * + * @uses jsonEncode + * @uses urlsafeB64Encode + */ + public static function encode($payload, $key, $alg = 'HS256', $keyId = null, $head = null) + { + $header = array('typ' => 'JWT', 'alg' => $alg); + if ($keyId !== null) { + $header['kid'] = $keyId; + } + if ( isset($head) && is_array($head) ) { + $header = array_merge($head, $header); + } + $segments = array(); + $segments[] = static::urlsafeB64Encode(static::jsonEncode($header)); + $segments[] = static::urlsafeB64Encode(static::jsonEncode($payload)); + $signing_input = implode('.', $segments); + + $signature = static::sign($signing_input, $key, $alg); + $segments[] = static::urlsafeB64Encode($signature); + + return implode('.', $segments); + } + + /** + * Sign a string with a given key and algorithm. + * + * @param string $msg The message to sign + * @param string|resource $key The secret key + * @param string $alg The signing algorithm. + * Supported algorithms are 'HS256', 'HS384', 'HS512' and 'RS256' + * + * @return string An encrypted message + * + * @throws DomainException Unsupported algorithm was specified + */ + public static function sign($msg, $key, $alg = 'HS256') + { + if (empty(static::$supported_algs[$alg])) { + throw new DomainException('Algorithm not supported'); + } + list($function, $algorithm) = static::$supported_algs[$alg]; + switch($function) { + case 'hash_hmac': + return hash_hmac($algorithm, $msg, $key, true); + case 'openssl': + $signature = ''; + $success = openssl_sign($msg, $signature, $key, $algorithm); + if (!$success) { + throw new DomainException("OpenSSL unable to sign data"); + } else { + return $signature; + } + } + } + + /** + * Verify a signature with the message, key and method. Not all methods + * are symmetric, so we must have a separate verify and sign method. + * + * @param string $msg The original message (header and body) + * @param string $signature The original signature + * @param string|resource $key For HS*, a string key works. for RS*, must be a resource of an openssl public key + * @param string $alg The algorithm + * + * @return bool + * + * @throws DomainException Invalid Algorithm or OpenSSL failure + */ + private static function verify($msg, $signature, $key, $alg) + { + if (empty(static::$supported_algs[$alg])) { + throw new DomainException('Algorithm not supported'); + } + + list($function, $algorithm) = static::$supported_algs[$alg]; + switch($function) { + case 'openssl': + $success = openssl_verify($msg, $signature, $key, $algorithm); + if ($success === 1) { + return true; + } elseif ($success === 0) { + return false; + } + // returns 1 on success, 0 on failure, -1 on error. + throw new DomainException( + 'OpenSSL error: ' . openssl_error_string() + ); + case 'hash_hmac': + default: + $hash = hash_hmac($algorithm, $msg, $key, true); + if (function_exists('hash_equals')) { + return hash_equals($signature, $hash); + } + $len = min(static::safeStrlen($signature), static::safeStrlen($hash)); + + $status = 0; + for ($i = 0; $i < $len; $i++) { + $status |= (ord($signature[$i]) ^ ord($hash[$i])); + } + $status |= (static::safeStrlen($signature) ^ static::safeStrlen($hash)); + + return ($status === 0); + } + } + + /** + * Decode a JSON string into a PHP object. + * + * @param string $input JSON string + * + * @return object Object representation of JSON string + * + * @throws DomainException Provided string was invalid JSON + */ + public static function jsonDecode($input) + { + if (version_compare(PHP_VERSION, '5.4.0', '>=') && !(defined('JSON_C_VERSION') && PHP_INT_SIZE > 4)) { + /** In PHP >=5.4.0, json_decode() accepts an options parameter, that allows you + * to specify that large ints (like Steam Transaction IDs) should be treated as + * strings, rather than the PHP default behaviour of converting them to floats. + */ + $obj = json_decode($input, false, 512, JSON_BIGINT_AS_STRING); + } else { + /** Not all servers will support that, however, so for older versions we must + * manually detect large ints in the JSON string and quote them (thus converting + *them to strings) before decoding, hence the preg_replace() call. + */ + $max_int_length = strlen((string) PHP_INT_MAX) - 1; + $json_without_bigints = preg_replace('/:\s*(-?\d{'.$max_int_length.',})/', ': "$1"', $input); + $obj = json_decode($json_without_bigints); + } + + if (function_exists('json_last_error') && $errno = json_last_error()) { + static::handleJsonError($errno); + } elseif ($obj === null && $input !== 'null') { + throw new DomainException('Null result with non-null input'); + } + return $obj; + } + + /** + * Encode a PHP object into a JSON string. + * + * @param object|array $input A PHP object or array + * + * @return string JSON representation of the PHP object or array + * + * @throws DomainException Provided object could not be encoded to valid JSON + */ + public static function jsonEncode($input) + { + $json = json_encode($input); + if (function_exists('json_last_error') && $errno = json_last_error()) { + static::handleJsonError($errno); + } elseif ($json === 'null' && $input !== null) { + throw new DomainException('Null result with non-null input'); + } + return $json; + } + + /** + * Decode a string with URL-safe Base64. + * + * @param string $input A Base64 encoded string + * + * @return string A decoded string + */ + public static function urlsafeB64Decode($input) + { + $remainder = strlen($input) % 4; + if ($remainder) { + $padlen = 4 - $remainder; + $input .= str_repeat('=', $padlen); + } + return base64_decode(strtr($input, '-_', '+/')); + } + + /** + * Encode a string with URL-safe Base64. + * + * @param string $input The string you want encoded + * + * @return string The base64 encode of what you passed in + */ + public static function urlsafeB64Encode($input) + { + return str_replace('=', '', strtr(base64_encode($input), '+/', '-_')); + } + + /** + * Helper method to create a JSON error. + * + * @param int $errno An error number from json_last_error() + * + * @return void + */ + private static function handleJsonError($errno) + { + $messages = array( + JSON_ERROR_DEPTH => 'Maximum stack depth exceeded', + JSON_ERROR_STATE_MISMATCH => 'Invalid or malformed JSON', + JSON_ERROR_CTRL_CHAR => 'Unexpected control character found', + JSON_ERROR_SYNTAX => 'Syntax error, malformed JSON', + JSON_ERROR_UTF8 => 'Malformed UTF-8 characters' //PHP >= 5.3.3 + ); + throw new DomainException( + isset($messages[$errno]) + ? $messages[$errno] + : 'Unknown JSON error: ' . $errno + ); + } + + /** + * Get the number of bytes in cryptographic strings. + * + * @param string + * + * @return int + */ + private static function safeStrlen($str) + { + if (function_exists('mb_strlen')) { + return mb_strlen($str, '8bit'); + } + return strlen($str); + } +} diff --git a/lib/php-jwt/src/SignatureInvalidException.php b/lib/php-jwt/src/SignatureInvalidException.php new file mode 100644 index 00000000000..27332b21bea --- /dev/null +++ b/lib/php-jwt/src/SignatureInvalidException.php @@ -0,0 +1,7 @@ +MIT 1.1.1 + + php-jwt + A simple library to encode and decode JSON Web Tokens (JWT) in PHP, conforming to RFC 7519 + BSD + 5.0.0 + 3-Clause + diff --git a/mod/lti/OAuthBody.php b/mod/lti/OAuthBody.php index 5b8033d1084..761b01c1618 100644 --- a/mod/lti/OAuthBody.php +++ b/mod/lti/OAuthBody.php @@ -39,13 +39,43 @@ defined('MOODLE_INTERNAL') || die; require_once($CFG->dirroot . '/mod/lti/OAuth.php'); require_once($CFG->dirroot . '/mod/lti/TrivialStore.php'); -function get_oauth_key_from_headers() { +/** + * + * @param int $typeid LTI type ID. + * @param string[] $scopes Array of scopes which give permission for the current request. + * + * @return string|int|boolean The OAuth consumer key, the LTI type ID for the validated bearer token, otherwise false. + */ +function get_oauth_key_from_headers($typeid = null, $scopes = null) { + global $DB; + + $now = time(); + $requestheaders = OAuthUtil::get_headers(); - if (@substr($requestheaders['Authorization'], 0, 6) == "OAuth ") { - $headerparameters = OAuthUtil::split_header($requestheaders['Authorization']); + if (isset($requestheaders['Authorization'])) { + if (substr($requestheaders['Authorization'], 0, 6) == "OAuth ") { + $headerparameters = OAuthUtil::split_header($requestheaders['Authorization']); - return format_string($headerparameters['oauth_consumer_key']); + return format_string($headerparameters['oauth_consumer_key']); + } else if (empty($scopes)) { + return true; + } else if (substr($requestheaders['Authorization'], 0, 7) == 'Bearer ') { + $tokenvalue = trim(substr($requestheaders['Authorization'], 7)); + $conditions = array('token' => $tokenvalue); + if (!empty($typeid)) { + $conditions['typeid'] = intval($typeid); + } + $token = $DB->get_record('lti_access_tokens', $conditions); + if ($token) { + // Log token access. + $DB->set_field('lti_access_tokens', 'lastaccess', $now, array('id' => $token->id)); + $permittedscopes = json_decode($token->scope); + if ((intval($token->validuntil) > $now) && !empty(array_intersect($scopes, $permittedscopes))) { + return intval($token->typeid); + } + } + } } return false; } @@ -63,7 +93,7 @@ function handle_oauth_body_post($oauthconsumerkey, $oauthconsumersecret, $body, } } - if (@substr($requestheaders['Authorization'], 0, 6) == "OAuth ") { + if (isset($requestheaders['Authorization']) && (substr($requestheaders['Authorization'], 0, 6) == "OAuth ")) { $headerparameters = OAuthUtil::split_header($requestheaders['Authorization']); $oauthbodyhash = $headerparameters['oauth_body_hash']; } diff --git a/mod/lti/amd/build/tool_card_controller.min.js b/mod/lti/amd/build/tool_card_controller.min.js index 177c7db0dba..cd988337244 100644 --- a/mod/lti/amd/build/tool_card_controller.min.js +++ b/mod/lti/amd/build/tool_card_controller.min.js @@ -1 +1 @@ -define(["jquery","core/ajax","core/notification","core/templates","mod_lti/tool_type","mod_lti/events","mod_lti/keys","core/str"],function(a,b,c,d,e,f,g,h){var i={DELETE_BUTTON:".delete",NAME_ELEMENT:".name",DESCRIPTION_ELEMENT:".description",CAPABILITIES_CONTAINER:".capabilities-container",ACTIVATE_BUTTON:".tool-card-footer a.activate"},j=2e3,k=function(a){return a.find(i.DELETE_BUTTON)},l=function(a){return a.find(i.NAME_ELEMENT)},m=function(a){return a.find(i.DESCRIPTION_ELEMENT)},n=function(a){return a.find(i.ACTIVATE_BUTTON)},o=function(a){return!!n(a).length},p=function(a){return a.find(i.CAPABILITIES_CONTAINER)},q=function(a){return!!p(a).length},r=function(a){return a.attr("data-type-id")},s=function(a){a.removeClass("announcement loading success fail capabilities")},t=function(a){s(a),a.addClass("announcement loading")},u=function(a){a.removeClass("announcement loading")},v=function(b){var c=a.Deferred();return s(b),b.addClass("announcement success"),setTimeout(function(){b.removeClass("announcement success"),c.resolve()},j),c},w=function(b){var c=a.Deferred();return s(b),b.addClass("announcement fail"),setTimeout(function(){b.removeClass("announcement fail"),c.resolve()},j),c},x=function(b){var d=a.Deferred(),f=r(b);return t(b),""===f?a.Deferred().resolve():(h.get_strings([{key:"delete",component:"mod_lti"},{key:"delete_confirmation",component:"mod_lti"},{key:"delete",component:"mod_lti"},{key:"cancel",component:"core"}]).done(function(a){c.confirm(a[0],a[1],a[2],a[3],function(){e["delete"](f).done(function(){u(b),v(b).done(function(){b.remove()}).fail(c.exception).always(function(){d.resolve()})}).fail(function(a){w(b),d.reject(a)})},function(){u(b),d.resolve()})}).fail(function(a){u(b),c.exception(a),d.reject(a)}),d)},y=function(a,b){a.attr("data-val-snapshot",b)},z=function(a){return a.attr("data-val-snapshot")},A=function(a){var b=m(a);if(!b.hasClass("loading")){var c=b.text().trim();y(b,c)}},B=function(b){var d=r(b);if(""===d)return a.Deferred().resolve();var f=m(b);if(f.hasClass("loading"))return a.Deferred().resolve();var g=f.text().trim(),h=z(f);if(h==g)return a.Deferred().resolve();f.addClass("loading");var i=e.update({id:d,description:g});return i.done(function(a){f.removeClass("loading"),f.text(a.description)}).fail(c.exception),i.fail(function(){f.removeClass("loading")}),i},C=function(a){var b=l(a);if(!b.hasClass("loading")){var c=b.text().trim();y(b,c)}},D=function(b){var c=r(b);if(""===c)return a.Deferred().resolve();var d=l(b);if(d.hasClass("loading"))return a.Deferred().resolve();var f=d.text().trim(),g=z(d);if(g==f)return a.Deferred().resolve();d.addClass("loading");var h=e.update({id:c,name:f});return h.done(function(a){d.removeClass("loading"),d.text(a.name)}),h.fail(function(){d.removeClass("loading")}),h},E=function(b){var c=r(b);if(""===c)return a.Deferred().resolve();t(b);var f=e.update({id:c,state:e.constants.state.configured});return f.then(function(a){return u(b),v(b),a}).then(function(a){return d.render("mod_lti/tool_card",a)}).then(function(a){var c=a[0],e=a[1];d.replaceNode(b,c,e)})["catch"](function(){u(b),w(b)}),f},F=function(a){a.addClass("announcement capabilities")},G=function(a){a.removeClass("announcement capabilities")},H=function(a){q(a)?F(a):E(a)},I=function(a){var b=k(a);b.click(function(b){b.preventDefault(),x(a)}),b.keypress(function(a){a.metaKey||a.shiftKey||a.altKey||a.ctrlKey||a.keyCode!=g.ENTER&&a.keyCode!=g.SPACE||(a.preventDefault(),b.click())});var c=m(a);c.focus(function(b){b.preventDefault(),A(a)}),c.blur(function(b){b.preventDefault(),B(a)}),c.keypress(function(a){a.metaKey||a.shiftKey||a.altKey||a.ctrlKey||a.keyCode==g.ENTER&&(a.preventDefault(),c.blur())});var d=l(a);if(d.focus(function(b){b.preventDefault(),C(a)}),d.blur(function(b){b.preventDefault(),D(a)}),d.keypress(function(a){a.metaKey||a.shiftKey||a.altKey||a.ctrlKey||a.keyCode==g.ENTER&&(a.preventDefault(),d.blur())}),o(a)){var e=n(a);e.click(function(b){b.preventDefault(),H(a)}),e.keypress(function(a){a.metaKey||a.shiftKey||a.altKey||a.ctrlKey||a.keyCode!=g.ENTER&&a.keyCode!=g.SPACE||(a.preventDefault(),e.click())})}if(q(a)){var h=p(a);h.on(f.CAPABILITIES_AGREE,function(){E(a)}),h.on(f.CAPABILITIES_DECLINE,function(){G(a)})}};return{init:function(a){I(a)}}}); \ No newline at end of file +define(["jquery","core/ajax","core/notification","core/templates","core/modal_factory","mod_lti/tool_type","mod_lti/events","mod_lti/keys","core/str"],function(a,b,c,d,e,f,g,h,i){var j={DELETE_BUTTON:".delete",NAME_ELEMENT:".name",DESCRIPTION_ELEMENT:".description",CAPABILITIES_CONTAINER:".capabilities-container",ACTIVATE_BUTTON:".tool-card-footer a.activate"},k=2e3,l=function(a){return a.find(j.DELETE_BUTTON)},m=function(a){return a.find(j.NAME_ELEMENT)},n=function(a){return a.find(j.DESCRIPTION_ELEMENT)},o=function(a){return a.find(j.ACTIVATE_BUTTON)},p=function(a){return!!o(a).length},q=function(a){return a.find(j.CAPABILITIES_CONTAINER)},r=function(a){return!!q(a).length},s=function(a){return a.attr("data-type-id")},t=function(a){a.removeClass("announcement loading success fail capabilities")},u=function(a){t(a),a.addClass("announcement loading")},v=function(a){a.removeClass("announcement loading")},w=function(b){var c=a.Deferred();return t(b),b.addClass("announcement success"),setTimeout(function(){b.removeClass("announcement success"),c.resolve()},k),c},x=function(b){var c=a.Deferred();return t(b),b.addClass("announcement fail"),setTimeout(function(){b.removeClass("announcement fail"),c.resolve()},k),c},y=function(b){var d=a.Deferred(),e=s(b);return u(b),""===e?a.Deferred().resolve():(i.get_strings([{key:"delete",component:"mod_lti"},{key:"delete_confirmation",component:"mod_lti"},{key:"delete",component:"mod_lti"},{key:"cancel",component:"core"}]).done(function(a){c.confirm(a[0],a[1],a[2],a[3],function(){f["delete"](e).done(function(){v(b),w(b).done(function(){b.remove()}).fail(c.exception).always(function(){d.resolve()})}).fail(function(a){x(b),d.reject(a)})},function(){v(b),d.resolve()})}).fail(function(a){v(b),c.exception(a),d.reject(a)}),d)},z=function(a,b){a.attr("data-val-snapshot",b)},A=function(a){return a.attr("data-val-snapshot")},B=function(a){var b=n(a);if(!b.hasClass("loading")){var c=b.text().trim();z(b,c)}},C=function(b){var d=s(b);if(""===d)return a.Deferred().resolve();var e=n(b);if(e.hasClass("loading"))return a.Deferred().resolve();var g=e.text().trim(),h=A(e);if(h==g)return a.Deferred().resolve();e.addClass("loading");var i=f.update({id:d,description:g});return i.done(function(a){e.removeClass("loading"),e.text(a.description)}).fail(c.exception),i.fail(function(){e.removeClass("loading")}),i},D=function(a){var b=m(a);if(!b.hasClass("loading")){var c=b.text().trim();z(b,c)}},E=function(b){var c=s(b);if(""===c)return a.Deferred().resolve();var d=m(b);if(d.hasClass("loading"))return a.Deferred().resolve();var e=d.text().trim(),g=A(d);if(g==e)return a.Deferred().resolve();d.addClass("loading");var h=f.update({id:c,name:e});return h.done(function(a){d.removeClass("loading"),d.text(a.name)}),h.fail(function(){d.removeClass("loading")}),h},F=function(b){var c=s(b);if(""===c)return a.Deferred().resolve();u(b);var e=f.update({id:c,state:f.constants.state.configured});return e.then(function(a){return v(b),w(b),a}).then(function(a){return d.render("mod_lti/tool_card",a)}).then(function(a){var c=a[0],e=a[1];d.replaceNode(b,c,e)})["catch"](function(){v(b),x(b)}),e},G=function(a){a.addClass("announcement capabilities")},H=function(a){a.removeClass("announcement capabilities")},I=function(a){r(a)?G(a):F(a)},J=function(a){var b=l(a);b.click(function(b){b.preventDefault(),y(a)}),b.keypress(function(a){a.metaKey||a.shiftKey||a.altKey||a.ctrlKey||a.keyCode!=h.ENTER&&a.keyCode!=h.SPACE||(a.preventDefault(),b.click())});var c=n(a);c.focus(function(b){b.preventDefault(),B(a)}),c.blur(function(b){b.preventDefault(),C(a)}),c.keypress(function(a){a.metaKey||a.shiftKey||a.altKey||a.ctrlKey||a.keyCode==h.ENTER&&(a.preventDefault(),c.blur())});var d=m(a);if(d.focus(function(b){b.preventDefault(),D(a)}),d.blur(function(b){b.preventDefault(),E(a)}),d.keypress(function(a){a.metaKey||a.shiftKey||a.altKey||a.ctrlKey||a.keyCode==h.ENTER&&(a.preventDefault(),d.blur())}),p(a)){var e=o(a);e.click(function(b){b.preventDefault(),I(a)}),e.keypress(function(a){a.metaKey||a.shiftKey||a.altKey||a.ctrlKey||a.keyCode!=h.ENTER&&a.keyCode!=h.SPACE||(a.preventDefault(),e.click())})}if(r(a)){var f=q(a);f.on(g.CAPABILITIES_AGREE,function(){F(a)}),f.on(g.CAPABILITIES_DECLINE,function(){H(a)})}},K=function(b){var c=a("#"+b.data("uniqid")+"-"+b.data("deploymentid")),f={uniqid:b.data("uniqid"),platformid:b.data("platformid"),clientid:b.data("clientid"),deploymentid:b.data("deploymentid"),urls:{publickeyset:b.data("publickeyseturl"),accesstoken:b.data("accesstokenurl"),authrequest:b.data("authrequesturl")}},g=d.render("mod_lti/tool_config_modal_body",f),h="mailto:?subject="+encodeURIComponent(b.data("mailtosubject"))+"&body="+encodeURIComponent(b.data("platformidstr"))+":%20"+encodeURIComponent(b.data("platformid"))+"%0D%0A"+encodeURIComponent(b.data("clientidstr"))+":%20"+encodeURIComponent(b.data("clientid"))+"%0D%0A"+encodeURIComponent(b.data("deploymentidstr"))+":%20"+encodeURIComponent(b.data("deploymentid"))+"%0D%0A"+encodeURIComponent(b.data("publickeyseturlstr"))+":%20"+encodeURIComponent(b.data("publickeyseturl"))+"%0D%0A"+encodeURIComponent(b.data("accesstokenurlstr"))+":%20"+encodeURIComponent(b.data("accesstokenurl"))+"%0D%0A"+encodeURIComponent(b.data("authrequesturlstr"))+":%20"+encodeURIComponent(b.data("authrequesturl"))+"%0D%0A";f={mailto:h};var i=d.render("mod_lti/tool_config_modal_footer",f);e.create({large:!0,title:b.data("modaltitle"),body:g,footer:i},c)};return{init:function(a){J(a),K(a)}}}); \ No newline at end of file diff --git a/mod/lti/amd/src/tool_card_controller.js b/mod/lti/amd/src/tool_card_controller.js index 4050f724f65..763037f045a 100644 --- a/mod/lti/amd/src/tool_card_controller.js +++ b/mod/lti/amd/src/tool_card_controller.js @@ -26,9 +26,10 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since 3.1 */ -define(['jquery', 'core/ajax', 'core/notification', 'core/templates', 'mod_lti/tool_type', 'mod_lti/events', 'mod_lti/keys', + define(['jquery', 'core/ajax', 'core/notification', 'core/templates', 'core/modal_factory', + 'mod_lti/tool_type', 'mod_lti/events', 'mod_lti/keys', 'core/str'], - function($, ajax, notification, templates, toolType, ltiEvents, KEYS, str) { + function($, ajax, notification, templates, modalFactory, toolType, ltiEvents, KEYS, str) { var SELECTORS = { DELETE_BUTTON: '.delete', @@ -631,6 +632,52 @@ define(['jquery', 'core/ajax', 'core/notification', 'core/templates', 'mod_lti/t } }; + /** + * Sets up the templates for the tool configuration modal on this tool type card. + * + * @method registerModal + * @private + * @param {JQuery} element jQuery object representing the tool card. + */ + var registerModal = function(element) { + var trigger = $('#' + element.data('uniqid') + '-' + element.data('deploymentid')); + var context = { + 'uniqid': element.data('uniqid'), + 'platformid': element.data('platformid'), + 'clientid': element.data('clientid'), + 'deploymentid': element.data('deploymentid'), + 'urls': { + 'publickeyset': element.data('publickeyseturl'), + 'accesstoken': element.data('accesstokenurl'), + 'authrequest': element.data('authrequesturl') + } + }; + var bodyPromise = templates.render('mod_lti/tool_config_modal_body', context); + var mailTo = 'mailto:?subject=' + encodeURIComponent(element.data('mailtosubject')) + + '&body=' + encodeURIComponent(element.data('platformidstr')) + ':%20' + + encodeURIComponent(element.data('platformid')) + '%0D%0A' + + encodeURIComponent(element.data('clientidstr')) + ':%20' + + encodeURIComponent(element.data('clientid')) + '%0D%0A' + + encodeURIComponent(element.data('deploymentidstr')) + ':%20' + + encodeURIComponent(element.data('deploymentid')) + '%0D%0A' + + encodeURIComponent(element.data('publickeyseturlstr')) + ':%20' + + encodeURIComponent(element.data('publickeyseturl')) + '%0D%0A' + + encodeURIComponent(element.data('accesstokenurlstr')) + ':%20' + + encodeURIComponent(element.data('accesstokenurl')) + '%0D%0A' + + encodeURIComponent(element.data('authrequesturlstr')) + ':%20' + + encodeURIComponent(element.data('authrequesturl')) + '%0D%0A'; + context = { + 'mailto': mailTo + }; + var footerPromise = templates.render('mod_lti/tool_config_modal_footer', context); + modalFactory.create({ + large: true, + title: element.data('modaltitle'), + body: bodyPromise, + footer: footerPromise, + }, trigger); + }; + return /** @alias module:mod_lti/tool_card_controller */ { /** @@ -640,6 +687,7 @@ define(['jquery', 'core/ajax', 'core/notification', 'core/templates', 'mod_lti/t */ init: function(element) { registerEventListeners(element); + registerModal(element); } }; }); diff --git a/mod/lti/auth.php b/mod/lti/auth.php new file mode 100644 index 00000000000..6f4da8a1db7 --- /dev/null +++ b/mod/lti/auth.php @@ -0,0 +1,155 @@ +. + +/** + * This file returns an array of available public keys + * + * @package mod_lti + * @copyright 2019 Stephen Vickers + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +require_once(__DIR__ . '/../../config.php'); +require_once($CFG->dirroot . '/mod/lti/locallib.php'); + +$scope = optional_param('scope', '', PARAM_TEXT); +$responsetype = optional_param('response_type', '', PARAM_TEXT); +$clientid = optional_param('client_id', '', PARAM_TEXT); +$redirecturi = optional_param('redirect_uri', '', PARAM_TEXT); +$loginhint = optional_param('login_hint', '', PARAM_TEXT); +$ltimessagehint = optional_param('lti_message_hint', 0, PARAM_INT); +$state = optional_param('state', '', PARAM_TEXT); +$responsemode = optional_param('response_mode', '', PARAM_TEXT); +$nonce = optional_param('nonce', '', PARAM_TEXT); +$prompt = optional_param('prompt', '', PARAM_TEXT); + +$ok = !empty($scope) && !empty($responsetype) && !empty($clientid) && + !empty($redirecturi) && !empty($loginhint) && !empty($scope) && + !empty($nonce) && !empty($SESSION->lti_message_hint); + +if (!$ok) { + $error = 'invalid_request'; +} +if ($ok && ($scope !== 'openid')) { + $ok = false; + $error = 'invalid_scope'; +} +if ($ok && ($responsetype !== 'id_token')) { + $ok = false; + $error = 'unsupported_response_type'; +} +if ($ok) { + list($courseid, $typeid, $id, $titleb64, $textb64) = explode(',', $SESSION->lti_message_hint, 5); + $ok = ($id !== $ltimessagehint); + if (!$ok) { + $error = 'invalid_request'; + } else { + $config = lti_get_type_type_config($typeid); + $ok = ($clientid === $config->lti_clientid); + if (!$ok) { + $error = 'unauthorized_client'; + } + } +} +if ($ok && ($loginhint !== $USER->id)) { + $ok = false; + $error = 'access_denied'; +} +if ($ok) { + $uris = array_map("trim", explode("\n", $config->lti_redirectionuris)); + $ok = in_array($redirecturi, $uris); + if (!$ok) { + $error = 'invalid_request'; + $desc = 'Unregistered redirect_uri ' . $redirecturi; + } +} +if ($ok) { + if (isset($responsemode)) { + $ok = ($responsemode === 'form_post'); + if (!$ok) { + $error = 'invalid_request'; + $desc = 'Invalid response_mode'; + } + } else { + $responsemode = 'query'; + } +} +if ($ok && !empty($prompt) && ($prompt !== 'none')) { + $ok = false; + $error = 'invalid_request'; + $desc = 'Invalid prompt'; +} + +if ($ok) { + $course = $DB->get_record('course', array('id' => $courseid), '*', MUST_EXIST); + if ($id) { + $cm = get_coursemodule_from_id('lti', $id, 0, false, MUST_EXIST); + $context = context_module::instance($cm->id); + require_login($course, true, $cm); + require_capability('mod/lti:view', $context); + $lti = $DB->get_record('lti', array('id' => $cm->instance), '*', MUST_EXIST); + list($endpoint, $params) = lti_get_launch_data($lti, $nonce); + } else { + require_login($course); + $context = context_course::instance($courseid); + require_capability('moodle/course:manageactivities', $context); + require_capability('mod/lti:addcoursetool', $context); + // Set the return URL. We send the launch container along to help us avoid frames-within-frames when the user returns. + $returnurlparams = [ + 'course' => $courseid, + 'id' => $typeid, + 'sesskey' => sesskey() + ]; + $returnurl = new \moodle_url('/mod/lti/contentitem_return.php', $returnurlparams); + // Prepare the request. + $title = base64_decode($titleb64); + $text = base64_decode($textb64); + $request = lti_build_content_item_selection_request($typeid, $course, $returnurl, $title, $text, + [], [], false, false, false, false, false, $nonce); + $endpoint = $request->url; + $params = $request->params; + } +} else { + $params['error'] = $error; + if (!empty($desc)) { + $params['error_description'] = $desc; + } +} +if (isset($state)) { + $params['state'] = $state; +} +unset($SESSION->lti_message_hint); +if ($responsemode !== 'query') { + $r = '
\n"; + if (!empty($params)) { + foreach ($params as $key => $value) { + $key = htmlspecialchars($key); + $value = htmlspecialchars($value); + $r .= " \n"; + } + } + $r .= "
\n"; + $r .= "\n"; + echo $r; +} else { + $url = new \moodle_url(redirecturi, $params); + redirect($url->out(false)); +} diff --git a/mod/lti/certs.php b/mod/lti/certs.php new file mode 100644 index 00000000000..4618aa1167b --- /dev/null +++ b/mod/lti/certs.php @@ -0,0 +1,48 @@ +. + +/** + * This file returns an array of available public keys + * + * @package mod_lti + * @copyright 2019 Stephen Vickers + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +define('NO_DEBUG_DISPLAY', true); +define('NO_MOODLE_COOKIES', true); + +require_once(__DIR__ . '/../../config.php'); + +$jwks = array('keys' => array()); + +$privatekey = get_config('mod_lti', 'privatekey'); +$res = openssl_pkey_get_private($privatekey); +$details = openssl_pkey_get_details($res); + +$jwk = array(); +$jwk['kty'] = 'RSA'; +$jwk['alg'] = 'RS256'; +$jwk['kid'] = get_config('mod_lti', 'kid'); +$jwk['e'] = strtr(base64_encode($details['rsa']['e']), '+/', '-_'); +$jwk['n'] = strtr(base64_encode($details['rsa']['n']), '+/', '-_'); +$jwk['use'] = 'sig'; + +$jwks['keys'][] = $jwk; + +@header('Content-Type: application/json; charset=utf-8'); + +echo json_encode($jwks, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); diff --git a/mod/lti/classes/external.php b/mod/lti/classes/external.php index e9ca83e7a4b..b6e081ee14b 100644 --- a/mod/lti/classes/external.php +++ b/mod/lti/classes/external.php @@ -53,11 +53,17 @@ class mod_lti_external extends external_api { 'id' => new external_value(PARAM_INT, 'Tool type id'), 'name' => new external_value(PARAM_NOTAGS, 'Tool type name'), 'description' => new external_value(PARAM_NOTAGS, 'Tool type description'), + 'platformid' => new external_value(PARAM_TEXT, 'Platform ID'), + 'clientid' => new external_value(PARAM_TEXT, 'Client ID'), + 'deploymentid' => new external_value(PARAM_INT, 'Deployment ID'), 'urls' => new external_single_structure( array( 'icon' => new external_value(PARAM_URL, 'Tool type icon URL'), 'edit' => new external_value(PARAM_URL, 'Tool type edit URL'), 'course' => new external_value(PARAM_URL, 'Tool type edit URL', VALUE_OPTIONAL), + 'publickeyset' => new external_value(PARAM_URL, 'Public Keyset URL'), + 'accesstoken' => new external_value(PARAM_URL, 'Access Token URL'), + 'authrequest' => new external_value(PARAM_URL, 'Authorisation Request URL'), ) ), 'state' => new external_single_structure( diff --git a/mod/lti/classes/local/ltiservice/resource_base.php b/mod/lti/classes/local/ltiservice/resource_base.php index 32a02f83692..19fefa51081 100644 --- a/mod/lti/classes/local/ltiservice/resource_base.php +++ b/mod/lti/classes/local/ltiservice/resource_base.php @@ -184,13 +184,18 @@ abstract class resource_base { public function get_endpoint() { $this->parse_template(); - $url = $this->get_service()->get_service_path() . $this->get_template(); + $template = preg_replace('/[\(\)]/', '', $this->get_template()); + $url = $this->get_service()->get_service_path() . $template; foreach ($this->params as $key => $value) { $url = str_replace('{' . $key . '}', $value, $url); } $toolproxy = $this->get_service()->get_tool_proxy(); if (!empty($toolproxy)) { + $url = str_replace('{config_type}', 'toolproxy', $url); $url = str_replace('{tool_proxy_id}', $toolproxy->guid, $url); + } else { + $url = str_replace('{config_type}', 'tool', $url); + $url = str_replace('{tool_proxy_id}', $this->get_service()->get_type()->id, $url); } return $url; @@ -204,6 +209,52 @@ abstract class resource_base { */ public abstract function execute($response); + /** + * Check to make sure the request is valid. + * + * @param int $typeid The typeid we want to use + * @param string $body Body of HTTP request message + * @param string[] $scopes Array of scope(s) required for incoming request + * + * @return boolean + */ + public function check_tool($typeid, $body = null, $scopes = null) { + + $ok = $this->get_service()->check_tool($typeid, $body, $scopes); + if ($ok) { + if ($this->get_service()->get_tool_proxy()) { + $toolproxyjson = $this->get_service()->get_tool_proxy()->toolproxy; + } + if (!empty($toolproxyjson)) { + // Check tool proxy to ensure service being requested is included. + $toolproxy = json_decode($toolproxyjson); + if (!empty($toolproxy) && isset($toolproxy->security_contract->tool_service)) { + $contexts = lti_get_contexts($toolproxy); + $tpservices = $toolproxy->security_contract->tool_service; + foreach ($tpservices as $service) { + $fqid = lti_get_fqid($contexts, $service->service); + $id = explode('#', $fqid, 2); + if ($this->get_id() === $id[1]) { + $ok = true; + break; + } + } + } + if (!$ok) { + debugging('Requested service not permitted: ' . $this->get_id(), DEBUG_DEVELOPER); + } + } else { + // Check that the scope required for the service request is included in those granted for the + // access token being used. + $permittedscopes = $this->get_service()->get_permitted_scopes(); + $ok = is_null($permittedscopes) || empty($scopes) || !empty(array_intersect($permittedscopes, $scopes)); + } + } + + return $ok; + + } + /** * Check to make sure the request is valid. * @@ -211,9 +262,13 @@ abstract class resource_base { * @param string $body Body of HTTP request message * * @return boolean + * @deprecated since Moodle 3.7 MDL-62599 - please do not use this function any more. + * @see resource_base::check_tool() */ public function check_tool_proxy($toolproxyguid, $body = null) { + debugging('check_tool_proxy() is deprecated to allow LTI 1 connections to support services. ' . + 'Please use resource_base::check_tool() instead.', DEBUG_DEVELOPER); $ok = false; if ($this->get_service()->check_tool_proxy($toolproxyguid, $body)) { $toolproxyjson = $this->get_service()->get_tool_proxy()->toolproxy; @@ -234,7 +289,7 @@ abstract class resource_base { } } if (!$ok) { - debugging('Requested service not included in tool proxy: ' . $this->get_id(), DEBUG_DEVELOPER); + debugging('Requested service not included in tool proxy: ' . $this->get_id()); } } } @@ -252,8 +307,12 @@ abstract class resource_base { * @param string $body Body of HTTP request message * * @return boolean + * @deprecated since Moodle 3.7 MDL-62599 - please do not use this function any more. + * @see resource_base::check_tool() */ public function check_type($typeid, $contextid, $permissionrequested, $body = null) { + debugging('check_type() is deprecated to allow LTI 1 connections to support services. ' . + 'Please use resource_base::check_tool() instead.', DEBUG_DEVELOPER); $ok = false; if ($this->get_service()->check_type($typeid, $contextid, $body)) { $neededpermissions = $this->get_permissions($typeid); @@ -269,7 +328,6 @@ abstract class resource_base { } } return $ok; - } /** @@ -277,8 +335,12 @@ abstract class resource_base { * * @param int $ltitype Type of LTI * @return array with the permissions related to this resource by the $ltitype or empty if none. + * @deprecated since Moodle 3.7 MDL-62599 - please do not use this function any more. + * @see resource_base::check_tool() */ public function get_permissions($ltitype) { + debugging('get_permissions() is deprecated to allow LTI 1 connections to support services. ' . + 'Please use resource_base::check_tool() instead.', DEBUG_DEVELOPER); return array(); } @@ -304,9 +366,10 @@ abstract class resource_base { if (empty($this->params)) { $this->params = array(); - if (isset($_SERVER['PATH_INFO']) && !empty($_SERVER['PATH_INFO'])) { + if (!empty($_SERVER['PATH_INFO'])) { $path = explode('/', $_SERVER['PATH_INFO']); - $parts = explode('/', $this->get_template()); + $template = preg_replace('/\([0-9a-zA-Z_\-,\/]+\)/', '', $this->get_template()); + $parts = explode('/', $template); for ($i = 0; $i < count($parts); $i++) { if ((substr($parts[$i], 0, 1) == '{') && (substr($parts[$i], -1) == '}')) { $value = ''; diff --git a/mod/lti/classes/local/ltiservice/response.php b/mod/lti/classes/local/ltiservice/response.php index 8f4bcabcd2e..a36417e6bf3 100644 --- a/mod/lti/classes/local/ltiservice/response.php +++ b/mod/lti/classes/local/ltiservice/response.php @@ -114,12 +114,16 @@ class response { * @return string */ public function get_reason() { - if (empty($this->reason)) { - $this->reason = $this->responsecodes[$this->code]; + $code = $this->code; + if (($code < 200) || ($code >= 600)) { + $code = 500; // Status code must be between 200 and 599. + } + if (empty($this->reason) && array_key_exists($code, $this->responsecodes)) { + $this->reason = $this->responsecodes[$code]; } // Use generic reason for this category (based on first digit) if a specific reason is not defined. if (empty($this->reason)) { - $this->reason = $this->responsecodes[intval($this->code / 100) * 100]; + $this->reason = $this->responsecodes[intval($code / 100) * 100]; } return $this->reason; } @@ -222,13 +226,28 @@ class response { foreach ($this->additionalheaders as $header) { header($header); } - if (($this->code >= 200) && ($this->code < 300)) { + if ((($this->code >= 200) && ($this->code < 300)) || !empty($this->body)) { if (!empty($this->contenttype)) { - header("Content-Type: {$this->contenttype};charset=UTF-8"); + header("Content-Type: {$this->contenttype}; charset=utf-8"); } if (!empty($this->body)) { echo $this->body; } + } else if ($this->code >= 400) { + header("Content-Type: application/json; charset=utf-8"); + $body = new \stdClass(); + $body->status = $this->code; + $body->reason = $this->get_reason(); + $body->request = new \stdClass(); + $body->request->method = $_SERVER['REQUEST_METHOD']; + $body->request->url = $_SERVER['REQUEST_URI']; + if (isset($_SERVER['HTTP_ACCEPT'])) { + $body->request->accept = $_SERVER['HTTP_ACCEPT']; + } + if (isset($_SERVER['CONTENT_TYPE'])) { + $body->request->contentType = explode(';', $_SERVER['CONTENT_TYPE'], 2)[0]; + } + echo json_encode($body, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); } } diff --git a/mod/lti/classes/local/ltiservice/service_base.php b/mod/lti/classes/local/ltiservice/service_base.php index 26a89c1bcd8..c896c6a6a20 100644 --- a/mod/lti/classes/local/ltiservice/service_base.php +++ b/mod/lti/classes/local/ltiservice/service_base.php @@ -49,6 +49,8 @@ abstract class service_base { /** Label representing an LTI 2 message type */ const LTI_VERSION2P0 = 'LTI-2p0'; + /** Service enabled */ + const SERVICE_ENABLED = 1; /** @var string ID for the service. */ protected $id; @@ -58,6 +60,10 @@ abstract class service_base { protected $unsigned; /** @var stdClass Tool proxy object for the current service request. */ private $toolproxy; + /** @var stdClass LTI type object for the current service request. */ + private $type; + /** @var stdClass LTI type config object for the current service request. */ + private $typeconfig; /** @var array Instances of the resources associated with this service. */ protected $resources; @@ -71,6 +77,8 @@ abstract class service_base { $this->name = null; $this->unsigned = false; $this->toolproxy = null; + $this->type = null; + $this->typeconfig = null; $this->resources = null; } @@ -86,6 +94,17 @@ abstract class service_base { } + /** + * Get the service compoent ID. + * + * @return string + */ + public function get_component_id() { + + return 'ltiservice_' . $this->id; + + } + /** * Get the service name. * @@ -132,6 +151,54 @@ abstract class service_base { } + /** + * Get the type object. + * + * @return stdClass + */ + public function get_type() { + + return $this->type; + + } + + /** + * Set the LTI type object. + * + * @param object $type The LTI type for this service request + * + * @var stdClass + */ + public function set_type($type) { + + $this->type = $type; + + } + + /** + * Get the type config object. + * + * @return stdClass + */ + public function get_typeconfig() { + + return $this->typeconfig; + + } + + /** + * Set the LTI type config object. + * + * @param object $typeconfig The LTI type config for this service request + * + * @var stdClass + */ + public function set_typeconfig($typeconfig) { + + $this->typeconfig = $typeconfig; + + } + /** * Get the resources for this service. * @@ -139,13 +206,27 @@ abstract class service_base { */ abstract public function get_resources(); + /** + * Get the scope(s) permitted for this service. + * + * A null value indicates that no scopes are required to access the service. + * + * @return array|null + */ + public function get_permitted_scopes() { + return null; + } + /** * Returns the configuration options for this service. * * @param \MoodleQuickForm $mform Moodle quickform object definition + * @deprecated since Moodle 3.7 MDL-62599 - please do not use this function any more. + * @see service_base::get_configuration_elements() */ public function get_configuration_options(&$mform) { - + debugging('get_configuration_options() has been deprecated, ' . + 'please use service_base::get_configuration_elements() instead.', DEBUG_DEVELOPER); } /** @@ -154,6 +235,17 @@ abstract class service_base { * @return array Names list of the parameters that the service will be saving in the configuration */ public function get_configuration_parameter_names() { + debugging('get_configuration_options() has been deprecated, ' . + 'please use service_base::get_configuration_elements() instead.', DEBUG_DEVELOPER); + return array(); + } + + /** + * Create form element for gradebook sync add/edit page. + * + * @return array of \HTML_QuickForm_element Form elements + */ + public function get_configuration_elements() { return array(); } @@ -244,6 +336,63 @@ abstract class service_base { } + /** + * Check that the request has been properly signed and is permitted. + * + * @param string $typeid LTI type ID + * @param string $body Request body (null if none) + * @param string[] $scopes Array of required scope(s) for incoming request + * + * @return boolean + */ + public function check_tool($typeid, $body = null, $scopes = null) { + + $ok = true; + $toolproxy = null; + $consumerkey = lti\get_oauth_key_from_headers($typeid, $scopes); + if ($consumerkey === false) { + $ok = $this->is_unsigned(); + } else { + if (empty($typeid) && is_int($consumerkey)) { + $typeid = $consumerkey; + } + if (!empty($typeid)) { + $this->type = lti_get_type($typeid); + $this->typeconfig = lti_get_type_config($typeid); + $ok = !empty($this->type->id); + if ($ok && !empty($this->type->toolproxyid)) { + $this->toolproxy = lti_get_tool_proxy($this->type->toolproxyid); + } + } else { + $toolproxy = lti_get_tool_proxy_from_guid($consumerkey); + if ($toolproxy !== false) { + $this->toolproxy = $toolproxy; + } + } + } + if ($ok && is_string($consumerkey)) { + if (!empty($this->toolproxy)) { + $key = $this->toolproxy->guid; + $secret = $this->toolproxy->secret; + } else { + if ($this->type->ltiversion === LTI_VERSION_1P3) { + $key = $this->type->clientid; // Should not be needed as access tokens are used. + } else { + $key = $this->typeconfig['resourcekey']; + } + $secret = $this->typeconfig['password']; + } + if (!$this->is_unsigned() && ($key == $consumerkey)) { + $ok = $this->check_signature($key, $secret, $body); + } else { + $ok = $this->is_unsigned(); + } + } + + return $ok; + + } + /** * Check that the request has been properly signed. * @@ -251,9 +400,13 @@ abstract class service_base { * @param string $body Request body (null if none) * * @return boolean + * @deprecated since Moodle 3.7 MDL-62599 - please do not use this function any more. + * @see service_base::check_tool() */ public function check_tool_proxy($toolproxyguid, $body = null) { + debugging('check_tool_proxy() is deprecated to allow LTI 1 connections to support services. ' . + 'Please use service_base::check_tool() instead.', DEBUG_DEVELOPER); $ok = false; $toolproxy = null; $consumerkey = lti\get_oauth_key_from_headers(); @@ -274,7 +427,9 @@ abstract class service_base { if ($ok) { $this->toolproxy = $toolproxy; } + return $ok; + } /** @@ -285,8 +440,12 @@ abstract class service_base { * @param string $body Request body (null if none) * * @return bool + * @deprecated since Moodle 3.7 MDL-62599 - please do not use this function any more. + * @see service_base::check_tool() */ public function check_type($typeid, $courseid, $body = null) { + debugging('check_type() is deprecated to allow LTI 1 connections to support services. ' . + 'Please use service_base::check_tool() instead.', DEBUG_DEVELOPER); $ok = false; $tool = null; $consumerkey = lti\get_oauth_key_from_headers(); diff --git a/mod/lti/classes/output/tool_configure_page.php b/mod/lti/classes/output/tool_configure_page.php index abd852fa4d5..65cb4c70194 100644 --- a/mod/lti/classes/output/tool_configure_page.php +++ b/mod/lti/classes/output/tool_configure_page.php @@ -23,6 +23,8 @@ */ namespace mod_lti\output; +defined('MOODLE_INTERNAL') || die; + require_once($CFG->dirroot.'/mod/lti/locallib.php'); use moodle_url; diff --git a/mod/lti/classes/task/clean_access_tokens.php b/mod/lti/classes/task/clean_access_tokens.php new file mode 100644 index 00000000000..4f274130bd3 --- /dev/null +++ b/mod/lti/classes/task/clean_access_tokens.php @@ -0,0 +1,56 @@ +. + +/** + * A scheduled task for lti module. + * + * @package mod_lti + * @copyright 2019 Stephen Vickers + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace mod_lti\task; + +use core\task\scheduled_task; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Class containing the scheduled task for lti module. + * + * @package mod_lti + * @copyright 2018 Stephen Vickers + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class clean_access_tokens extends scheduled_task { + + /** + * Get a descriptive name for this task (shown to admins). + * + * @return string + */ + public function get_name() { + return get_string('cleanaccesstokens', 'mod_lti'); + } + + /** + * Run lti cron. + */ + public function execute() { + global $DB; + + $DB->delete_records_select('lti_access_tokens', 'validuntil < ?', [time()]); + } +} diff --git a/mod/lti/contentitem.php b/mod/lti/contentitem.php index 0b6b87281d5..0c954949782 100644 --- a/mod/lti/contentitem.php +++ b/mod/lti/contentitem.php @@ -32,6 +32,16 @@ $courseid = required_param('course', PARAM_INT); $title = optional_param('title', '', PARAM_TEXT); $text = optional_param('text', '', PARAM_RAW); +$config = lti_get_type_type_config($id); +if ($config->lti_ltiversion === LTI_VERSION_1P3) { + if (!isset($SESSION->lti_initiatelogin_status)) { + echo lti_initiatelogin($courseid, 0, null, $config, 'ContentItemSelectionRequest', $title, $text); + exit; + } else { + unset($SESSION->lti_initiatelogin_status); + } +} + // Check access and capabilities. $course = $DB->get_record('course', array('id' => $courseid), '*', MUST_EXIST); require_login($course); diff --git a/mod/lti/contentitem_return.php b/mod/lti/contentitem_return.php index 53cc5de0195..a6a1b7dfc00 100644 --- a/mod/lti/contentitem_return.php +++ b/mod/lti/contentitem_return.php @@ -28,12 +28,26 @@ require_once($CFG->dirroot . '/mod/lti/locallib.php'); $id = required_param('id', PARAM_INT); $courseid = required_param('course', PARAM_INT); -$messagetype = required_param('lti_message_type', PARAM_TEXT); -$version = required_param('lti_version', PARAM_TEXT); -$consumerkey = required_param('oauth_consumer_key', PARAM_RAW); -$items = optional_param('content_items', '', PARAM_RAW); -$errormsg = optional_param('lti_errormsg', '', PARAM_TEXT); -$msg = optional_param('lti_msg', '', PARAM_TEXT); + +$jwt = optional_param('JWT', '', PARAM_RAW); + +if (!empty($jwt)) { + list($tool, $params) = lti_convert_from_jwt($id, $jwt); + $consumerkey = isset($params['oauth_consumer_key']) ? $params['oauth_consumer_key'] : ''; + $messagetype = isset($params['lti_message_type']) ? $params['lti_message_type'] : ''; + $version = isset($params['lti_version']) ? $params['lti_version'] : ''; + $items = isset($params['content_items']) ? $params['content_items'] : ''; + $errormsg = isset($params['lti_errormsg']) ? $params['lti_errormsg'] : ''; + $msg = isset($params['lti_msg']) ? $params['lti_msg'] : ''; +} else { + $consumerkey = required_param('oauth_consumer_key', PARAM_RAW); + $messagetype = required_param('lti_message_type', PARAM_TEXT); + $version = required_param('lti_version', PARAM_TEXT); + $items = optional_param('content_items', '', PARAM_RAW); + $errormsg = optional_param('lti_errormsg', '', PARAM_TEXT); + $msg = optional_param('lti_msg', '', PARAM_TEXT); + $tool = lti_verify_oauth_signature($id, $consumerkey); +} $course = $DB->get_record('course', array('id' => $courseid), '*', MUST_EXIST); require_login($course); @@ -46,7 +60,7 @@ $redirecturl = null; $returndata = null; if (empty($errormsg) && !empty($items)) { try { - $returndata = lti_tool_configuration_from_content_item($id, $messagetype, $version, $consumerkey, $items); + $returndata = lti_tool_configuration_from_content_item($tool, $messagetype, $version, $consumerkey, $items); } catch (moodle_exception $e) { $errormsg = $e->getMessage(); } diff --git a/mod/lti/db/install.php b/mod/lti/db/install.php new file mode 100644 index 00000000000..9adf9db60ba --- /dev/null +++ b/mod/lti/db/install.php @@ -0,0 +1,47 @@ +. + +/** + * Post installation and migration code. + * + * @package mod_lti + * @copyright 2019 Stephen Vickers + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +/** + * Stub for database installation. + */ +function xmldb_lti_install() { + global $CFG; + + // Add platform ID configuration setting. + set_config('platformid', $CFG->wwwroot, 'mod_lti'); + + // Create the private key. + $kid = bin2hex(openssl_random_pseudo_bytes(10)); + set_config('kid', $kid, 'mod_lti'); + $config = array( + "digest_alg" => "sha256", + "private_key_bits" => 2048, + "private_key_type" => OPENSSL_KEYTYPE_RSA, + ); + $res = openssl_pkey_new($config); + openssl_pkey_export($res, $privatekey); + set_config('privatekey', $privatekey, 'mod_lti'); +} diff --git a/mod/lti/db/install.xml b/mod/lti/db/install.xml index 040f32edae3..2c5af4c995f 100644 --- a/mod/lti/db/install.xml +++ b/mod/lti/db/install.xml @@ -1,5 +1,5 @@ - @@ -72,6 +72,8 @@ + + @@ -88,6 +90,7 @@ + @@ -108,6 +111,7 @@ + @@ -117,6 +121,7 @@ + @@ -140,5 +145,23 @@
+ + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/mod/lti/db/services.php b/mod/lti/db/services.php index c90397a9a24..32c41e8d816 100644 --- a/mod/lti/db/services.php +++ b/mod/lti/db/services.php @@ -24,6 +24,8 @@ * @since Moodle 3.0 */ +defined('MOODLE_INTERNAL') || die; + $functions = array( 'mod_lti_get_tool_launch_data' => array( diff --git a/mod/lti/db/tasks.php b/mod/lti/db/tasks.php new file mode 100644 index 00000000000..6a4120372a3 --- /dev/null +++ b/mod/lti/db/tasks.php @@ -0,0 +1,38 @@ +. + +/** + * This file defines tasks performed by the plugin. + * + * @package mod_lti + * @copyright 2019 Stephen Vickers + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +// List of tasks. +$tasks = array( + array( + 'classname' => 'mod_lti\task\clean_access_tokens', + 'blocking' => 0, + 'minute' => 'R', + 'hour' => 'R', + 'day' => '*', + 'dayofweek' => '*', + 'month' => '*' + ) +); diff --git a/mod/lti/db/upgrade.php b/mod/lti/db/upgrade.php index f77a66186dd..2c15428f131 100644 --- a/mod/lti/db/upgrade.php +++ b/mod/lti/db/upgrade.php @@ -61,6 +61,8 @@ function xmldb_lti_upgrade($oldversion) { global $CFG, $DB; + $dbman = $DB->get_manager(); + // Automatically generated Moodle v3.3.0 release upgrade line. // Put any upgrade step following this. @@ -125,6 +127,112 @@ function xmldb_lti_upgrade($oldversion) { // Automatically generated Moodle v3.6.0 release upgrade line. // Put any upgrade step following this. - return true; + if ($oldversion < 2019031300) { + // Define table lti_access_tokens to be updated. + $table = new xmldb_table('lti_types'); + // Define field ltiversion to be added to lti_types. + $field = new xmldb_field('ltiversion', XMLDB_TYPE_CHAR, 10, null, XMLDB_NOTNULL, null, null, 'coursevisible'); + + // Conditionally launch add field ltiversion. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + $DB->set_field_select('lti_types', 'ltiversion', 'LTI-1p0', 'toolproxyid IS NULL'); + $DB->set_field_select('lti_types', 'ltiversion', 'LTI-2p0', 'toolproxyid IS NOT NULL'); + } + + // Define field clientid to be added to lti_types. + $field = new xmldb_field('clientid', XMLDB_TYPE_CHAR, '255', null, null, null, null, 'ltiversion'); + + // Conditionally launch add field clientid. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // Define index clientid (unique) to be added to lti_types. + $index = new xmldb_index('clientid', XMLDB_INDEX_UNIQUE, array('clientid')); + + // Conditionally launch add index clientid. + if (!$dbman->index_exists($table, $index)) { + $dbman->add_index($table, $index); + } + + // Add platform ID configuration setting. + set_config('platformid', $CFG->wwwroot, 'mod_lti'); + + // Create the private key. + $kid = bin2hex(openssl_random_pseudo_bytes(10)); + set_config('kid', $kid, 'mod_lti'); + $config = array( + "digest_alg" => "sha256", + "private_key_bits" => 2048, + "private_key_type" => OPENSSL_KEYTYPE_RSA, + ); + $res = openssl_pkey_new($config); + openssl_pkey_export($res, $privatekey); + set_config('privatekey', $privatekey, 'mod_lti'); + + // Lti savepoint reached. + upgrade_mod_savepoint(true, 2019031300, 'lti'); + } + + if ($oldversion < 2019031301) { + // Define table lti_access_tokens to be created. + $table = new xmldb_table('lti_access_tokens'); + + // Adding fields to table lti_access_tokens. + $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null); + $table->add_field('typeid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null); + $table->add_field('scope', XMLDB_TYPE_TEXT, null, null, XMLDB_NOTNULL, null, null); + $table->add_field('token', XMLDB_TYPE_CHAR, '128', null, XMLDB_NOTNULL, null, null); + $table->add_field('validuntil', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null); + $table->add_field('timecreated', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null); + $table->add_field('lastaccess', XMLDB_TYPE_INTEGER, '10', null, null, null, null); + + // Adding keys to table lti_access_tokens. + $table->add_key('primary', XMLDB_KEY_PRIMARY, array('id')); + $table->add_key('typeid', XMLDB_KEY_FOREIGN, array('typeid'), 'lti_types', array('id')); + + // Add an index. + $table->add_index('token', XMLDB_INDEX_UNIQUE, array('token')); + + // Conditionally launch create table for lti_access_tokens. + if (!$dbman->table_exists($table)) { + $dbman->create_table($table); + } + + // Lti savepoint reached. + upgrade_mod_savepoint(true, 2019031301, 'lti'); + } + + if ($oldversion < 2019031302) { + $DB->set_field('lti_types_config', 'name', 'ltiservice_memberships', array('name' => 'memberships')); + $DB->set_field('lti_types_config', 'name', 'ltiservice_gradebookservices', array('name' => 'gradesynchronization')); + + // Lti savepoint reached. + upgrade_mod_savepoint(true, 2019031302, 'lti'); + } + + if ($oldversion < 2019031303) { + // Define field typeid to be added to lti_tool_settings. + $table = new xmldb_table('lti_tool_settings'); + $field = new xmldb_field('typeid', XMLDB_TYPE_INTEGER, '10', null, null, null, null, 'toolproxyid'); + + // Conditionally launch add field typeid. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // Define key typeid (foreign) to be added to lti_tool_settings. + $table = new xmldb_table('lti_tool_settings'); + $key = new xmldb_key('typeid', XMLDB_KEY_FOREIGN, ['typeid'], 'lti_types', ['id']); + + // Launch add key typeid. + $dbman->add_key($table, $key); + + // Lti savepoint reached. + upgrade_mod_savepoint(true, 2019031303, 'lti'); + } + + return true; } diff --git a/mod/lti/edit_form.php b/mod/lti/edit_form.php index 5e618dec0f2..19b914cebfc 100644 --- a/mod/lti/edit_form.php +++ b/mod/lti/edit_form.php @@ -68,11 +68,13 @@ class mod_lti_edit_types_form extends moodleform { * Define this form. */ public function definition() { - global $CFG; + global $CFG, $PAGE; $mform =& $this->_form; - $istool = $this->_customdata && $this->_customdata->istool; + $istool = $this->_customdata && isset($this->_customdata->istool) && $this->_customdata->istool; + $typeid = $this->_customdata->id ?? ''; + $clientid = $this->_customdata->clientid ?? ''; // Add basiclti elements. $mform->addElement('header', 'setup', get_string('tool_settings', 'lti')); @@ -96,14 +98,54 @@ class mod_lti_edit_types_form extends moodleform { } if (!$istool) { + $options = array( + LTI_VERSION_1 => get_string('oauthsecurity', 'lti'), + LTI_VERSION_1P3 => get_string('jwtsecurity', 'lti'), + ); + $mform->addElement('select', 'lti_ltiversion', get_string('ltiversion', 'lti'), $options); + $mform->setType('lti_ltiversion', PARAM_TEXT); + $mform->addHelpButton('lti_ltiversion', 'ltiversion', 'lti'); + $mform->setDefault('lti_ltiversion', LTI_VERSION_1); + $mform->addElement('text', 'lti_resourcekey', get_string('resourcekey_admin', 'lti')); $mform->setType('lti_resourcekey', PARAM_TEXT); $mform->addHelpButton('lti_resourcekey', 'resourcekey_admin', 'lti'); + $mform->hideIf('lti_resourcekey', 'lti_ltiversion', 'eq', LTI_VERSION_1P3); $mform->setForceLtr('lti_resourcekey'); $mform->addElement('passwordunmask', 'lti_password', get_string('password_admin', 'lti')); $mform->setType('lti_password', PARAM_TEXT); $mform->addHelpButton('lti_password', 'password_admin', 'lti'); + $mform->hideIf('lti_password', 'lti_ltiversion', 'eq', LTI_VERSION_1P3); + + if (!empty($typeid)) { + $mform->addElement('text', 'lti_clientid_disabled', get_string('clientidadmin', 'lti')); + $mform->setType('lti_clientid_disabled', PARAM_TEXT); + $mform->addHelpButton('lti_clientid_disabled', 'clientidadmin', 'lti'); + $mform->hideIf('lti_clientid_disabled', 'lti_ltiversion', 'neq', LTI_VERSION_1P3); + $mform->disabledIf('lti_clientid_disabled', null); + $mform->setForceLtr('lti_clientid_disabled'); + $mform->addElement('hidden', 'lti_clientid'); + $mform->setType('lti_clientid', PARAM_TEXT); + } + + $mform->addElement('textarea', 'lti_publickey', get_string('publickey', 'lti'), array('rows' => 8, 'cols' => 60)); + $mform->setType('lti_publickey', PARAM_TEXT); + $mform->addHelpButton('lti_publickey', 'publickey', 'lti'); + $mform->hideIf('lti_publickey', 'lti_ltiversion', 'neq', LTI_VERSION_1P3); + $mform->setForceLtr('lti_publickey'); + + $mform->addElement('text', 'lti_initiatelogin', get_string('initiatelogin', 'lti'), array('size' => '64')); + $mform->setType('lti_initiatelogin', PARAM_URL); + $mform->addHelpButton('lti_initiatelogin', 'initiatelogin', 'lti'); + $mform->hideIf('lti_initiatelogin', 'lti_ltiversion', 'neq', LTI_VERSION_1P3); + + $mform->addElement('textarea', 'lti_redirectionuris', get_string('redirectionuris', 'lti'), + array('rows' => 3, 'cols' => 60)); + $mform->setType('lti_redirectionuris', PARAM_TEXT); + $mform->addHelpButton('lti_redirectionuris', 'redirectionuris', 'lti'); + $mform->hideIf('lti_redirectionuris', 'lti_ltiversion', 'neq', LTI_VERSION_1P3); + $mform->setForceLtr('lti_redirectionuris'); } if ($istool) { @@ -185,8 +227,7 @@ class mod_lti_edit_types_form extends moodleform { $mform->addHelpButton('lti_secureicon', 'secure_icon_url', 'lti'); if (!$istool) { - // Display the lti advantage services. - $this->get_lti_advantage_services($mform); + $this->get_lti_services($mform); } if (!$istool) { @@ -287,8 +328,12 @@ class mod_lti_edit_types_form extends moodleform { * Generates the lti advantage extra configuration adding it to the mform * * @param MoodleQuickForm $mform + * @deprecated since Moodle 3.7 MDL-62599 - please do not use this function any more. + * @see mod_lti_edit_types_form::get_lti_services() */ public function get_lti_advantage_services(&$mform) { + debugging('get_lti_advantage_services() has been deprecated, ' . + 'please use mod_lti_edit_types_form::get_lti_services() instead.', DEBUG_DEVELOPER); // For each service add the label and get the array of configuration. $services = lti_get_services(); $mform->addElement('header', 'services', get_string('services', 'lti')); @@ -297,4 +342,38 @@ class mod_lti_edit_types_form extends moodleform { $service->get_configuration_options($mform); } } + + /** + * Generates the lti services extra configuration adding it to the mform + * + * @param MoodleQuickForm $mform + */ + public function get_lti_services(&$mform) { + $services = lti_get_services(); + $mform->addElement('header', 'services', get_string('services', 'lti')); + foreach ($services as $service) { + $elements = $service->get_configuration_elements(); + if (!empty($elements)) { + foreach ($elements as $name => $element) { + $id = $service->get_component_id(); + if (!empty($name)) { + $id = "{$id}_{$name}"; + } + $element->setName($id); + $mform->addelement($element); + $type = PARAM_TEXT; + if ($element instanceof \MoodleQuickForm_select) { + if (is_int($element->_options[0]['attr']['value'])) { + $type = PARAM_INT; + } + } + $mform->setType($id, $type); + $mform->setDefault($id, 0); + if (empty($name)) { + $mform->addHelpButton($id, $id, $id); + } + } + } + } + } } diff --git a/mod/lti/instructor_edit_tool_type.php b/mod/lti/instructor_edit_tool_type.php index 2ea72e64f7e..cca3fb387c3 100644 --- a/mod/lti/instructor_edit_tool_type.php +++ b/mod/lti/instructor_edit_tool_type.php @@ -64,12 +64,18 @@ if (defined('BEHAT_SITE_RUNNING') && BEHAT_SITE_RUNNING) { echo $OUTPUT->header(); -$form = new mod_lti_edit_types_form(); +if ($action == 'edit') { + $type = lti_get_type_type_config($typeid); +} else { + $type = new stdClass(); + $type->lti_clientid = null; +} + +$form = new mod_lti_edit_types_form($url, (object)array('id' => $typeid, 'clientid' => $type->lti_clientid)); // If the user just opened an add or edit form. if ($action == 'add' || $action == 'edit') { if ($action == 'edit') { - $type = lti_get_type_type_config($typeid); $form->set_data($type); } echo $OUTPUT->heading(get_string('toolsetup', 'lti')); diff --git a/mod/lti/lang/en/lti.php b/mod/lti/lang/en/lti.php index 17ac2ad8584..9749e601add 100644 --- a/mod/lti/lang/en/lti.php +++ b/mod/lti/lang/en/lti.php @@ -93,7 +93,12 @@ $string['cannot_edit'] = 'You may not edit this tool configuration.'; $string['capabilities'] = 'Capabilities'; $string['capabilitiesrequired'] = 'This tool requires access to the following data in order to activate:'; $string['capabilities_help'] = 'Select those capabilities which you wish to offer to the tool provider. More than one capability can be selected.'; +$string['cleanaccesstokens'] = 'External tool removal of expired access tokens'; $string['click_to_continue'] = 'Click to continue'; +$string['clientidadmin'] = 'Client ID'; +$string['clientidadmin_help'] = 'The client ID can be thought of as a unique value used to identify a tool. +It is created automatically for each tool which uses the JWT security profile introduced in LTI 1.3 and should +be part of the details passed to the provider of the tool so that they can configure the connection at their end.'; $string['comment'] = 'Comment'; $string['configpassword'] = 'Default remote tool password'; $string['configpreferheight'] = 'Default preferred height'; @@ -217,7 +222,10 @@ $string['indicator:cognitivedepth'] = 'LTI cognitive'; $string['indicator:cognitivedepth_help'] = 'This indicator is based on the cognitive depth reached by the student in an LTI activity.'; $string['indicator:socialbreadth'] = 'LTI social'; $string['indicator:socialbreadth_help'] = 'This indicator is based on the social breadth reached by the student in an LTI activity.'; +$string['initiatelogin'] = 'Initiate Login URL'; +$string['initiatelogin_help'] = 'The tool URL to which requests for initiating a login are to be sent. This URL is required before a message can be successfully sent to the tool.'; $string['invalidid'] = 'LTI ID was incorrect'; +$string['jwtsecurity'] = 'LTI 1.3'; $string['launch_in_moodle'] = 'Launch tool in Moodle'; $string['launch_in_popup'] = 'Launch tool in a pop-up'; $string['launch_url'] = 'Tool URL'; @@ -262,6 +270,8 @@ $string['lti_launch_error_unsigned_help'] = '

This error may be a result of a $string['lti_tool_request_added'] = 'Tool configuration request successfully submitted. You may need to contact an administrator to complete the tool configuration.'; $string['lti_tool_request_existing'] = 'A tool configuration for the tool domain has already been submitted.'; $string['ltiunknownserviceapicall'] = 'LTI unknown service API call.'; +$string['ltiversion'] = 'LTI version'; +$string['ltiversion_help'] = 'The version of LTI being used for signing messages and service requests: LTI 1.0/1.1 and LTI 2.0 use the OAuth 1.0A security profile; LTI 1.3.0 uses JWTs.'; $string['main_admin'] = 'General help'; $string['main_admin_help'] = 'External tools allow Moodle users to seamlessly interact with learning resources hosted remotely. Through a special launch protocol, the remote tool will have access to some general information about the launching user. For example, @@ -316,6 +326,7 @@ $string['noprofileservice'] = 'Profile service not found'; $string['noservers'] = 'No servers found'; $string['notypes'] = 'There are currently no LTI tools set up in Moodle. Click the Install link above to add some.'; $string['noviewusers'] = 'No users were found with permissions to use this tool'; +$string['oauthsecurity'] = 'LTI 1.0/1.1'; $string['optionalsettings'] = 'Optional settings'; $string['organization'] = 'Organization details'; $string['organizationdescr'] = 'Organization description'; @@ -382,9 +393,13 @@ $string['privacy:metadata:timemodified'] = 'The time when the record was modifie $string['privacy:metadata:userid'] = 'The ID of the user accessing the LTI Consumer'; $string['privacy:metadata:useridnumber'] = 'The ID number of the user accessing the LTI Consumer'; $string['privacy:metadata:username'] = 'The username of the user accessing the LTI Consumer'; +$string['publickey'] = 'Public key'; +$string['publickey_help'] = 'The public key (in PEM format) provided by the tool to allow signatures of incoming messages and service requests to be verified.'; $string['quickgrade'] = 'Allow quick grading'; $string['quickgrade_help'] = 'If enabled, multiple tools can be graded on one page. Add grades and comments then click the "Save all my feedback" button to save all changes for that page.'; $string['redirect'] = 'You will be redirected in few seconds. If you are not, press the button.'; +$string['redirectionuris'] = 'Redirection URI(s)'; +$string['redirectionuris_help'] = 'A list of URIs (one per line) which the tool uses when making authorisation requests. At least one must be registered before a message can be successfully sent to the tool.'; $string['register'] = 'Register'; $string['register_warning'] = 'The registration page seems to be taking a while to open. If it does not appear, check that you entered the correct URL in the configuration settings. If Moodle is using https, ensure the tool you are configuring supports https and you are using https in the URL.'; $string['registertype'] = 'Configure a new external tool registration'; @@ -486,6 +501,16 @@ $string['tooldescription'] = 'Tool description'; $string['tooldescription_help'] = 'The description of the tool that will be displayed to teachers in the activity list. This should describe what the tool is for and what it does and any additional information the teacher may need to know.'; +$string['tooldetailsaccesstokenurl'] = 'Access Token URL'; +$string['tooldetailsauthrequesturl'] = 'Authentication request URL'; +$string['tooldetailsclientid'] = 'Client ID'; +$string['tooldetailsdeploymentid'] = 'Deployment ID'; +$string['tooldetailsmailtosubject'] = 'LTI Tool Configuration'; +$string['tooldetailsmodalemail'] = 'Email'; +$string['tooldetailsmodallink'] = 'View Configuration Details'; +$string['tooldetailsmodaltitle'] = 'Tool Configuration Details'; +$string['tooldetailsplatformid'] = 'Platform ID'; +$string['tooldetailspublickeyseturl'] = 'Public Keyset URL'; $string['toolisbeingused'] = 'This tool is being used {$a} times'; $string['toolisnotbeingused'] = 'This tool has not yet been used'; $string['toolproxy'] = 'External tool registrations'; diff --git a/mod/lti/launch.php b/mod/lti/launch.php index ca27416fab0..20b04b455e7 100644 --- a/mod/lti/launch.php +++ b/mod/lti/launch.php @@ -55,6 +55,20 @@ $triggerview = optional_param('triggerview', 1, PARAM_BOOL); $cm = get_coursemodule_from_id('lti', $id, 0, false, MUST_EXIST); $lti = $DB->get_record('lti', array('id' => $cm->instance), '*', MUST_EXIST); + +$typeid = $lti->typeid; +if ($typeid) { + $config = lti_get_type_type_config($typeid); + if ($config->lti_ltiversion === LTI_VERSION_1P3) { + if (!isset($SESSION->lti_initiatelogin_status)) { + echo lti_initiatelogin($cm->course, $id, $lti, $config); + exit; + } else { + unset($SESSION->lti_initiatelogin_status); + } + } +} + $course = $DB->get_record('course', array('id' => $cm->course), '*', MUST_EXIST); $context = context_module::instance($cm->id); diff --git a/mod/lti/locallib.php b/mod/lti/locallib.php index 295581b901b..1c13c275b7d 100644 --- a/mod/lti/locallib.php +++ b/mod/lti/locallib.php @@ -52,12 +52,14 @@ defined('MOODLE_INTERNAL') || die; // TODO: Switch to core oauthlib once implemented - MDL-30149. use moodle\mod\lti as lti; +use Firebase\JWT\JWT as JWT; global $CFG; require_once($CFG->dirroot.'/mod/lti/OAuth.php'); require_once($CFG->libdir.'/weblib.php'); require_once($CFG->dirroot . '/course/modlib.php'); require_once($CFG->dirroot . '/mod/lti/TrivialStore.php'); +require_once($CFG->libdir . '/php-jwt/src/JWT.php'); define('LTI_URL_DOMAIN_REGEX', '/(?:https?:\/\/)?(?:www\.)?([^\/]+)(?:\/|$)/i'); @@ -88,32 +90,438 @@ define('LTI_COURSEVISIBLE_ACTIVITYCHOOSER', 2); define('LTI_VERSION_1', 'LTI-1p0'); define('LTI_VERSION_2', 'LTI-2p0'); +define('LTI_VERSION_1P3', '1.3.0'); + +define('LTI_ACCESS_TOKEN_LIFE', 3600); + +// Standard prefix for JWT claims. +define('LTI_JWT_CLAIM_PREFIX', 'https://purl.imsglobal.org/spec/lti'); + +/** + * Return the mapping for standard message types to JWT message_type claim. + * + * @return array + */ +function lti_get_jwt_message_type_mapping() { + return array( + 'basic-lti-launch-request' => 'LtiResourceLinkRequest', + 'ContentItemSelectionRequest' => 'LtiDeepLinkingRequest', + 'LtiDeepLinkingResponse' => 'ContentItemSelection', + ); +} + +/** + * Return the mapping for standard message parameters to JWT claim. + * + * @return array + */ +function lti_get_jwt_claim_mapping() { + return array( + 'accept_copy_advice' => [ + 'suffix' => 'dl', + 'group' => 'deep_linking_settings', + 'claim' => 'accept_copy_advice', + 'isarray' => false + ], + 'accept_media_types' => [ + 'suffix' => 'dl', + 'group' => 'deep_linking_settings', + 'claim' => 'accept_media_types', + 'isarray' => true + ], + 'accept_multiple' => [ + 'suffix' => 'dl', + 'group' => 'deep_linking_settings', + 'claim' => 'accept_multiple', + 'isarray' => false + ], + 'accept_presentation_document_targets' => [ + 'suffix' => 'dl', + 'group' => 'deep_linking_settings', + 'claim' => 'accept_presentation_document_targets', + 'isarray' => true + ], + 'accept_types' => [ + 'suffix' => 'dl', + 'group' => 'deep_linking_settings', + 'claim' => 'accept_types', + 'isarray' => true + ], + 'accept_unsigned' => [ + 'suffix' => 'dl', + 'group' => 'deep_linking_settings', + 'claim' => 'accept_unsigned', + 'isarray' => false + ], + 'auto_create' => [ + 'suffix' => 'dl', + 'group' => 'deep_linking_settings', + 'claim' => 'auto_create', + 'isarray' => false + ], + 'can_confirm' => [ + 'suffix' => 'dl', + 'group' => 'deep_linking_settings', + 'claim' => 'can_confirm', + 'isarray' => false + ], + 'content_item_return_url' => [ + 'suffix' => 'dl', + 'group' => 'deep_linking_settings', + 'claim' => 'deep_link_return_url', + 'isarray' => false + ], + 'content_items' => [ + 'suffix' => 'dl', + 'group' => '', + 'claim' => 'content_items', + 'isarray' => true + ], + 'data' => [ + 'suffix' => 'dl', + 'group' => 'deep_linking_settings', + 'claim' => 'data', + 'isarray' => false + ], + 'text' => [ + 'suffix' => 'dl', + 'group' => 'deep_linking_settings', + 'claim' => 'text', + 'isarray' => false + ], + 'title' => [ + 'suffix' => 'dl', + 'group' => 'deep_linking_settings', + 'claim' => 'title', + 'isarray' => false + ], + 'lti_msg' => [ + 'suffix' => 'dl', + 'group' => '', + 'claim' => 'msg', + 'isarray' => false + ], + 'lti_log' => [ + 'suffix' => 'dl', + 'group' => '', + 'claim' => 'log', + 'isarray' => false + ], + 'lti_errormsg' => [ + 'suffix' => 'dl', + 'group' => '', + 'claim' => 'errormsg', + 'isarray' => false + ], + 'lti_errorlog' => [ + 'suffix' => 'dl', + 'group' => '', + 'claim' => 'errorlog', + 'isarray' => false + ], + 'context_id' => [ + 'suffix' => '', + 'group' => 'context', + 'claim' => 'id', + 'isarray' => false + ], + 'context_label' => [ + 'suffix' => '', + 'group' => 'context', + 'claim' => 'label', + 'isarray' => false + ], + 'context_title' => [ + 'suffix' => '', + 'group' => 'context', + 'claim' => 'title', + 'isarray' => false + ], + 'context_type' => [ + 'suffix' => '', + 'group' => 'context', + 'claim' => 'type', + 'isarray' => true + ], + 'lis_course_offering_sourcedid' => [ + 'suffix' => '', + 'group' => 'lis', + 'claim' => 'course_offering_sourcedid', + 'isarray' => false + ], + 'lis_course_section_sourcedid' => [ + 'suffix' => '', + 'group' => 'lis', + 'claim' => 'course_section_sourcedid', + 'isarray' => false + ], + 'launch_presentation_css_url' => [ + 'suffix' => '', + 'group' => 'launch_presentation', + 'claim' => 'css_url', + 'isarray' => false + ], + 'launch_presentation_document_target' => [ + 'suffix' => '', + 'group' => 'launch_presentation', + 'claim' => 'document_target', + 'isarray' => false + ], + 'launch_presentation_height' => [ + 'suffix' => '', + 'group' => 'launch_presentation', + 'claim' => 'height', + 'isarray' => false + ], + 'launch_presentation_locale' => [ + 'suffix' => '', + 'group' => 'launch_presentation', + 'claim' => 'locale', + 'isarray' => false + ], + 'launch_presentation_return_url' => [ + 'suffix' => '', + 'group' => 'launch_presentation', + 'claim' => 'return_url', + 'isarray' => false + ], + 'launch_presentation_width' => [ + 'suffix' => '', + 'group' => 'launch_presentation', + 'claim' => 'width', + 'isarray' => false + ], + 'lis_person_contact_email_primary' => [ + 'suffix' => '', + 'group' => null, + 'claim' => 'email', + 'isarray' => false + ], + 'lis_person_name_family' => [ + 'suffix' => '', + 'group' => null, + 'claim' => 'family_name', + 'isarray' => false + ], + 'lis_person_name_full' => [ + 'suffix' => '', + 'group' => null, + 'claim' => 'name', + 'isarray' => false + ], + 'lis_person_name_given' => [ + 'suffix' => '', + 'group' => null, + 'claim' => 'given_name', + 'isarray' => false + ], + 'lis_person_sourcedid' => [ + 'suffix' => '', + 'group' => 'lis', + 'claim' => 'person_sourcedid', + 'isarray' => false + ], + 'user_id' => [ + 'suffix' => '', + 'group' => null, + 'claim' => 'sub', + 'isarray' => false + ], + 'user_image' => [ + 'suffix' => '', + 'group' => null, + 'claim' => 'picture', + 'isarray' => false + ], + 'roles' => [ + 'suffix' => '', + 'group' => '', + 'claim' => 'roles', + 'isarray' => true + ], + 'role_scope_mentor' => [ + 'suffix' => '', + 'group' => '', + 'claim' => 'role_scope_mentor', + 'isarray' => false + ], + 'deployment_id' => [ + 'suffix' => '', + 'group' => '', + 'claim' => 'deployment_id', + 'isarray' => false + ], + 'lti_message_type' => [ + 'suffix' => '', + 'group' => '', + 'claim' => 'message_type', + 'isarray' => false + ], + 'lti_version' => [ + 'suffix' => '', + 'group' => '', + 'claim' => 'version', + 'isarray' => false + ], + 'resource_link_description' => [ + 'suffix' => '', + 'group' => 'resource_link', + 'claim' => 'description', + 'isarray' => false + ], + 'resource_link_id' => [ + 'suffix' => '', + 'group' => 'resource_link', + 'claim' => 'id', + 'isarray' => false + ], + 'resource_link_title' => [ + 'suffix' => '', + 'group' => 'resource_link', + 'claim' => 'title', + 'isarray' => false + ], + 'tool_consumer_info_product_family_code' => [ + 'suffix' => '', + 'group' => 'tool_platform', + 'claim' => 'family_code', + 'isarray' => false + ], + 'tool_consumer_info_version' => [ + 'suffix' => '', + 'group' => 'tool_platform', + 'claim' => 'version', + 'isarray' => false + ], + 'tool_consumer_instance_contact_email' => [ + 'suffix' => '', + 'group' => 'tool_platform', + 'claim' => 'contact_email', + 'isarray' => false + ], + 'tool_consumer_instance_description' => [ + 'suffix' => '', + 'group' => 'tool_platform', + 'claim' => 'description', + 'isarray' => false + ], + 'tool_consumer_instance_guid' => [ + 'suffix' => '', + 'group' => 'tool_platform', + 'claim' => 'guid', + 'isarray' => false + ], + 'tool_consumer_instance_name' => [ + 'suffix' => '', + 'group' => 'tool_platform', + 'claim' => 'name', + 'isarray' => false + ], + 'tool_consumer_instance_url' => [ + 'suffix' => '', + 'group' => 'tool_platform', + 'claim' => 'url', + 'isarray' => false + ], + 'custom_context_memberships_url' => [ + 'suffix' => 'nrps', + 'group' => 'namesroleservice', + 'claim' => 'context_memberships_url', + 'isarray' => false + ], + 'custom_context_memberships_versions' => [ + 'suffix' => 'nrps', + 'group' => 'namesroleservice', + 'claim' => 'service_versions', + 'isarray' => true + ], + 'custom_gradebookservices_scope' => [ + 'suffix' => 'ags', + 'group' => 'endpoint', + 'claim' => 'scope', + 'isarray' => true + ], + 'custom_lineitems_url' => [ + 'suffix' => 'ags', + 'group' => 'endpoint', + 'claim' => 'lineitems', + 'isarray' => false + ], + 'custom_lineitem_url' => [ + 'suffix' => 'ags', + 'group' => 'endpoint', + 'claim' => 'lineitem', + 'isarray' => false + ], + 'custom_results_url' => [ + 'suffix' => 'ags', + 'group' => 'endpoint', + 'claim' => 'results', + 'isarray' => false + ], + 'custom_result_url' => [ + 'suffix' => 'ags', + 'group' => 'endpoint', + 'claim' => 'result', + 'isarray' => false + ], + 'custom_scores_url' => [ + 'suffix' => 'ags', + 'group' => 'endpoint', + 'claim' => 'scores', + 'isarray' => false + ], + 'custom_score_url' => [ + 'suffix' => 'ags', + 'group' => 'endpoint', + 'claim' => 'score', + 'isarray' => false + ], + 'lis_outcome_service_url' => [ + 'suffix' => 'bos', + 'group' => 'basicoutcomesservice', + 'claim' => 'lis_outcome_service_url', + 'isarray' => false + ], + 'lis_result_sourcedid' => [ + 'suffix' => 'bos', + 'group' => 'basicoutcomesservice', + 'claim' => 'lis_result_sourcedid', + 'isarray' => false + ], + ); +} /** * Return the launch data required for opening the external tool. * * @param stdClass $instance the external tool activity settings + * @param string $nonce * @return array the endpoint URL and parameters (including the signature) * @since Moodle 3.0 */ -function lti_get_launch_data($instance) { +function lti_get_launch_data($instance, $nonce = '') { global $PAGE, $CFG, $USER; if (empty($instance->typeid)) { $tool = lti_get_tool_by_url_match($instance->toolurl, $instance->course); if ($tool) { $typeid = $tool->id; + $ltiversion = $tool->ltiversion; } else { $tool = lti_get_tool_by_url_match($instance->securetoolurl, $instance->course); if ($tool) { $typeid = $tool->id; + $ltiversion = $tool->ltiversion; } else { $typeid = null; + $ltiversion = LTI_VERSION_1; } } } else { $typeid = $instance->typeid; $tool = lti_get_type($typeid); + $ltiversion = $tool->ltiversion; } if ($typeid) { @@ -145,6 +553,8 @@ function lti_get_launch_data($instance) { $toolproxy = null; if (!empty($instance->resourcekey)) { $key = $instance->resourcekey; + } else if ($ltiversion === LTI_VERSION_1P3) { + $key = $tool->clientid; } else if (!empty($typeconfig['resourcekey'])) { $key = $typeconfig['resourcekey']; } else { @@ -190,7 +600,7 @@ function lti_get_launch_data($instance) { } else { $requestparams = $allparams; } - $requestparams = array_merge($requestparams, lti_build_standard_request($instance, $orgid, $islti2)); + $requestparams = array_merge($requestparams, lti_build_standard_message($instance, $orgid, $ltiversion)); $customstr = ''; if (isset($typeconfig['customparameters'])) { $customstr = $typeconfig['customparameters']; @@ -231,14 +641,15 @@ function lti_get_launch_data($instance) { $requestparams['launch_presentation_return_url'] = $returnurl; - // Add the parameters configured by the LTI advantage services. + // Add the parameters configured by the LTI services. if ($typeid && !$islti2) { $services = lti_get_services(); foreach ($services as $service) { - $ltiadvantageparameters = $service->get_launch_parameters('basic-lti-launch-request', + $serviceparameters = $service->get_launch_parameters('basic-lti-launch-request', $course->id, $USER->id , $typeid, $instance->id); - foreach ($ltiadvantageparameters as $ltiadvantagekey => $ltiadvantagevalue) { - $requestparams[$ltiadvantagekey] = $ltiadvantagevalue; + foreach ($serviceparameters as $paramkey => $paramvalue) { + $requestparams['custom_' . $paramkey] = lti_parse_custom_parameter($toolproxy, $tool, $requestparams, $paramvalue, + $islti2); } } } @@ -254,8 +665,8 @@ function lti_get_launch_data($instance) { } } - if (!empty($key) && !empty($secret)) { - $parms = lti_sign_parameters($requestparams, $endpoint, "POST", $key, $secret); + if ((!empty($key) && !empty($secret)) || ($ltiversion === LTI_VERSION_1P3)) { + $parms = lti_sign_parameters($requestparams, $endpoint, "POST", $key, $secret, $typeid, $nonce); $endpointurl = new \moodle_url($endpoint); $endpointparams = $endpointurl->params(); @@ -517,6 +928,25 @@ function lti_build_request_lti2($tool, $params) { * @return array Request details */ function lti_build_standard_request($instance, $orgid, $islti2, $messagetype = 'basic-lti-launch-request') { + if (!$islti2) { + $ltiversion = LTI_VERSION_1; + } else { + $ltiversion = LTI_VERSION_2; + } + return lti_build_standard_message($instance, $orgid, $ltiversion, $messagetype); +} + +/** + * This function builds the standard parameters for an LTI message that must be sent to the tool producer + * + * @param stdClass $instance Basic LTI instance object + * @param string $orgid Organisation ID + * @param boolean $ltiversion LTI version to be used for tool messages + * @param string $messagetype The request message type. Defaults to basic-lti-launch-request if empty. + * + * @return array Message parameters + */ +function lti_build_standard_message($instance, $orgid, $ltiversion, $messagetype = 'basic-lti-launch-request') { global $CFG; $requestparams = array(); @@ -538,11 +968,7 @@ function lti_build_standard_request($instance, $orgid, $islti2, $messagetype = ' // Add oauth_callback to be compliant with the 1.0A spec. $requestparams['oauth_callback'] = 'about:blank'; - if (!$islti2) { - $requestparams['lti_version'] = 'LTI-1p0'; - } else { - $requestparams['lti_version'] = 'LTI-2p0'; - } + $requestparams['lti_version'] = $ltiversion; $requestparams['lti_message_type'] = $messagetype; if ($orgid) { @@ -623,14 +1049,15 @@ function lti_build_custom_parameters($toolproxy, $tool, $instance, $params, $cus * TC without further interaction from the user. False by default. * @param bool $canconfirm Flag for can_confirm parameter. False by default. * @param bool $copyadvice Indicates whether the TC is able and willing to make a local copy of a content item. False by default. + * @param string $nonce * @return stdClass The object containing the signed request parameters and the URL to the TP's Content-Item selection interface. * @throws moodle_exception When the LTI tool type does not exist.` * @throws coding_exception For invalid media type and presentation target parameters. */ function lti_build_content_item_selection_request($id, $course, moodle_url $returnurl, $title = '', $text = '', $mediatypes = [], $presentationtargets = [], $autocreate = false, $multiple = false, - $unsigned = false, $canconfirm = false, $copyadvice = false) { - global $USER; + $unsigned = false, $canconfirm = false, $copyadvice = false, $nonce = '') { + global $PAGE, $USER; $tool = lti_get_type($id); // Validate parameters. @@ -653,14 +1080,18 @@ function lti_build_content_item_selection_request($id, $course, moodle_url $retu $key = ''; $secret = ''; $islti2 = false; + $islti13 = false; if (isset($tool->toolproxyid)) { $islti2 = true; $toolproxy = lti_get_tool_proxy($tool->toolproxyid); $key = $toolproxy->guid; $secret = $toolproxy->secret; } else { + $islti13 = $tool->ltiversion === LTI_VERSION_1P3; $toolproxy = null; - if (!empty($typeconfig['resourcekey'])) { + if ($islti13 && !empty($tool->clientid)) { + $key = $tool->clientid; + } else if (!$islti13 && !empty($typeconfig['resourcekey'])) { $key = $typeconfig['resourcekey']; } if (!empty($typeconfig['password'])) { @@ -708,21 +1139,9 @@ function lti_build_content_item_selection_request($id, $course, moodle_url $retu $requestparams = array_merge($requestparams, $lti2params); } - // Add the parameters configured by the LTI advantage services. - if ($id && !$islti2) { - $services = lti_get_services(); - foreach ($services as $service) { - $ltiadvantageparameters = $service->get_launch_parameters('ContentItemSelectionRequest', - $course->id, $USER->id , $id); - foreach ($ltiadvantageparameters as $ltiadvantagekey => $ltiadvantagevalue) { - $requestparams[$ltiadvantagekey] = $ltiadvantagevalue; - } - } - } - // Get standard request parameters and merge to the request parameters. $orgid = !empty($typeconfig['organizationid']) ? $typeconfig['organizationid'] : ''; - $standardparams = lti_build_standard_request(null, $orgid, $islti2, 'ContentItemSelectionRequest'); + $standardparams = lti_build_standard_message(null, $orgid, $tool->ltiversion, 'ContentItemSelectionRequest'); $requestparams = array_merge($requestparams, $standardparams); // Get custom request parameters and merge to the request parameters. @@ -733,6 +1152,19 @@ function lti_build_content_item_selection_request($id, $course, moodle_url $retu $customparams = lti_build_custom_parameters($toolproxy, $tool, $instance, $requestparams, $customstr, '', $islti2); $requestparams = array_merge($requestparams, $customparams); + // Add the parameters configured by the LTI services. + if ($id && !$islti2) { + $services = lti_get_services(); + foreach ($services as $service) { + $serviceparameters = $service->get_launch_parameters('ContentItemSelectionRequest', + $PAGE->course->id, $USER->id , $id); + foreach ($serviceparameters as $paramkey => $paramvalue) { + $requestparams['custom_' . $paramkey] = lti_parse_custom_parameter($toolproxy, $tool, $requestparams, $paramvalue, + $islti2); + } + } + } + // Allow request params to be updated by sub-plugins. $plugins = core_component::get_plugin_list('ltisource'); foreach (array_keys($plugins) as $plugin) { @@ -743,13 +1175,18 @@ function lti_build_content_item_selection_request($id, $course, moodle_url $retu } } - // Media types. Set to ltilink by default if empty. - if (empty($mediatypes)) { - $mediatypes = [ - 'application/vnd.ims.lti.v1.ltilink', - ]; + if (!$islti13) { + // Media types. Set to ltilink by default if empty. + if (empty($mediatypes)) { + $mediatypes = [ + 'application/vnd.ims.lti.v1.ltilink', + ]; + } + $requestparams['accept_media_types'] = implode(',', $mediatypes); + } else { + // Only LTI links are currently supported. + $requestparams['accept_types'] = 'ltiResourceLink'; } - $requestparams['accept_media_types'] = implode(',', $mediatypes); // Presentation targets. Supports frame, iframe, window by default if empty. if (empty($presentationtargets)) { @@ -770,7 +1207,7 @@ function lti_build_content_item_selection_request($id, $course, moodle_url $retu $requestparams['content_item_return_url'] = $returnurl->out(false); $requestparams['title'] = $title; $requestparams['text'] = $text; - $signedparams = lti_sign_parameters($requestparams, $toolurlout, 'POST', $key, $secret); + $signedparams = lti_sign_parameters($requestparams, $toolurlout, 'POST', $key, $secret, $id, $nonce); $toolurlparams = $toolurl->params(); // Strip querystring params in endpoint url from $signedparams to avoid duplication. @@ -805,31 +1242,20 @@ function lti_build_content_item_selection_request($id, $course, moodle_url $retu } /** - * Processes the tool provider's response to the ContentItemSelectionRequest and builds the configuration data from the - * selected content item. This configuration data can be then used when adding a tool into the course. + * Verifies the OAuth signature of an incoming message. * * @param int $typeid The tool type ID. - * @param string $messagetype The value for the lti_message_type parameter. - * @param string $ltiversion The value for the lti_version parameter. * @param string $consumerkey The consumer key. - * @param string $contentitemsjson The JSON string for the content_items parameter. - * @return stdClass The array of module information objects. + * @return stdClass Tool type * @throws moodle_exception * @throws lti\OAuthException */ -function lti_tool_configuration_from_content_item($typeid, $messagetype, $ltiversion, $consumerkey, $contentitemsjson) { +function lti_verify_oauth_signature($typeid, $consumerkey) { $tool = lti_get_type($typeid); // Validate parameters. if (!$tool) { throw new moodle_exception('errortooltypenotfound', 'mod_lti'); } - // Check lti_message_type. Show debugging if it's not set to ContentItemSelection. - // No need to throw exceptions for now since lti_message_type does not seem to be used in this processing at the moment. - if ($messagetype !== 'ContentItemSelection') { - debugging("lti_message_type is invalid: {$messagetype}. It should be set to 'ContentItemSelection'.", - DEBUG_DEVELOPER); - } - $typeconfig = lti_get_type_config($typeid); if (isset($tool->toolproxyid)) { @@ -852,17 +1278,6 @@ function lti_tool_configuration_from_content_item($typeid, $messagetype, $ltiver } } - // Check LTI versions from our side and the response's side. Show debugging if they don't match. - // No need to throw exceptions for now since LTI version does not seem to be used in this processing at the moment. - $expectedversion = LTI_VERSION_1; - if ($islti2) { - $expectedversion = LTI_VERSION_2; - } - if ($ltiversion !== $expectedversion) { - debugging("lti_version from response does not match the tool's configuration. Tool: {$expectedversion}," . - " Response: {$ltiversion}", DEBUG_DEVELOPER); - } - if ($consumerkey !== $key) { throw new moodle_exception('errorincorrectconsumerkey', 'mod_lti'); } @@ -879,6 +1294,87 @@ function lti_tool_configuration_from_content_item($typeid, $messagetype, $ltiver throw new lti\OAuthException("OAuth signature failed: " . $e->getMessage()); } + return $tool; +} + +/** + * Verifies the JWT signature of an incoming message. + * + * @param int $typeid The tool type ID. + * @param string $consumerkey The consumer key. + * @param string $jwtparam JWT parameter value + * + * @return stdClass Tool type + * @throws moodle_exception + */ +function lti_verify_jwt_signature($typeid, $consumerkey, $jwtparam) { + $tool = lti_get_type($typeid); + // Validate parameters. + if (!$tool) { + throw new moodle_exception('errortooltypenotfound', 'mod_lti'); + } + $typeconfig = lti_get_type_config($typeid); + + if (isset($tool->toolproxyid)) { + throw new moodle_exception('JWT security not supported with LTI 2'); + } else { + if (!empty($tool->clientid)) { + $key = $tool->clientid; + } else { + $key = ''; + } + if (!empty($typeconfig['publickey'])) { + $publickey = $typeconfig['publickey']; + } else { + $publickey = ''; + } + } + if ($consumerkey !== $key) { + throw new moodle_exception('errorincorrectconsumerkey', 'mod_lti'); + } + if (empty($publickey)) { + throw new moodle_exception('No public key configured'); + } + + try { + $jwt = JWT::decode($jwtparam, $publickey, array('RS256')); + } catch (Exception $e) { + throw new moodle_exception($e->getMessage()); + } + + return $tool; +} + +/** + * Processes the tool provider's response to the ContentItemSelectionRequest and builds the configuration data from the + * selected content item. This configuration data can be then used when adding a tool into the course. + * + * @param stdClass $tool The tool type. + * @param string $messagetype The value for the lti_message_type parameter. + * @param string $ltiversion The value for the lti_version parameter. + * @param string $consumerkey The consumer key. + * @param string $contentitemsjson The JSON string for the content_items parameter. + * @return stdClass The array of module information objects. + * @throws moodle_exception + * @throws lti\OAuthException + */ +function lti_tool_configuration_from_content_item($tool, $messagetype, $ltiversion, $consumerkey, $contentitemsjson) { + // Check lti_message_type. Show debugging if it's not set to ContentItemSelection. + // No need to throw exceptions for now since lti_message_type does not seem to be used in this processing at the moment. + if ($messagetype !== 'ContentItemSelection') { + debugging("lti_message_type is invalid: {$messagetype}. It should be set to 'ContentItemSelection'.", + DEBUG_DEVELOPER); + } + + // Check LTI versions from our side and the response's side. Show debugging if they don't match. + // No need to throw exceptions for now since LTI version does not seem to be used in this processing at the moment. + $expectedversion = $tool->ltiversion; + $islti2 = ($expectedversion === LTI_VERSION_2); + if ($ltiversion !== $expectedversion) { + debugging("lti_version from response does not match the tool's configuration. Tool: {$expectedversion}," . + " Response: {$ltiversion}", DEBUG_DEVELOPER); + } + $items = json_decode($contentitemsjson); if (empty($items)) { throw new moodle_exception('errorinvaliddata', 'mod_lti', '', $contentitemsjson); @@ -890,6 +1386,7 @@ function lti_tool_configuration_from_content_item($typeid, $messagetype, $ltiver $config = null; if (!empty($items->{'@graph'})) { $item = $items->{'@graph'}[0]; + $typeconfig = lti_get_type_type_config($tool->id); $config = new stdClass(); $config->name = ''; @@ -919,11 +1416,11 @@ function lti_tool_configuration_from_content_item($typeid, $messagetype, $ltiver $config->toolurl = $url->out(false); $config->typeid = 0; } else { - $config->typeid = $typeid; + $config->typeid = $tool->id; } $config->instructorchoiceacceptgrades = LTI_SETTING_NEVER; - if (!$islti2 && isset($typeconfig['acceptgrades'])) { - $acceptgrades = $typeconfig['acceptgrades']; + if (!$islti2 && isset($typeconfig->lti_acceptgrades)) { + $acceptgrades = $typeconfig->lti_acceptgrades; if ($acceptgrades == LTI_SETTING_ALWAYS) { // We create a line item regardless if the definition contains one or not. $config->instructorchoiceacceptgrades = LTI_SETTING_ALWAYS; @@ -972,6 +1469,91 @@ function lti_tool_configuration_from_content_item($typeid, $messagetype, $ltiver return $config; } +/** + * Converts the new Deep-Linking format for Content-Items to the old format. + * + * @param string $param JSON string representing new Deep-Linking format + */ +function lti_convert_content_items($param) { + $items = array(); + $json = json_decode($param); + if (!empty($json) && is_array($json)) { + foreach ($json as $item) { + if (isset($item->type)) { + $newitem = clone $item; + switch ($item->type) { + case 'ltiResourceLink': + $newitem->{'@type'} = 'LtiLinkItem'; + $newitem->mediaType = 'application\/vnd.ims.lti.v1.ltilink'; + break; + case 'link': + case 'rich': + $newitem->{'@type'} = 'ContentItem'; + $newitem->mediaType = 'text/html'; + break; + case 'file': + $newitem->{'@type'} = 'FileItem'; + break; + } + unset($newitem->type); + if (isset($item->html)) { + $newitem->text = $item->html; + unset($newitem->html); + } + if (isset($item->presentation)) { + $newitem->placementAdvice = new stdClass(); + if (isset($item->presentation->documentTarget)) { + $newitem->placementAdvice->presentationDocumentTarget = $item->presentation->documentTarget; + } + if (isset($item->presentation->windowTarget)) { + $newitem->placementAdvice->windowTarget = $item->presentation->windowTarget; + } + if (isset($item->presentation->width)) { + $newitem->placementAdvice->dislayWidth = $item->presentation->width; + } + if (isset($item->presentation->height)) { + $newitem->placementAdvice->dislayHeight = $item->presentation->height; + } + unset($newitem->presentation); + } + if (isset($item->icon) && isset($item->icon->url)) { + $newitem->icon->{'@id'} = $item->icon->url; + unset($newitem->icon->url); + } + if (isset($item->thumbnail) && isset($item->thumbnail->url)) { + $newitem->thumbnail->{'@id'} = $item->thumbnail->url; + unset($newitem->thumbnail->url); + } + if (isset($item->lineItem)) { + unset($newitem->lineItem); + $newitem->lineItem = new stdClass(); + $newitem->lineItem->{'@type'} = 'LineItem'; + $newitem->lineItem->reportingMethod = 'http://purl.imsglobal.org/ctx/lis/v2p1/Result#totalScore'; + if (isset($item->lineItem->label)) { + $newitem->lineItem->label = $item->lineItem->label; + } + if (isset($item->lineItem->resourceId)) { + $newitem->lineItem->assignedActivity = new stdClass(); + $newitem->lineItem->assignedActivity->activityId = $item->lineItem->resourceId; + } + if (isset($item->lineItem->scoreMaximum)) { + $newitem->lineItem->scoreConstraints = new stdClass(); + $newitem->lineItem->scoreConstraints->{'@type'} = 'NumericLimits'; + $newitem->lineItem->scoreConstraints->totalMaximum = $item->lineItem->scoreMaximum; + } + } + $items[] = $newitem; + } + } + } + + $newitems = new stdClass(); + $newitems->{'@context'} = 'http://purl.imsglobal.org/ctx/lti/v1/ContentItem'; + $newitems->{'@graph'} = $items; + + return json_encode($newitems); +} + function lti_get_tool_table($tools, $id) { global $OUTPUT; $html = ''; @@ -1188,20 +1770,22 @@ function lti_get_enabled_capabilities($tool) { } else { $enabledcapabilities = array(); } - $paramstr = str_replace("\r\n", "\n", $tool->parameter); - $paramstr = str_replace("\n\r", "\n", $paramstr); - $paramstr = str_replace("\r", "\n", $paramstr); - $params = explode("\n", $paramstr); - foreach ($params as $param) { - $pos = strpos($param, '='); - if (($pos === false) || ($pos < 1)) { - continue; - } - $value = trim(core_text::substr($param, $pos + 1, strlen($param))); - if (substr($value, 0, 1) == '$') { - $value = substr($value, 1); - if (!in_array($value, $enabledcapabilities)) { - $enabledcapabilities[] = $value; + if (!empty($tool->parameter)) { + $paramstr = str_replace("\r\n", "\n", $tool->parameter); + $paramstr = str_replace("\n\r", "\n", $paramstr); + $paramstr = str_replace("\r", "\n", $paramstr); + $params = explode("\n", $paramstr); + foreach ($params as $param) { + $pos = strpos($param, '='); + if (($pos === false) || ($pos < 1)) { + continue; + } + $value = trim(core_text::substr($param, $pos + 1, strlen($param))); + if (substr($value, 0, 1) == '$') { + $value = substr($value, 1); + if (!in_array($value, $enabledcapabilities)) { + $enabledcapabilities[] = $value; + } } } } @@ -1231,12 +1815,11 @@ function lti_split_custom_parameters($toolproxy, $tool, $params, $customstr, $is continue; } $key = trim(core_text::substr($line, 0, $pos)); - $key = lti_map_keyname($key, false); $val = trim(core_text::substr($line, $pos + 1, strlen($line))); $val = lti_parse_custom_parameter($toolproxy, $tool, $params, $val, $islti2); $key2 = lti_map_keyname($key); $retval['custom_'.$key2] = $val; - if ($key != $key2) { + if (($islti2 || ($tool->ltiversion === LTI_VERSION_1P3)) && ($key != $key2)) { $retval['custom_'.$key] = $val; } } @@ -1304,11 +1887,12 @@ function lti_parse_custom_parameter($toolproxy, $tool, $params, $value, $islti2) } else { $value = lti_calculate_custom_parameter($value1); } - } else if ($islti2) { + } else { $val = $value; $services = lti_get_services(); foreach ($services as $service) { $service->set_tool_proxy($toolproxy); + $service->set_type($tool); $value = $service->parse_value($val); if ($val != $value) { break; @@ -1346,16 +1930,18 @@ function lti_calculate_custom_parameter($value) { * @return string Processed name */ function lti_map_keyname($key, $tolower = true) { - $newkey = ""; if ($tolower) { + $newkey = ''; $key = core_text::strtolower(trim($key)); - } - foreach (str_split($key) as $ch) { - if ( ($ch >= 'a' && $ch <= 'z') || ($ch >= '0' && $ch <= '9') || (!$tolower && ($ch >= 'A' && $ch <= 'Z'))) { - $newkey .= $ch; - } else { - $newkey .= '_'; + foreach (str_split($key) as $ch) { + if ( ($ch >= 'a' && $ch <= 'z') || ($ch >= '0' && $ch <= '9') ) { + $newkey .= $ch; + } else { + $newkey .= '_'; + } } + } else { + $newkey = $key; } return $newkey; } @@ -1697,6 +2283,7 @@ function lti_get_shared_secrets_by_key($key) { // Look up the shared secret for the specified key in both the types_config table (for configured tools) // And in the lti resource table for ad-hoc tools. + $lti13 = LTI_VERSION_1P3; $query = "SELECT t2.value FROM {lti_types_config} t1 JOIN {lti_types_config} t2 ON t1.typeid = t2.typeid @@ -1705,18 +2292,19 @@ function lti_get_shared_secrets_by_key($key) { AND t1.value = :key1 AND t2.name = 'password' AND type.state = :configured1 + AND type.ltiversion <> :ltiversion UNION SELECT tp.secret AS value FROM {lti_tool_proxies} tp JOIN {lti_types} t ON tp.id = t.toolproxyid WHERE tp.guid = :key2 AND t.state = :configured2 - UNION - SELECT password AS value + UNION + SELECT password AS value FROM {lti} WHERE resourcekey = :key3"; - $sharedsecrets = $DB->get_records_sql($query, array('configured1' => LTI_TOOL_STATE_CONFIGURED, + $sharedsecrets = $DB->get_records_sql($query, array('configured1' => LTI_TOOL_STATE_CONFIGURED, 'ltiversion' => $lti13, 'configured2' => LTI_TOOL_STATE_CONFIGURED, 'key1' => $key, 'key2' => $key, 'key3' => $key)); $values = array_map(function($item) { @@ -1830,6 +2418,11 @@ function lti_get_type_type_config($id) { $type->lti_toolurl = $basicltitype->baseurl; + $type->lti_ltiversion = $basicltitype->ltiversion; + + $type->lti_clientid = $basicltitype->clientid; + $type->lti_clientid_disabled = $type->lti_clientid; + $type->lti_description = $basicltitype->description; $type->lti_parameters = $basicltitype->parameter; @@ -1844,6 +2437,15 @@ function lti_get_type_type_config($id) { if (isset($config['password'])) { $type->lti_password = $config['password']; } + if (isset($config['publickey'])) { + $type->lti_publickey = $config['publickey']; + } + if (isset($config['initiatelogin'])) { + $type->lti_initiatelogin = $config['initiatelogin']; + } + if (isset($config['redirectionuris'])) { + $type->lti_redirectionuris = $config['redirectionuris']; + } if (isset($config['sendname'])) { $type->lti_sendname = $config['sendname']; @@ -1913,14 +2515,10 @@ function lti_get_type_type_config($id) { // Get the parameters from the LTI services. $services = lti_get_services(); - $ltiserviceprefixlength = 11; foreach ($services as $service) { - $configurationparameters = $service->get_configuration_parameter_names(); - foreach ($configurationparameters as $ltiserviceparameter) { - $shortltiserviceparameter = substr($ltiserviceparameter, $ltiserviceprefixlength); - if (isset($config[$shortltiserviceparameter])) { - $type->$ltiserviceparameter = $config[$shortltiserviceparameter]; - } + $name = $service->get_component_id(); + if (isset($config[$name])) { + $type->{$name} = $config[$name]; } } @@ -1938,6 +2536,17 @@ function lti_prepare_type_for_save($type, $config) { if (isset($config->lti_typename)) { $type->name = $config->lti_typename; } + if (isset($config->lti_ltiversion)) { + $type->ltiversion = $config->lti_ltiversion; + } + if (isset($config->lti_clientid)) { + $type->clientid = $config->lti_clientid; + } + if ((!empty($type->ltiversion) && $type->ltiversion === LTI_VERSION_1P3) && empty($type->clientid)) { + $type->clientid = random_string(15); + } else if (empty($type->clientid)) { + $type->clientid = null; + } if (isset($config->lti_coursevisible)) { $type->coursevisible = $config->lti_coursevisible; } @@ -1969,6 +2578,8 @@ function lti_prepare_type_for_save($type, $config) { unset ($config->lti_typename); unset ($config->lti_toolurl); unset ($config->lti_description); + unset ($config->lti_ltiversion); + unset ($config->lti_clientid); unset ($config->lti_icon); unset ($config->lti_secureicon); } @@ -1997,7 +2608,7 @@ function lti_update_type($type, $config) { if (substr($key, 0, 11) == 'ltiservice_' && !is_null($value)) { $record = new \StdClass(); $record->typeid = $type->id; - $record->name = substr($key, 11); + $record->name = $key; $record->value = $value; lti_update_config($record); } @@ -2026,6 +2637,10 @@ function lti_add_type($type, $config) { $type->state = LTI_TOOL_STATE_PENDING; } + if (!isset($type->ltiversion)) { + $type->ltiversion = LTI_VERSION_1; + } + if (!isset($type->timecreated)) { $type->timecreated = time(); } @@ -2048,12 +2663,13 @@ function lti_add_type($type, $config) { if ($id) { foreach ($config as $key => $value) { if (!is_null($value)) { - $fieldparts = preg_split("/(lti|ltiservice)_/i", $key); - // If array has only one element, it did not start with the pattern. - if (count($fieldparts) < 2) { + if (substr($key, 0, 4) === 'lti_') { + $fieldname = substr($key, 4); + } else if (substr($key, 0, 11) !== 'ltiservice_') { continue; + } else { + $fieldname = $key; } - $fieldname = $fieldparts[1]; $record = new \StdClass(); $record->typeid = $id; @@ -2298,7 +2914,7 @@ function lti_update_config($config) { /** * Gets the tool settings * - * @param int $toolproxyid Id of tool proxy record + * @param int $toolproxyid Id of tool proxy record (or tool ID if negative) * @param int $courseid Id of course (null if system settings) * @param int $instanceid Id of course module (null if system or context settings) * @@ -2308,8 +2924,13 @@ function lti_get_tool_settings($toolproxyid, $courseid = null, $instanceid = nul global $DB; $settings = array(); - $settingsstr = $DB->get_field('lti_tool_settings', 'settings', array('toolproxyid' => $toolproxyid, - 'course' => $courseid, 'coursemoduleid' => $instanceid)); + if ($toolproxyid > 0) { + $settingsstr = $DB->get_field('lti_tool_settings', 'settings', array('toolproxyid' => $toolproxyid, + 'course' => $courseid, 'coursemoduleid' => $instanceid)); + } else { + $settingsstr = $DB->get_field('lti_tool_settings', 'settings', array('typeid' => -$toolproxyid, + 'course' => $courseid, 'coursemoduleid' => $instanceid)); + } if ($settingsstr !== false) { $settings = json_decode($settingsstr, true); } @@ -2320,7 +2941,7 @@ function lti_get_tool_settings($toolproxyid, $courseid = null, $instanceid = nul * Sets the tool settings ( * * @param array $settings Array of settings - * @param int $toolproxyid Id of tool proxy record + * @param int $toolproxyid Id of tool proxy record (or tool ID if negative) * @param int $courseid Id of course (null if system settings) * @param int $instanceid Id of course module (null if system or context settings) */ @@ -2328,13 +2949,22 @@ function lti_set_tool_settings($settings, $toolproxyid, $courseid = null, $insta global $DB; $json = json_encode($settings); - $record = $DB->get_record('lti_tool_settings', array('toolproxyid' => $toolproxyid, - 'course' => $courseid, 'coursemoduleid' => $instanceid)); + if ($toolproxyid >= 0) { + $record = $DB->get_record('lti_tool_settings', array('toolproxyid' => $toolproxyid, + 'course' => $courseid, 'coursemoduleid' => $instanceid)); + } else { + $record = $DB->get_record('lti_tool_settings', array('typeid' => -$toolproxyid, + 'course' => $courseid, 'coursemoduleid' => $instanceid)); + } if ($record !== false) { $DB->update_record('lti_tool_settings', (object)array('id' => $record->id, 'settings' => $json, 'timemodified' => time())); } else { $record = new \stdClass(); - $record->toolproxyid = $toolproxyid; + if ($toolproxyid > 0) { + $record->toolproxyid = $toolproxyid; + } else { + $record->typeid = -$toolproxyid; + } $record->course = $courseid; $record->coursemoduleid = $instanceid; $record->settings = $json; @@ -2354,23 +2984,217 @@ function lti_set_tool_settings($settings, $toolproxyid, $courseid = null, $insta * @param string $oauthconsumersecret * @return array|null */ -function lti_sign_parameters($oldparms, $endpoint, $method, $oauthconsumerkey, $oauthconsumersecret) { +function lti_sign_parameters($oldparms, $endpoint, $method, $oauthconsumerkey, $oauthconsumersecret, $typeid = 0, $nonce = '') { $parms = $oldparms; $testtoken = ''; - // TODO: Switch to core oauthlib once implemented - MDL-30149. - $hmacmethod = new lti\OAuthSignatureMethod_HMAC_SHA1(); - $testconsumer = new lti\OAuthConsumer($oauthconsumerkey, $oauthconsumersecret, null); - $accreq = lti\OAuthRequest::from_consumer_and_token($testconsumer, $testtoken, $method, $endpoint, $parms); - $accreq->sign_request($hmacmethod, $testconsumer, $testtoken); + if ($parms['lti_version'] !== LTI_VERSION_1P3) { + // TODO: Switch to core oauthlib once implemented - MDL-30149. + $hmacmethod = new lti\OAuthSignatureMethod_HMAC_SHA1(); + $testconsumer = new lti\OAuthConsumer($oauthconsumerkey, $oauthconsumersecret, null); + $accreq = lti\OAuthRequest::from_consumer_and_token($testconsumer, $testtoken, $method, $endpoint, $parms); + $accreq->sign_request($hmacmethod, $testconsumer, $testtoken); - $newparms = $accreq->get_parameters(); + $newparms = $accreq->get_parameters(); + } else { + $newparms = array(); + $newparms['id_token'] = lti_convert_to_jwt($parms, $endpoint, $oauthconsumerkey, $typeid, $nonce); + } return $newparms; } +/** + * Converts the message paramters to their equivalent JWT claim and signs the payload to launch the external tool using JWT + * + * @param array $parms Parameters to be passed for signing + * @param string $endpoint url of the external tool + * @param string $oauthconsumerkey + * @param int $typeid + * @param string $nonce + * @return string + */ +function lti_convert_to_jwt($parms, $endpoint, $oauthconsumerkey, $typeid, $nonce) { + + if (empty($typeid)) { + $typeid = 0; + } + $messagetypemapping = lti_get_jwt_message_type_mapping(); + if (isset($parms['lti_message_type']) && array_key_exists($parms['lti_message_type'], $messagetypemapping)) { + $parms['lti_message_type'] = $messagetypemapping[$parms['lti_message_type']]; + } + if (isset($parms['roles'])) { + $roles = explode(',', $parms['roles']); + $newroles = array(); + foreach ($roles as $role) { + if (strpos($role, 'urn:lti:role:ims/lis/') === 0) { + $role = 'http://purl.imsglobal.org/vocab/lis/v2/membership#' . substr($role, 21); + } else if (strpos($role, 'urn:lti:instrole:ims/lis/') === 0) { + $role = 'http://purl.imsglobal.org/vocab/lis/v2/institution/person#' . substr($role, 25); + } else if (strpos($role, 'urn:lti:sysrole:ims/lis/') === 0) { + $role = 'http://purl.imsglobal.org/vocab/lis/v2/system/person#' . substr($role, 24); + } else if ((strpos($role, '://') === false) && (strpos($role, 'urn:') !== 0)) { + $role = "http://purl.imsglobal.org/vocab/lis/v2/membership#{$role}"; + } + $newroles[] = $role; + } + $parms['roles'] = implode(',', $newroles); + } + + $now = time(); + if (empty($nonce)) { + $nonce = bin2hex(openssl_random_pseudo_bytes(10)); + } + $claimmapping = lti_get_jwt_claim_mapping(); + $payload = array( + 'nonce' => $nonce, + 'iat' => $now, + 'exp' => $now + 60, + ); + $payload['iss'] = get_config('mod_lti', 'platformid'); + $payload['aud'] = $oauthconsumerkey; + $payload[LTI_JWT_CLAIM_PREFIX . '/claim/deployment_id'] = strval($typeid); + $payload[LTI_JWT_CLAIM_PREFIX . '/claim/target_link_uri'] = $endpoint; + + foreach ($parms as $key => $value) { + $claim = LTI_JWT_CLAIM_PREFIX; + if (array_key_exists($key, $claimmapping)) { + $mapping = $claimmapping[$key]; + if ($mapping['isarray']) { + $value = explode(',', $value); + sort($value); + } + if (!empty($mapping['suffix'])) { + $claim .= "-{$mapping['suffix']}"; + } + $claim .= '/claim/'; + if (is_null($mapping['group'])) { + $payload[$mapping['claim']] = $value; + } else if (empty($mapping['group'])) { + $payload["{$claim}{$mapping['claim']}"] = $value; + } else { + $claim .= $mapping['group']; + $payload[$claim][$mapping['claim']] = $value; + } + } else if (strpos($key, 'custom_') === 0) { + $payload["{$claim}/claim/custom"][substr($key, 7)] = $value; + } else if (strpos($key, 'ext_') === 0) { + $payload["{$claim}/claim/ext"][substr($key, 4)] = $value; + } + } + + $privatekey = get_config('mod_lti', 'privatekey'); + $kid = get_config('mod_lti', 'kid'); + $jwt = JWT::encode($payload, $privatekey, 'RS256', $kid); + return $jwt; + +} + +/** + * Verfies the JWT and converts its claims to their equivalent message parameter. + * + * @param int $typeid + * @param string $jwtparam JWT parameter + * + * @return stdClass Tool type + * @throws moodle_exception + */ +function lti_convert_from_jwt($typeid, $jwtparam) { + + $parts = explode('.', $jwtparam); + $ok = (count($parts) === 3); + if ($ok) { + $payload = JWT::urlsafeB64Decode($parts[1]); + $claims = json_decode($payload, true); + $ok = !is_null($claims) && !empty($claims['iss']); + } + if ($ok) { + $tool = lti_verify_jwt_signature($typeid, $claims['iss'], $jwtparam); + } + $params = array(); + $params['oauth_consumer_key'] = $claims['iss']; + foreach (lti_get_jwt_claim_mapping() as $key => $mapping) { + $claim = LTI_JWT_CLAIM_PREFIX; + if (!empty($mapping['suffix'])) { + $claim .= "-{$mapping['suffix']}"; + } + $claim .= '/claim/'; + if (is_null($mapping['group'])) { + $claim = $mapping['claim']; + } else if (empty($mapping['group'])) { + $claim .= $mapping['claim']; + } else { + $claim .= $mapping['group']; + } + if (isset($claims[$claim])) { + $value = null; + if (empty($mapping['group'])) { + $value = $claims[$claim]; + } else { + $group = $claims[$claim]; + if (!is_array($group)) { + $ok = false; + $error = "'{$claim}' should be an array"; + } else if (array_key_exists($mapping['claim'], + $group)) { + $value = $group[$mapping['claim']]; + } + } + if (!empty($value) && $mapping['isarray']) { + if (!is_array($value)) { + $ok = false; + $error = "'{$value}' should be an array"; + } else if (is_array($value[0])) { + $value = json_encode($value); + } else { + $value = implode(',', $value); + } + } + if (!is_null($value) && is_string($value) && (strlen($value) > 0)) { + $params[$key] = $value; + } + } + } + if ($ok) { + $claim = LTI_JWT_CLAIM_PREFIX . '/claim/custom'; + if (isset($claims[$claim])) { + $custom = $claims[$claim]; + if (!is_array($custom)) { + $ok = false; + $error = "'{$custom}' should be an array"; + } else { + foreach ($custom as $key => $value) { + $params["custom_{$key}"] = $value; + } + } + } + } + if ($ok) { + $claim = LTI_JWT_CLAIM_PREFIX . '/claim/ext'; + if (isset($claims[$claim])) { + $ext = $claims[$claim]; + if (!is_array($ext)) { + $ok = false; + $error = "'{$ext}' should be an array"; + } else { + foreach ($ext as $key => $value) { + $params["ext_{$key}"] = $value; + } + } + } + } + if (isset($params['content_items'])) { + $params['content_items'] = lti_convert_content_items($params['content_items']); + } + $messagetypemapping = lti_get_jwt_message_type_mapping(); + if (isset($params['lti_message_type']) && array_key_exists($params['lti_message_type'], $messagetypemapping)) { + $params['lti_message_type'] = $messagetypemapping[$params['lti_message_type']]; + } + return array($tool, $params); +} + /** * Posts the launch petition HTML * @@ -2437,6 +3261,66 @@ function lti_post_launch_html($newparms, $endpoint, $debug=false) { return $r; } +/** + * Generate the form for initiating a login request for an LTI 1.3 message + * + * @param int $courseid Course ID + * @param int $id LTI instance ID + * @param stdClass|null $instance LTI instance + * @param stdClass $config Tool type configuration + * @param string $messagetype LTI message type + * @param string $title Title of content item + * @param string $text Description of content item + * @return string + */ +function lti_initiatelogin($courseid, $id, $instance, $config, $messagetype = 'basic-lti-launch-request', $title = '', $text = '') { + global $SESSION, $USER, $COURSE; + + if (!empty($instance)) { + $endpoint = !empty($instance->toolurl) ? $instance->toolurl : $config->lti_toolurl; + } else { + $endpoint = $config->lti_toolurl; + if (($messagetype === 'ContentItemSelectionRequest') && !empty($config->lti_toolurl_ContentItemSelectionRequest)) { + $endpoint = $config->lti_toolurl_ContentItemSelectionRequest; + } + } + $endpoint = trim($endpoint); + + // If SSL is forced make sure https is on the normal launch URL. + if (isset($typeconfig['forcessl']) && ($typeconfig['forcessl'] == '1')) { + $endpoint = lti_ensure_url_is_https($endpoint); + } else if (!strstr($endpoint, '://')) { + $endpoint = 'http://' . $endpoint; + } + + $params = array(); + $params['iss'] = get_config('mod_lti', 'platformid'); + $params['target_link_uri'] = $endpoint; + $params['login_hint'] = $USER->id; + $params['lti_message_hint'] = $id; + $SESSION->lti_message_hint = "{$courseid},{$config->typeid},{$id}," . base64_encode($title) . ',' . + base64_encode($text); + + $r = "

lti_initiatelogin . + "\" name=\"ltiInitiateLoginForm\" id=\"ltiInitiateLoginForm\" method=\"post\" " . + "encType=\"application/x-www-form-urlencoded\">\n"; + + foreach ($params as $key => $value) { + $key = htmlspecialchars($key); + $value = htmlspecialchars($value); + $r .= " \n"; + } + $r .= "
\n"; + + $r .= "\n"; + + return $r; +} + function lti_get_type($typeid) { global $DB; @@ -2668,6 +3552,8 @@ function lti_get_capabilities() { 'Membership.role' => 'roles', 'Result.sourcedId' => 'lis_result_sourcedid', 'Result.autocreate' => 'lis_outcome_service_url', + 'BasicOutcome.sourcedId' => 'lis_result_sourcedid', + 'BasicOutcome.url' => 'lis_outcome_service_url', 'Moodle.Person.userGroupIds' => null); return $capabilities; @@ -2735,6 +3621,31 @@ function lti_get_service_by_resource_id($services, $resourceid) { } +/** + * Initializes an array with the scopes for services supported by the LTI module + * + * @param object $type LTI tool type + * @param object $typeconfig LTI tool type configuration + * + * @return array List of scopes + */ +function lti_get_permitted_service_scopes($type, $typeconfig) { + + $services = lti_get_services(); + $scopes = array(); + foreach ($services as $service) { + $service->set_type($type); + $service->set_typeconfig($typeconfig); + $servicescopes = $service->get_permitted_scopes(); + if (!empty($servicescopes)) { + $scopes = array_merge($scopes, $servicescopes); + } + } + + return $scopes; + +} + /** * Extracts the named contexts from a tool proxy * @@ -2861,6 +3772,13 @@ function get_tool_type_urls(stdClass $type) { $urls['course'] = $courseurl; } + $url = new moodle_url('/mod/lti/certs.php'); + $urls['publickeyset'] = $url->out(); + $url = new moodle_url('/mod/lti/token.php'); + $urls['accesstoken'] = $url->out(); + $url = new moodle_url('/mod/lti/auth.php'); + $urls['authrequest'] = $url->out(); + return $urls; } @@ -2923,6 +3841,33 @@ function get_tool_type_state_info(stdClass $type) { ); } +/** + * Returns information on the configuration of the tool type + * + * @param stdClass $type The tool type + * + * @return array An array with configuration details + */ +function get_tool_type_config($type) { + global $CFG; + + $platformid = get_config('mod_lti', 'platformid'); + $clientid = $type->clientid; + $deploymentid = $type->id; + $publickeyseturl = "{$CFG->wwwroot}/mod/lti/certs.php"; + $accesstokenurl = "{$CFG->wwwroot}/mod/lti/token.php"; + $authrequesturl = "{$CFG->wwwroot}/mod/lti/auth.php"; + + return array( + 'platformid' => $platformid, + 'clientid' => $clientid, + 'deploymentid' => $deploymentid, + 'publickeyseturl' => $publickeyseturl, + 'accesstokenurl' => $accesstokenurl, + 'authrequesturl' => $authrequesturl + ); +} + /** * Returns a summary of each LTI capability this tool type requires in plain language * @@ -2984,6 +3929,8 @@ function get_tool_type_instance_ids($type) { * @return array An array of values representing this type */ function serialise_tool_type(stdClass $type) { + global $CFG; + $capabilitygroups = get_tool_type_capability_groups($type); $instanceids = get_tool_type_instance_ids($type); // Clean the name. We don't want tags here. @@ -3000,6 +3947,9 @@ function serialise_tool_type(stdClass $type) { 'description' => $description, 'urls' => get_tool_type_urls($type), 'state' => get_tool_type_state_info($type), + 'platformid' => get_config('mod_lti', 'platformid'), + 'clientid' => $type->clientid, + 'deploymentid' => $type->id, 'hascapabilitygroups' => !empty($capabilitygroups), 'capabilitygroups' => $capabilitygroups, // Course ID of 1 means it's not linked to a course. @@ -3269,3 +4219,38 @@ function get_tag($tagname, $xpath, $attribute = null) { } return null; } + +/** + * Create a new access token. + * + * @param int $typeid Tool type ID + * @param string[] $scopes Scopes permitted for new token + * + * @return stdClass Access token + */ +function lti_new_access_token($typeid, $scopes) { + global $DB; + + // Make sure the token doesn't exist (even if it should be almost impossible with the random generation). + $numtries = 0; + do { + $numtries ++; + $generatedtoken = md5(uniqid(rand(), 1)); + if ($numtries > 5) { + throw new moodle_exception('Failed to generate LTI access token'); + } + } while ($DB->record_exists('lti_access_tokens', array('token' => $generatedtoken))); + $newtoken = new stdClass(); + $newtoken->typeid = $typeid; + $newtoken->scope = json_encode(array_values($scopes)); + $newtoken->token = $generatedtoken; + + $newtoken->timecreated = time(); + $newtoken->validuntil = $newtoken->timecreated + LTI_ACCESS_TOKEN_LIFE; + $newtoken->lastaccess = null; + + $DB->insert_record('lti_access_tokens', $newtoken); + + return $newtoken; + +} diff --git a/mod/lti/mod_form.php b/mod/lti/mod_form.php index 163e923c6b5..bc3ba4e3c9e 100644 --- a/mod/lti/mod_form.php +++ b/mod/lti/mod_form.php @@ -112,7 +112,7 @@ class mod_lti_mod_form extends moodleform_mod { $toolproxy[] = $type->id; $attributes = array( 'globalTool' => 1, 'toolproxy' => 1); $enabledcapabilities = explode("\n", $type->enabledcapability); - if (!in_array('Result.autocreate', $enabledcapabilities)) { + if (!in_array('Result.autocreate', $enabledcapabilities) || in_array('BasicOutcome.url', $enabledcapabilities)) { $attributes['nogrades'] = 1; } if (!in_array('Person.name.full', $enabledcapabilities) && !in_array('Person.name.family', $enabledcapabilities) && diff --git a/mod/lti/service.php b/mod/lti/service.php index a5a5df4b010..d10e57d324e 100644 --- a/mod/lti/service.php +++ b/mod/lti/service.php @@ -33,6 +33,7 @@ require_once($CFG->dirroot.'/mod/lti/servicelib.php'); // TODO: Switch to core oauthlib once implemented - MDL-30149. use mod_lti\service_exception_handler; use moodle\mod\lti as lti; +use ltiservice_basicoutcomes\local\service\basicoutcomes; $rawbody = file_get_contents("php://input"); @@ -46,24 +47,26 @@ if ($logrequests) { lti_log_request($rawbody); } -foreach (lti\OAuthUtil::get_headers() as $name => $value) { - if ($name === 'Authorization') { - // TODO: Switch to core oauthlib once implemented - MDL-30149. - $oauthparams = lti\OAuthUtil::split_header($value); +$ok = true; +$type = null; +$toolproxy = false; - $consumerkey = $oauthparams['oauth_consumer_key']; - break; +$consumerkey = lti\get_oauth_key_from_headers(null, array(basicoutcomes::SCOPE_BASIC_OUTCOMES)); +if ($consumerkey === false) { + throw new Exception('Missing or invalid consumer key or access token.'); +} else if (is_string($consumerkey)) { + $toolproxy = lti_get_tool_proxy_from_guid($consumerkey); + if ($toolproxy !== false) { + $secrets = array($toolproxy->secret); + } else if (!empty($tool)) { + $secrets = array($typeconfig['password']); + } else { + $secrets = lti_get_shared_secrets_by_key($consumerkey); + } + $sharedsecret = lti_verify_message($consumerkey, lti_get_shared_secrets_by_key($consumerkey), $rawbody); + if ($sharedsecret === false) { + throw new Exception('Message signature not valid'); } -} - -if (empty($consumerkey)) { - throw new Exception('Consumer key is missing.'); -} - -$sharedsecret = lti_verify_message($consumerkey, lti_get_shared_secrets_by_key($consumerkey), $rawbody); - -if ($sharedsecret === false) { - throw new Exception('Message signature not valid'); } // TODO MDL-46023 Replace this code with a call to the new library. diff --git a/mod/lti/service/basicoutcomes/classes/local/resources/basicoutcomes.php b/mod/lti/service/basicoutcomes/classes/local/resources/basicoutcomes.php new file mode 100644 index 00000000000..3d9c02e8f0e --- /dev/null +++ b/mod/lti/service/basicoutcomes/classes/local/resources/basicoutcomes.php @@ -0,0 +1,75 @@ +. + +/** + * This file contains a class definition for the Basic Outcomes resource + * + * @package ltiservice_basicoutcomes + * @copyright 2019 Stephen Vickers + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace ltiservice_basicoutcomes\local\resources; + +defined('MOODLE_INTERNAL') || die(); + +/** + * A resource implementing the Basic Outcomes service. + * + * @package ltiservice_basicoutcomes + * @since Moodle 3.7 + * @copyright 2019 Stephen Vickers + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class basicoutcomes extends \mod_lti\local\ltiservice\resource_base { + + /** + * Class constructor. + * + * @param \mod_lti\local\ltiservice\service_base $service Service instance + */ + public function __construct($service) { + + parent::__construct($service); + $this->id = 'Outcomes.LTI1'; + $this->template = ''; + $this->formats[] = 'application/vnd.ims.lti.v1.outcome+xml'; + $this->methods[] = 'POST'; + + } + + /** + * Get the resource fully qualified endpoint. + * + * @return string + */ + public function get_endpoint() { + + $url = new \moodle_url('/mod/lti/service.php'); + return $url->out(false); + + } + + /** + * Execute the request for this resource. + * + * @param \mod_lti\local\ltiservice\response $response Response object for this request. + */ + public function execute($response) { + // Should never be called as the endpoint sends requests to the LTI 1 service endpoint. + } + +} diff --git a/mod/lti/service/basicoutcomes/classes/local/service/basicoutcomes.php b/mod/lti/service/basicoutcomes/classes/local/service/basicoutcomes.php new file mode 100644 index 00000000000..58142b337eb --- /dev/null +++ b/mod/lti/service/basicoutcomes/classes/local/service/basicoutcomes.php @@ -0,0 +1,114 @@ +. + +/** + * This file contains a class definition for the Basic Outcomes service + * + * @package ltiservice_basicoutcomes + * @copyright 2019 Stephen Vickers + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace ltiservice_basicoutcomes\local\service; + +defined('MOODLE_INTERNAL') || die(); + +/** + * A service implementing Basic Outcomes. + * + * @package ltiservice_basicoutcomes + * @since Moodle 3.7 + * @copyright 2019 Stephen Vickers + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class basicoutcomes extends \mod_lti\local\ltiservice\service_base { + + /** Scope for accessing the service */ + const SCOPE_BASIC_OUTCOMES = 'https://purl.imsglobal.org/spec/lti-bo/scope/basicoutcome'; + + /** + * Class constructor. + */ + public function __construct() { + + parent::__construct(); + $this->id = 'basicoutcomes'; + $this->name = 'Basic Outcomes'; + + } + + /** + * Get the resources for this service. + * + * @return array + */ + public function get_resources() { + + if (empty($this->resources)) { + $this->resources = array(); + $this->resources[] = new \ltiservice_basicoutcomes\local\resources\basicoutcomes($this); + } + + return $this->resources; + + } + /** + * Get the scope(s) permitted for the tool relevant to this service. + * + * @return array + */ + public function get_permitted_scopes() { + + $scopes = array(); + if (!isset($this->get_typeconfig()['acceptgrades']) || ($this->get_typeconfig()['acceptgrades'] != LTI_SETTING_NEVER)) { + $scopes[] = self::SCOPE_BASIC_OUTCOMES; + } + + return $scopes; + + } + + /** + * Return an array of key/values to add to the launch parameters. + * + * @param string $messagetype 'basic-lti-launch-request' or 'ContentItemSelectionRequest'. + * @param string $courseid The course id. + * @param string $user The user id. + * @param string $typeid The tool lti type id. + * @param string $modlti The id of the lti activity. + * + * The type is passed to check the configuration + * and not return parameters for services not used. + * + * @return array of key/value pairs to add as launch parameters. + */ + public function get_launch_parameters($messagetype, $courseid, $user, $typeid, $modlti = null) { + global $COURSE; + + $launchparameters = array(); + $tool = lti_get_type_type_config($typeid); + if (isset($tool->{$this->get_component_id()})) { + if ($tool->{$this->get_component_id()} == parent::SERVICE_ENABLED) { + if ($messagetype === 'basic-lti-launch-request') { + $launchparameters['lis_outcomes_service_url'] = '$BasicOutcome.url'; + $launchparameters['lis_result_sourcedid'] = '$BasicOutcome.sourcedId'; + } + } + } + return $launchparameters; + } + +} diff --git a/mod/lti/service/basicoutcomes/classes/privacy/provider.php b/mod/lti/service/basicoutcomes/classes/privacy/provider.php new file mode 100644 index 00000000000..a31ea9b1028 --- /dev/null +++ b/mod/lti/service/basicoutcomes/classes/privacy/provider.php @@ -0,0 +1,110 @@ +. + +/** + * Privacy Subsystem implementation for ltiservice_basicoutcomes. + * + * @package ltiservice_basicoutcomes + * @copyright 2019 Stephen Vickers + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace ltiservice_basicoutcomes\privacy; + +use \core_privacy\local\metadata\collection; +use \core_privacy\local\request\contextlist; +use \core_privacy\local\request\approved_contextlist; +use \core_privacy\local\request\userlist; +use \core_privacy\local\request\approved_userlist; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Privacy Subsystem for ltiservice_basicoutcomes. + * + * @copyright 2019 Stephen Vickers + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class provider implements + \core_privacy\local\metadata\provider, + \core_privacy\local\request\core_userlist_provider, + \core_privacy\local\request\plugin\provider { + + /** + * Returns meta data about this system. + * + * @param collection $collection The initialised collection to add items to. + * @return collection A listing of user data stored through this system. + */ + public static function get_metadata(collection $collection) : collection { + $collection->link_external_location('External LTI provider.', [ + 'userid' => 'privacy:metadata:userid', + 'grade' => 'privacy:metadata:grade', + ], 'privacy:metadata:externalpurpose'); + + return $collection; + } + + /** + * Get the list of contexts that contain user information for the specified user. + * + * @param int $userid The user to search. + * @return contextlist The contextlist containing the list of contexts used in this plugin. + */ + public static function get_contexts_for_userid(int $userid) : contextlist { + return new contextlist(); + } + + /** + * Get the list of users who have data within a context. + * + * @param userlist $userlist The userlist containing the list of users who have data in this context/plugin combination. + */ + public static function get_users_in_context(userlist $userlist) { + } + + /** + * Export all user data for the specified user, in the specified contexts. + * + * @param approved_contextlist $contextlist The approved contexts to export information for. + */ + public static function export_user_data(approved_contextlist $contextlist) { + } + + /** + * Delete all user data which matches the specified context. + * + * @param \context $context A user context. + */ + public static function delete_data_for_all_users_in_context(\context $context) { + } + + /** + * Delete multiple users within a single context. + * + * @param approved_userlist $userlist The approved context and user information to delete information for. + */ + public static function delete_data_for_users(approved_userlist $userlist) { + } + + /** + * Delete all user data for the specified user, in the specified contexts. + * + * @param approved_contextlist $contextlist The approved contexts and user information to delete information for. + */ + public static function delete_data_for_user(approved_contextlist $contextlist) { + } +} diff --git a/mod/lti/service/basicoutcomes/lang/en/ltiservice_basicoutcomes.php b/mod/lti/service/basicoutcomes/lang/en/ltiservice_basicoutcomes.php new file mode 100644 index 00000000000..3c660ac143d --- /dev/null +++ b/mod/lti/service/basicoutcomes/lang/en/ltiservice_basicoutcomes.php @@ -0,0 +1,32 @@ +. + +/** + * Strings for component 'ltiservice_basicoutcomes', language 'en' + * + * @package ltiservice_basicoutcomes + * @copyright 2019 Stephen Vickers + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +$string['allow'] = 'Use this service to accept grades from the tool'; +$string['ltiservice_basicoutcomes'] = 'Basic Outcomes'; +$string['ltiservice_basicoutcomes_help'] = 'Allow the tool to save and retrieve its grades to a gradebook column associated with each link.'; +$string['notallow'] = 'Do not use this service'; +$string['pluginname'] = 'Basic Outcomes Service'; +$string['privacy:metadata:externalpurpose'] = 'This information is sent to an external LTI provider.'; +$string['privacy:metadata:grade'] = 'The tool\'s grades of the user using the LTI consumer.'; +$string['privacy:metadata:userid'] = 'The ID of the user using the LTI consumer.'; diff --git a/mod/lti/service/basicoutcomes/version.php b/mod/lti/service/basicoutcomes/version.php new file mode 100644 index 00000000000..ce05dcee2c2 --- /dev/null +++ b/mod/lti/service/basicoutcomes/version.php @@ -0,0 +1,31 @@ +. + +/** + * Version information for the ltiservice_basicoutcomes service. + * + * @package ltiservice_basicoutcomes + * @copyright 2019 Stephen Vickers + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +defined('MOODLE_INTERNAL') || die(); + + +$plugin->version = 2019030500; +$plugin->requires = 2018112800; +$plugin->component = 'ltiservice_basicoutcomes'; diff --git a/mod/lti/service/gradebookservices/classes/local/resources/lineitem.php b/mod/lti/service/gradebookservices/classes/local/resources/lineitem.php index 16a3620e74e..a7131733718 100644 --- a/mod/lti/service/gradebookservices/classes/local/resources/lineitem.php +++ b/mod/lti/service/gradebookservices/classes/local/resources/lineitem.php @@ -68,86 +68,51 @@ class lineitem extends resource_base { $params = $this->parse_template(); $contextid = $params['context_id']; $itemid = $params['item_id']; - if ($response->get_request_method() === 'GET') { - $contenttype = $response->get_accept(); - } else { - $contenttype = $response->get_content_type(); - } + $isget = $response->get_request_method() === self::HTTP_GET; // We will receive typeid when working with LTI 1.x, if not then we are in LTI 2. - $typeid = optional_param('type_id', null, PARAM_ALPHANUM); - if (is_null($typeid)) { - if (!$this->check_tool_proxy(null, $response->get_request_data())) { - $response->set_code(403); - $response->set_reason("Invalid tool proxy specified."); - return; + $typeid = optional_param('type_id', null, PARAM_INT); + + $scopes = array(gradebookservices::SCOPE_GRADEBOOKSERVICES_LINEITEM); + if ($response->get_request_method() === self::HTTP_GET) { + $scopes[] = gradebookservices::SCOPE_GRADEBOOKSERVICES_LINEITEM_READ; + } + + try { + if (!$this->check_tool($typeid, $response->get_request_data(), $scopes)) { + throw new \Exception(null, 401); } - } else { + $typeid = $this->get_service()->get_type()->id; + if (!($course = $DB->get_record('course', array('id' => $contextid), 'id', IGNORE_MISSING))) { + throw new \Exception("Not Found: Course {$contextid} doesn't exist", 404); + } + if (!$this->get_service()->is_allowed_in_context($typeid, $course->id)) { + throw new \Exception('Not allowed in context', 403); + } + if (!$DB->record_exists('grade_items', array('id' => $itemid))) { + throw new \Exception("Not Found: Grade item {$itemid} doesn't exist", 404); + } + $item = $this->get_service()->get_lineitem($contextid, $itemid, $typeid); + if ($item === false) { + throw new \Exception('Line item does not exist', 404); + } + require_once($CFG->libdir.'/gradelib.php'); switch ($response->get_request_method()) { case self::HTTP_GET: - if (!$this->check_type($typeid, $contextid, 'LineItem.item:get', $response->get_request_data())) { - $response->set_code(403); - $response->set_reason("This resource does not support GET requests."); - return; - } + $this->get_request($response, $item, $typeid); break; case self::HTTP_PUT: - if (!$this->check_type($typeid, $contextid, 'LineItem.item:put', $response->get_request_data())) { - $response->set_code(403); - $response->set_reason("This resource does not support PUT requests."); - return; - } + $json = $this->process_put_request($response->get_request_data(), $item, $typeid); + $response->set_body($json); + $response->set_code(200); break; case self::HTTP_DELETE: - if (!$this->check_type($typeid, $contextid, 'LineItem.item:delete', $response->get_request_data())) { - $response->set_code(403); - $response->set_reason("This resource does not support DELETE requests."); - return; - } + $this->process_delete_request($item); + $response->set_code(204); break; - default: // Should not be possible. - $response->set_code(405); - return; } - } - if (empty($contextid) || (!empty($contenttype) && !in_array($contenttype, $this->formats))) { - $response->set_code(400); - $response->set_reason("Invalid request made."); - return; - } - if (!$DB->record_exists('course', array('id' => $contextid))) { - $response->set_code(404); - $response->set_reason("Not Found: Course $contextid doesn't exist."); - return; - } - if (!$DB->record_exists('grade_items', array('id' => $itemid))) { - $response->set_code(404); - $response->set_reason("Not Found: Grade item $itemid doesn't exist."); - return; - } - $item = $this->get_service()->get_lineitem($contextid, $itemid, $typeid); - if ($item === false) { - $response->set_code(403); - $response->set_reason("Line item does not exist."); - return; - } - require_once($CFG->libdir.'/gradelib.php'); - switch ($response->get_request_method()) { - case 'GET': - $this->get_request($response, $item, $typeid); - break; - case 'PUT': - $json = $this->process_put_request($response->get_request_data(), $item, $typeid); - $response->set_body($json); - $response->set_code(200); - break; - case 'DELETE': - $this->process_delete_request($item); - $response->set_code(204); - break; - default: // Should not be possible. - $response->set_code(405); - $response->set_reason("Invalid request method specified."); - return; + } catch (\Exception $e) { + $response->set_code($e->getCode()); + $response->set_reason($e->getMessage()); } } @@ -218,7 +183,24 @@ class lineitem extends resource_base { $gbs->tag = $tag; } $ltilinkid = null; - if (isset($json->ltiLinkId)) { + if (isset($json->resourceLinkId)) { + if (is_numeric($json->resourceLinkId)) { + $ltilinkid = $json->resourceLinkId; + if ($gbs) { + if (intval($gbs->ltilinkid) !== intval($json->resourceLinkId)) { + $gbs->ltilinkid = $json->resourceLinkId; + $upgradegradebookservices = true; + } + } else { + if (intval($item->iteminstance) !== intval($json->resourceLinkId)) { + $item->iteminstance = intval($json->resourceLinkId); + $updategradeitem = true; + } + } + } else { + throw new \Exception(null, 400); + } + } else if (isset($json->ltiLinkId)) { if (is_numeric($json->ltiLinkId)) { $ltilinkid = $json->ltiLinkId; if ($gbs) { @@ -316,24 +298,6 @@ class lineitem extends resource_base { } } - /** - * Get permissions from the config of the tool for that resource - * - * @param int $typeid - * - * @return array with the permissions related to this resource by the $lti_type or null if none. - */ - public function get_permissions($typeid) { - $tool = lti_get_type_type_config($typeid); - if ($tool->ltiservice_gradesynchronization == '1') { - return array('LineItem.item:get'); - } else if ($tool->ltiservice_gradesynchronization == '2') { - return array('LineItem.item:get', 'LineItem.item:put', 'LineItem.item:delete'); - } else { - return array(); - } - } - /** * Parse a value for custom parameter substitution variables. * @@ -348,7 +312,13 @@ class lineitem extends resource_base { require_once($CFG->libdir . '/gradelib.php'); $this->params['context_id'] = $COURSE->id; + if ($tool = $this->get_service()->get_type()) { + $this->params['tool_code'] = $tool->id; + } $id = optional_param('id', 0, PARAM_INT); // Course Module ID. + if (empty($id)) { + $id = optional_param('lti_message_hint', 0, PARAM_INT); + } if (!empty($id)) { $cm = get_coursemodule_from_id('lti', $id, 0, false, MUST_EXIST); $id = $cm->instance; @@ -356,6 +326,7 @@ class lineitem extends resource_base { if ($item && $item->items) { $this->params['item_id'] = $item->items[0]->id; $resolved = parent::get_endpoint(); + $resolved .= "?type_id={$tool->id}"; } } $value = str_replace('$LineItem.url', $resolved, $value); diff --git a/mod/lti/service/gradebookservices/classes/local/resources/lineitems.php b/mod/lti/service/gradebookservices/classes/local/resources/lineitems.php index cd70180adef..2efc688bcc0 100644 --- a/mod/lti/service/gradebookservices/classes/local/resources/lineitems.php +++ b/mod/lti/service/gradebookservices/classes/local/resources/lineitems.php @@ -74,51 +74,35 @@ class lineitems extends resource_base { $contenttype = $response->get_content_type(); } $container = empty($contenttype) || ($contenttype === $this->formats[0]); - // We will receive typeid when working with LTI 1.x, if not the we are in LTI 2. - $typeid = optional_param('type_id', null, PARAM_ALPHANUM); - if (is_null($typeid)) { - if (!$this->check_tool_proxy(null, $response->get_request_data())) { - $response->set_code(403); - $response->set_reason("Invalid tool proxy specified."); - return; + // We will receive typeid when working with LTI 1.x, if not then we are in LTI 2. + $typeid = optional_param('type_id', null, PARAM_INT); + + $scopes = array(gradebookservices::SCOPE_GRADEBOOKSERVICES_LINEITEM); + if ($response->get_request_method() === self::HTTP_GET) { + $scopes[] = gradebookservices::SCOPE_GRADEBOOKSERVICES_LINEITEM_READ; + } + + try { + if (!$this->check_tool($typeid, $response->get_request_data(), $scopes)) { + throw new \Exception(null, 401); } - } else { - switch ($response->get_request_method()) { - case self::HTTP_GET: - if (!$this->check_type($typeid, $contextid, 'LineItem.collection:get', $response->get_request_data())) { - $response->set_code(403); - $response->set_reason("This resource does not support GET requests."); - return; - } - break; - case self::HTTP_POST: - if (!$this->check_type($typeid, $contextid, 'LineItem.collection:post', $response->get_request_data())) { - $response->set_code(403); - $response->set_reason("This resource does not support POST requests."); - return; - } - break; - default: // Should not be possible. - $response->set_code(405); - $response->set_reason("Invalid request method specified."); - return; + $typeid = $this->get_service()->get_type()->id; + if (empty($contextid) || !($container ^ ($response->get_request_method() === self::HTTP_POST)) || + (!empty($contenttype) && !in_array($contenttype, $this->formats))) { + throw new \Exception('No context or unsupported content type', 400); } - } - if (empty($contextid) || !($container ^ ($response->get_request_method() === self::HTTP_POST)) || - (!empty($contenttype) && !in_array($contenttype, $this->formats))) { - $response->set_code(400); - $response->set_reason("Invalid request made."); - return; - } - if (!$DB->record_exists('course', array('id' => $contextid))) { - $response->set_code(404); - $response->set_reason("Not Found: Course $contextid doesn't exist."); - return; - } - switch ($response->get_request_method()) { - case self::HTTP_GET: + if (!($course = $DB->get_record('course', array('id' => $contextid), 'id', IGNORE_MISSING))) { + throw new \Exception("Not Found: Course {$contextid} doesn't exist", 404); + } + if (!$this->get_service()->is_allowed_in_context($typeid, $course->id)) { + throw new \Exception('Not allowed in context', 403); + } + if ($response->get_request_method() !== self::HTTP_POST) { $resourceid = optional_param('resource_id', null, PARAM_TEXT); - $ltilinkid = optional_param('lti_link_id', null, PARAM_TEXT); + $ltilinkid = optional_param('resource_link_id', null, PARAM_TEXT); + if (is_null($ltilinkid)) { + $ltilinkid = optional_param('lti_link_id', null, PARAM_TEXT); + } $tag = optional_param('tag', null, PARAM_TEXT); $limitnum = optional_param('limit', 0, PARAM_INT); $limitfrom = optional_param('from', 0, PARAM_INT); @@ -129,23 +113,18 @@ class lineitems extends resource_base { $json = $this->get_json_for_get_request($items, $resourceid, $ltilinkid, $tag, $limitfrom, $limitnum, $totalcount, $typeid, $response); $response->set_content_type($this->formats[0]); - break; - case self::HTTP_POST: - try { - $json = $this->get_json_for_post_request($response->get_request_data(), $contextid, $typeid); - $response->set_code(201); - $response->set_content_type($this->formats[1]); - } catch (\Exception $e) { - $response->set_code($e->getCode()); - $response->set_reason($e->getMessage()); - } - break; - default: // Should not be possible. - $response->set_code(405); - $response->set_reason("Invalid request method specified."); - return; + } else { + $json = $this->get_json_for_post_request($response->get_request_data(), $contextid, $typeid); + $response->set_code(201); + $response->set_content_type($this->formats[1]); + } + $response->set_body($json); + + } catch (\Exception $e) { + $response->set_code($e->getCode()); + $response->set_reason($e->getMessage()); } - $response->set_body($json); + } /** @@ -186,7 +165,7 @@ class lineitems extends resource_base { $baseurl->param('resource_id', $resourceid); } if (isset($ltilinkid)) { - $baseurl->param('lti_link_id', $ltilinkid); + $baseurl->param('resource_link_id', $ltilinkid); } if (isset($tag)) { $baseurl->param('tag', $tag); @@ -217,7 +196,7 @@ class lineitems extends resource_base { } } - $jsonitems=[]; + $jsonitems = []; $endpoint = parent::get_endpoint(); foreach ($items as $item) { array_push($jsonitems, gradebookservices::item_for_json($item, $endpoint, $typeid)); @@ -255,7 +234,7 @@ class lineitems extends resource_base { if (empty($json) || !isset($json->scoreMaximum) || !isset($json->label)) { - throw new \Exception(null, 400); + throw new \Exception('No label or Score Maximum', 400); } if (is_numeric($json->scoreMaximum)) { $max = $json->scoreMaximum; @@ -264,7 +243,10 @@ class lineitems extends resource_base { } require_once($CFG->libdir.'/gradelib.php'); $resourceid = (isset($json->resourceId)) ? $json->resourceId : ''; - $ltilinkid = (isset($json->ltiLinkId)) ? $json->ltiLinkId : null; + $ltilinkid = (isset($json->resourceLinkId)) ? $json->resourceLinkId : null; + if ($ltilinkid == null) { + $ltilinkid = (isset($json->ltiLinkId)) ? $json->ltiLinkId : null; + } if ($ltilinkid != null) { if (is_null($typeid)) { if (!gradebookservices::check_lti_id($ltilinkid, $contextid, $this->get_service()->get_tool_proxy()->id)) { @@ -313,24 +295,6 @@ class lineitems extends resource_base { } - /** - * get permissions from the config of the tool for that resource - * - * @param string $typeid - * - * @return array with the permissions related to this resource by the lti type or null if none. - */ - public function get_permissions($typeid) { - $tool = lti_get_type_type_config($typeid); - if ($tool->ltiservice_gradesynchronization == '1') { - return array('LineItem.collection:get'); - } else if ($tool->ltiservice_gradesynchronization == '2') { - return array('LineItem.collection:get', 'LineItem.collection:post'); - } else { - return array(); - } - } - /** * Parse a value for custom parameter substitution variables. * @@ -343,7 +307,11 @@ class lineitems extends resource_base { if (strpos($value, '$LineItems.url') !== false) { $this->params['context_id'] = $COURSE->id; - $value = str_replace('$LineItems.url', parent::get_endpoint(), $value); + $query = ''; + if (($tool = $this->get_service()->get_type())) { + $query = "?type_id={$tool->id}"; + } + $value = str_replace('$LineItems.url', parent::get_endpoint() . $query, $value); } return $value; diff --git a/mod/lti/service/gradebookservices/classes/local/resources/results.php b/mod/lti/service/gradebookservices/classes/local/resources/results.php index 445c1dc91f4..61a6560b905 100644 --- a/mod/lti/service/gradebookservices/classes/local/resources/results.php +++ b/mod/lti/service/gradebookservices/classes/local/resources/results.php @@ -31,7 +31,7 @@ use mod_lti\local\ltiservice\resource_base; defined('MOODLE_INTERNAL') || die(); /** - * A resource implementing LISResults container. + * A resource implementing LISResult container. * * @package ltiservice_gradebookservices * @copyright 2017 Cengage Learning http://www.cengage.com @@ -66,90 +66,76 @@ class results extends resource_base { $contextid = $params['context_id']; $itemid = $params['item_id']; - $isget = $response->get_request_method() === 'GET'; - if ($isget) { - $contenttype = $response->get_accept(); - } else { - $contenttype = $response->get_content_type(); - } + $isget = $response->get_request_method() === self::HTTP_GET; // We will receive typeid when working with LTI 1.x, if not the we are in LTI 2. - $typeid = optional_param('type_id', null, PARAM_ALPHANUM); - if (is_null($typeid)) { - if (!$this->check_tool_proxy(null, $response->get_request_data())) { - $response->set_code(403); - $response->set_reason("Invalid tool proxy specified."); - return; + $typeid = optional_param('type_id', null, PARAM_INT); + + $scope = gradebookservices::SCOPE_GRADEBOOKSERVICES_RESULT_READ; + + try { + if (!$this->check_tool($typeid, $response->get_request_data(), array($scope))) { + throw new \Exception(null, 401); } - } else { - if (!$this->check_type($typeid, $contextid, 'Result.collection:get', $response->get_request_data())) { - $response->set_code(403); - $response->set_reason("This resource does not support GET requests."); - return; + $typeid = $this->get_service()->get_type()->id; + if (!($course = $DB->get_record('course', array('id' => $contextid), 'id', IGNORE_MISSING))) { + throw new \Exception("Not Found: Course {$contextid} doesn't exist", 404); } - } - if (empty($contextid) || (!empty($contenttype) && !in_array($contenttype, $this->formats))) { - $response->set_code(400); - $response->set_reason("Invalid request made."); - return; - } - if (!$DB->record_exists('course', array('id' => $contextid))) { - $response->set_code(404); - $response->set_reason("Not Found: Course $contextid doesn't exist."); - return; - } - if (!$DB->record_exists('grade_items', array('id' => $itemid))) { - $response->set_code(404); - $response->set_reason("Not Found: Grade item $itemid doesn't exist."); - return; - } - $item = $this->get_service()->get_lineitem($contextid, $itemid, $typeid); - if ($item === false) { - $response->set_code(403); - $response->set_reason("Line item does not exist."); - return; - } - $gbs = gradebookservices::find_ltiservice_gradebookservice_for_lineitem($itemid); - $ltilinkid = null; - if (isset($item->iteminstance)) { - $ltilinkid = $item->iteminstance; - } else if ($gbs && isset($gbs->ltilinkid)) { - $ltilinkid = $gbs->ltilinkid; - } - if ($ltilinkid != null) { - if (is_null($typeid)) { - if (isset($item->iteminstance) && (!gradebookservices::check_lti_id($ltilinkid, $item->courseid, - $this->get_service()->get_tool_proxy()->id))) { - $response->set_code(403); - $response->set_reason("Invalid LTI id supplied."); - return; - } - } else { - if (isset($item->iteminstance) && (!gradebookservices::check_lti_1x_id($ltilinkid, $item->courseid, - $typeid))) { - $response->set_code(403); - $response->set_reason("Invalid LTI id supplied."); - return; + if (!$this->get_service()->is_allowed_in_context($typeid, $course->id)) { + throw new \Exception('Not allowed in context', 403); + } + if (!$DB->record_exists('grade_items', array('id' => $itemid))) { + throw new \Exception("Not Found: Grade item {$itemid} doesn't exist", 404); + } + $item = $this->get_service()->get_lineitem($contextid, $itemid, $typeid); + if ($item === false) { + throw new \Exception('Line item does not exist', 404); + } + $gbs = gradebookservices::find_ltiservice_gradebookservice_for_lineitem($itemid); + $ltilinkid = null; + if (isset($item->iteminstance)) { + $ltilinkid = $item->iteminstance; + } else if ($gbs && isset($gbs->ltilinkid)) { + $ltilinkid = $gbs->ltilinkid; + } + if ($ltilinkid != null) { + if (is_null($typeid)) { + if (isset($item->iteminstance) && (!gradebookservices::check_lti_id($ltilinkid, $item->courseid, + $this->get_service()->get_tool_proxy()->id))) { + $response->set_code(403); + $response->set_reason("Invalid LTI id supplied."); + return; + } + } else { + if (isset($item->iteminstance) && (!gradebookservices::check_lti_1x_id($ltilinkid, $item->courseid, + $typeid))) { + $response->set_code(403); + $response->set_reason("Invalid LTI id supplied."); + return; + } } } + require_once($CFG->libdir.'/gradelib.php'); + switch ($response->get_request_method()) { + case 'GET': + $useridfilter = optional_param('user_id', 0, PARAM_INT); + $limitnum = optional_param('limit', 0, PARAM_INT); + $limitfrom = optional_param('from', 0, PARAM_INT); + $typeid = optional_param('type_id', null, PARAM_TEXT); + $json = $this->get_json_for_get_request($item->id, $limitfrom, $limitnum, + $useridfilter, $typeid, $response); + $response->set_content_type($this->formats[0]); + $response->set_body($json); + break; + default: // Should not be possible. + $response->set_code(405); + $response->set_reason("Invalid request method specified."); + return; + } + $response->set_body($json); + } catch (\Exception $e) { + $response->set_code($e->getCode()); + $response->set_reason($e->getMessage()); } - require_once($CFG->libdir.'/gradelib.php'); - switch ($response->get_request_method()) { - case 'GET': - $useridfilter = optional_param('user_id', 0, PARAM_INT); - $limitnum = optional_param('limit', 0, PARAM_INT); - $limitfrom = optional_param('from', 0, PARAM_INT); - $typeid = optional_param('type_id', null, PARAM_TEXT); - $json = $this->get_json_for_get_request($item->id, $limitfrom, $limitnum, - $useridfilter, $typeid, $response); - $response->set_content_type($this->formats[0]); - $response->set_body($json); - break; - default: // Should not be possible. - $response->set_code(405); - $response->set_reason("Invalid request method specified."); - return; - } - $response->set_body($json); } /** @@ -255,24 +241,6 @@ class results extends resource_base { return json_encode($jsonresults); } - /** - * get permissions from the config of the tool for that resource - * - * @param int $typeid - * - * @return array with the permissions related to this resource by the $lti_type or null if none. - */ - public function get_permissions($typeid) { - $tool = lti_get_type_type_config($typeid); - if ($tool->ltiservice_gradesynchronization == '1') { - return array('Result.collection:get'); - } else if ($tool->ltiservice_gradesynchronization == '2') { - return array('Result.collection:get'); - } else { - return array(); - } - } - /** * Parse a value for custom parameter substitution variables. * diff --git a/mod/lti/service/gradebookservices/classes/local/resources/scores.php b/mod/lti/service/gradebookservices/classes/local/resources/scores.php index afe63eae989..4bc4e2afd76 100644 --- a/mod/lti/service/gradebookservices/classes/local/resources/scores.php +++ b/mod/lti/service/gradebookservices/classes/local/resources/scores.php @@ -80,96 +80,82 @@ class scores extends resource_base { $container = empty($contenttype) || ($contenttype === $this->formats[0]); // We will receive typeid when working with LTI 1.x, if not the we are in LTI 2. $typeid = optional_param('type_id', null, PARAM_ALPHANUM); - if (is_null($typeid)) { - if (!$this->check_tool_proxy(null, $response->get_request_data())) { - $response->set_code(403); - return; + + $scope = gradebookservices::SCOPE_GRADEBOOKSERVICES_SCORE; + + try { + if (!$this->check_tool($typeid, $response->get_request_data(), array($scope))) { + throw new \Exception(null, 401); } - } else { + $typeid = $this->get_service()->get_type()->id; + if (empty($contextid) || !($container ^ ($response->get_request_method() === self::HTTP_POST)) || + (!empty($contenttype) && !in_array($contenttype, $this->formats))) { + throw new \Exception('No context or unsupported content type', 400); + } + if (!($course = $DB->get_record('course', array('id' => $contextid), 'id', IGNORE_MISSING))) { + throw new \Exception("Not Found: Course {$contextid} doesn't exist", 404); + } + if (!$this->get_service()->is_allowed_in_context($typeid, $course->id)) { + throw new \Exception('Not allowed in context', 403); + } + if (!$DB->record_exists('grade_items', array('id' => $itemid))) { + throw new \Exception("Not Found: Grade item {$itemid} doesn't exist", 404); + } + $item = $this->get_service()->get_lineitem($contextid, $itemid, $typeid); + if ($item === false) { + throw new \Exception('Line item does not exist', 404); + } + $gbs = gradebookservices::find_ltiservice_gradebookservice_for_lineitem($itemid); + $ltilinkid = null; + if (isset($item->iteminstance)) { + $ltilinkid = $item->iteminstance; + } else if ($gbs && isset($gbs->ltilinkid)) { + $ltilinkid = $gbs->ltilinkid; + } + if ($ltilinkid != null) { + if (is_null($typeid)) { + if (isset($item->iteminstance) && (!gradebookservices::check_lti_id($ltilinkid, $item->courseid, + $this->get_service()->get_tool_proxy()->id))) { + $response->set_code(403); + $response->set_reason("Invalid LTI id supplied."); + return; + } + } else { + if (isset($item->iteminstance) && (!gradebookservices::check_lti_1x_id($ltilinkid, $item->courseid, + $typeid))) { + $response->set_code(403); + $response->set_reason("Invalid LTI id supplied."); + return; + } + } + } + $json = '[]'; + require_once($CFG->libdir.'/gradelib.php'); switch ($response->get_request_method()) { case 'GET': $response->set_code(405); $response->set_reason("GET requests are not allowed."); - return; + break; case 'POST': - if (!$this->check_type($typeid, $contextid, 'Score.collection:post', $response->get_request_data())) { - $response->set_code(401); - $response->set_reason("This resource does not support POST requests."); - return; + try { + $json = $this->get_json_for_post_request($response, $response->get_request_data(), $item, $contextid, + $typeid); + $response->set_content_type($this->formats[1]); + } catch (\Exception $e) { + $response->set_code($e->getCode()); + $response->set_reason($e->getMessage()); } break; default: // Should not be possible. $response->set_code(405); + $response->set_reason("Invalid request method specified."); return; } + $response->set_body($json); + } catch (\Exception $e) { + $response->set_code($e->getCode()); + $response->set_reason($e->getMessage()); } - if (empty($contextid) || !($container ^ ($response->get_request_method() === 'POST')) || - (!empty($contenttype) && !in_array($contenttype, $this->formats))) { - $response->set_code(400); - return; - } - if (!$DB->record_exists('course', array('id' => $contextid))) { - $response->set_code(404); - $response->set_reason("Not Found: Course $contextid doesn't exist."); - return; - } - if (!$DB->record_exists('grade_items', array('id' => $itemid))) { - $response->set_code(404); - $response->set_reason("Not Found: Grade item $itemid doesn't exist."); - return; - } - $item = $this->get_service()->get_lineitem($contextid, $itemid, $typeid); - if ($item === false) { - $response->set_code(403); - $response->set_reason("Line item does not exist."); - return; - } - $gbs = gradebookservices::find_ltiservice_gradebookservice_for_lineitem($itemid); - $ltilinkid = null; - if (isset($item->iteminstance)) { - $ltilinkid = $item->iteminstance; - } else if ($gbs && isset($gbs->ltilinkid)) { - $ltilinkid = $gbs->ltilinkid; - } - if ($ltilinkid != null) { - if (is_null($typeid)) { - if (isset($item->iteminstance) && (!gradebookservices::check_lti_id($ltilinkid, $item->courseid, - $this->get_service()->get_tool_proxy()->id))) { - $response->set_code(403); - $response->set_reason("Invalid LTI id supplied."); - return; - } - } else { - if (isset($item->iteminstance) && (!gradebookservices::check_lti_1x_id($ltilinkid, $item->courseid, - $typeid))) { - $response->set_code(403); - $response->set_reason("Invalid LTI id supplied."); - return; - } - } - } - $json = '[]'; - require_once($CFG->libdir.'/gradelib.php'); - switch ($response->get_request_method()) { - case 'GET': - $response->set_code(405); - $response->set_reason("GET requests are not allowed."); - break; - case 'POST': - try { - $json = $this->get_json_for_post_request($response, $response->get_request_data(), $item, $contextid, $typeid); - $response->set_content_type($this->formats[1]); - } catch (\Exception $e) { - $response->set_code($e->getCode()); - $response->set_reason($e->getMessage()); - } - break; - default: // Should not be possible. - $response->set_code(405); - $response->set_reason("Invalid request method specified."); - return; - } - $response->set_body($json); } /** @@ -193,10 +179,11 @@ class scores extends resource_base { !isset($score->timestamp) || isset($score->timestamp) && !gradebookservices::validate_iso8601_date($score->timestamp) || (isset($score->scoreGiven) && !is_numeric($score->scoreGiven)) || + (isset($score->scoreGiven) && !isset($score->scoreMaximum)) || (isset($score->scoreMaximum) && !is_numeric($score->scoreMaximum)) || (!gradebookservices::is_user_gradable_in_course($contextid, $score->userId)) ) { - throw new \Exception('Incorrect score received' . $score, 400); + throw new \Exception('Incorrect score received' . $body, 400); } $score->timemodified = intval($score->timestamp); @@ -216,25 +203,7 @@ class scores extends resource_base { $score->scoreGiven = null; } } - gradebookservices::save_score($item, $score, $score->userId, $typeid); - } - - /** - * get permissions from the config of the tool for that resource - * - * @param int $typeid - * - * @return array with the permissions related to this resource by the $lti_type or null if none. - */ - public function get_permissions($typeid) { - $tool = lti_get_type_type_config($typeid); - if ($tool->ltiservice_gradesynchronization == '1') { - return array('Score.collection:post'); - } else if ($tool->ltiservice_gradesynchronization == '2') { - return array('Score.collection:post'); - } else { - return array(); - } + $this->get_service()->save_grade_item($item, $score, $score->userId); } /** diff --git a/mod/lti/service/gradebookservices/classes/local/service/gradebookservices.php b/mod/lti/service/gradebookservices/classes/local/service/gradebookservices.php index e6914c3352e..c170ed21cf2 100644 --- a/mod/lti/service/gradebookservices/classes/local/service/gradebookservices.php +++ b/mod/lti/service/gradebookservices/classes/local/service/gradebookservices.php @@ -43,8 +43,19 @@ defined('MOODLE_INTERNAL') || die(); */ class gradebookservices extends service_base { - /** Internal service name */ - const SERVICE_NAME = 'ltiservice_gradebookservices'; + /** Read-only access to Gradebook services */ + const GRADEBOOKSERVICES_READ = 1; + /** Full access to Gradebook services */ + const GRADEBOOKSERVICES_FULL = 2; + /** Scope for full access to Lineitem service */ + const SCOPE_GRADEBOOKSERVICES_LINEITEM = 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem'; + /** Scope for full access to Lineitem service */ + const SCOPE_GRADEBOOKSERVICES_LINEITEM_READ = 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly'; + /** Scope for access to Result service */ + const SCOPE_GRADEBOOKSERVICES_RESULT_READ = 'https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly'; + /** Scope for access to Score service */ + const SCOPE_GRADEBOOKSERVICES_SCORE = 'https://purl.imsglobal.org/spec/lti-ags/scope/score'; + /** * Class constructor. @@ -53,7 +64,7 @@ class gradebookservices extends service_base { parent::__construct(); $this->id = 'gradebookservices'; - $this->name = $this->get_string('servicename'); + $this->name = get_string($this->get_component_id(), $this->get_component_id()); } @@ -77,13 +88,40 @@ class gradebookservices extends service_base { return $this->resources; } + /** + * Get the scope(s) permitted for this service. + * + * @return array + */ + public function get_permitted_scopes() { + + $scopes = array(); + $ok = !empty($this->get_type()); + if ($ok && isset($this->get_typeconfig()[$this->get_component_id()])) { + if (!empty($setting = $this->get_typeconfig()[$this->get_component_id()])) { + $scopes[] = self::SCOPE_GRADEBOOKSERVICES_LINEITEM_READ; + $scopes[] = self::SCOPE_GRADEBOOKSERVICES_RESULT_READ; + $scopes[] = self::SCOPE_GRADEBOOKSERVICES_SCORE; + if ($setting == self::GRADEBOOKSERVICES_FULL) { + $scopes[] = self::SCOPE_GRADEBOOKSERVICES_LINEITEM; + } + } + } + + return $scopes; + + } + /** * Adds form elements for gradebook sync add/edit page. * * @param \MoodleQuickForm $mform Moodle quickform object definition + * @deprecated since Moodle 3.7 MDL-62599 - please do not use this function any more. + * @see gradebookservices::get_configuration_elements() */ public function get_configuration_options(&$mform) { - + debugging('get_configuration_options() has been deprecated, ' . + 'please use service_base::get_configuration_elements() instead.', DEBUG_DEVELOPER); $selectelementname = 'ltiservice_gradesynchronization'; $identifier = 'grade_synchronization'; $options = [ @@ -91,7 +129,6 @@ class gradebookservices extends service_base { $this->get_string('partialgs'), $this->get_string('alwaysgs') ]; - $mform->addElement('select', $selectelementname, $this->get_string($identifier), $options); $mform->setType($selectelementname, 'int'); $mform->setDefault($selectelementname, 0); @@ -99,23 +136,21 @@ class gradebookservices extends service_base { } /** - * Retrieves string from lang file + * Create form element for gradebook sync add/edit page. * - * @param string $identifier - * @return string + * @return array of \HTML_QuickForm_element Form elements */ - private function get_string($identifier) { - return get_string($identifier, self::SERVICE_NAME); - } + public function get_configuration_elements() { + $elements = array(); + $options = [ + get_string('nevergs', $this->get_component_id()), + get_string('partialgs', $this->get_component_id()), + get_string('alwaysgs', $this->get_component_id()) + ]; + $elements[''] = new \MoodleQuickForm_select($this->get_component_id(), get_string($this->get_component_id(), + $this->get_component_id()), $options); - /** - * Return an array with the names of the parameters that the service will be saving in the configuration - * - * @return array with the names of the parameters that the service will be saving in the configuration - * - */ - public function get_configuration_parameter_names() { - return array('ltiservice_gradesynchronization'); + return $elements; } /** @@ -134,15 +169,15 @@ class gradebookservices extends service_base { */ public function get_launch_parameters($messagetype, $courseid, $user, $typeid, $modlti = null) { global $DB; - $launchparameters = array(); - $tool = lti_get_type_type_config($typeid); + $this->set_type(lti_get_type($typeid)); + $this->set_typeconfig(lti_get_type_config($typeid)); // Only inject parameters if the service is enabled for this tool. - if (isset($tool->ltiservice_gradesynchronization)) { - if ($tool->ltiservice_gradesynchronization == '1' || $tool->ltiservice_gradesynchronization == '2') { + if (isset($this->get_typeconfig()[$this->get_component_id()])) { + if ($this->get_typeconfig()[$this->get_component_id()] == self::GRADEBOOKSERVICES_READ || + $this->get_typeconfig()[$this->get_component_id()] == self::GRADEBOOKSERVICES_FULL) { // Check for used in context is only needed because there is no explicit site tool - course relation. if ($this->is_allowed_in_context($typeid, $courseid)) { - $endpoint = $this->get_service_path() . "/{$courseid}/lineitems"; if (is_null($modlti)) { $id = null; } else { @@ -164,9 +199,10 @@ class gradebookservices extends service_base { $id = null; } } - $launchparameters['custom_lineitems_url'] = $endpoint . "?type_id={$typeid}"; + $launchparameters['gradebookservices_scope'] = implode(',', $this->get_permitted_scopes()); + $launchparameters['lineitems_url'] = '$LineItems.url'; if (!is_null($id)) { - $launchparameters['custom_lineitem_url'] = $endpoint . "/{$id}/lineitem?type_id={$typeid}"; + $launchparameters['lineitem_url'] = '$LineItem.url'; } } } @@ -316,10 +352,26 @@ class gradebookservices extends service_base { * @param int $userid User ID * * @throws \Exception + * @deprecated since Moodle 3.7 MDL-62599 - please do not use this function any more. + * @see gradebookservices::save_grade_item($gradeitem, $score, $userid) */ public static function save_score($gradeitem, $score, $userid) { + $service = new gradebookservices(); + $service->save_grade_item($gradeitem, $score, $userid); + } + + /** + * Set a grade item. + * + * @param object $gradeitem Grade Item record + * @param object $score Result object + * @param int $userid User ID + * + * @throws \Exception + */ + public function save_grade_item($gradeitem, $score, $userid) { global $DB, $CFG; - $source = 'mod' . self::SERVICE_NAME; + $source = 'mod' . $this->get_component_id(); if ($DB->get_record('user', array('id' => $userid)) === false) { throw new \Exception(null, 400); } @@ -406,11 +458,13 @@ class gradebookservices extends service_base { if ($gbs) { $lineitem->tag = (!empty($gbs->tag)) ? $gbs->tag : ''; if (isset($gbs->ltilinkid)) { + $lineitem->resourceLinkId = strval($gbs->ltilinkid); $lineitem->ltiLinkId = strval($gbs->ltilinkid); } } else { $lineitem->tag = ''; if (isset($item->iteminstance)) { + $lineitem->resourceLinkId = strval($item->iteminstance); $lineitem->ltiLinkId = strval($item->iteminstance); } } diff --git a/mod/lti/service/gradebookservices/lang/en/ltiservice_gradebookservices.php b/mod/lti/service/gradebookservices/lang/en/ltiservice_gradebookservices.php index 7d9a5d149d0..3bdc7b59c51 100644 --- a/mod/lti/service/gradebookservices/lang/en/ltiservice_gradebookservices.php +++ b/mod/lti/service/gradebookservices/lang/en/ltiservice_gradebookservices.php @@ -24,8 +24,8 @@ */ $string['alwaysgs'] = 'Use this service for grade sync and column management '; -$string['grade_synchronization'] = 'IMS LTI Assignment and Grade Services'; -$string['grade_synchronization_help'] = 'Whether to use the IMS LTI Assignment and Grade Services to synchronise grades instead of the Basic Outcomes service. +$string['ltiservice_gradebookservices'] = 'IMS LTI Assignment and Grade Services'; +$string['ltiservice_gradebookservices_help'] = 'Whether to use the IMS LTI Assignment and Grade Services to synchronise grades instead of the Basic Outcomes service. * **Do not use this service** - Basic Outcomes features and configuration will be used * **Use this service for grade sync only** - The service will populate the grades in an already existing gradebook column, but it will not be able to create new columns @@ -40,5 +40,4 @@ $string['privacy:metadata:grade'] = 'The grade the user received in Moodle for t $string['privacy:metadata:maxgrade'] = 'The max grade that can be achieved for this LTI activity.'; $string['privacy:metadata:timemodified'] = 'The last time the grade was updated'; $string['privacy:metadata:userid'] = 'The ID of the user using the LTI consumer.'; -$string['servicename'] = 'LTI Assignment and Grade Services'; $string['taskcleanup'] = 'LTI Assignment and Grade Services table cleanup'; diff --git a/mod/lti/service/memberships/classes/local/resources/contextmemberships.php b/mod/lti/service/memberships/classes/local/resources/contextmemberships.php index 85369349094..0ced8684179 100644 --- a/mod/lti/service/memberships/classes/local/resources/contextmemberships.php +++ b/mod/lti/service/memberships/classes/local/resources/contextmemberships.php @@ -54,7 +54,8 @@ class contextmemberships extends resource_base { $this->template = '/{context_type}/{context_id}/bindings/{tool_code}/memberships'; $this->variables[] = 'ToolProxyBinding.memberships.url'; $this->formats[] = 'application/vnd.ims.lis.v2.membershipcontainer+json'; - $this->methods[] = 'GET'; + $this->formats[] = 'application/vnd.ims.lti-nrps.v2.membershipcontainer+json'; + $this->methods[] = self::HTTP_GET; } @@ -79,19 +80,23 @@ class contextmemberships extends resource_base { } try { - if (!($course = $DB->get_record('course', array('id' => $params['context_id']), 'id', IGNORE_MISSING))) { + if (!$this->check_tool($params['tool_code'], $response->get_request_data(), + array(memberships::SCOPE_MEMBERSHIPS_READ))) { + throw new \Exception(null, 401); + } + if (!($course = $DB->get_record('course', array('id' => $params['context_id']), 'id,shortname,fullname', + IGNORE_MISSING))) { + throw new \Exception("Not Found: Course {$params['context_id']} doesn't exist", 404); + } + if (!$this->get_service()->is_allowed_in_context($params['tool_code'], $course->id)) { throw new \Exception(null, 404); } if (!($context = \context_course::instance($course->id))) { - throw new \Exception(null, 404); - } - if (!($tool = $DB->get_record('lti_types', array('id' => $params['tool_code']), - 'id,toolproxyid,enabledcapability,parameter', IGNORE_MISSING))) { - throw new \Exception(null, 404); + throw new \Exception("Not Found: Course instance {$course->id} doesn't exist", 404); } if (!empty($linkid)) { if (!($lti = $DB->get_record('lti', array('id' => $linkid), 'id,course,typeid,servicesalt', IGNORE_MISSING))) { - throw new \Exception(null, 404); + throw new \Exception("Not Found: LTI link {$linkid} doesn't exist", 404); } $modinfo = get_fast_modinfo($course); $cm = get_coursemodule_from_instance('lti', $linkid, $lti->course, false, MUST_EXIST); @@ -101,39 +106,15 @@ class contextmemberships extends resource_base { $modinfo = null; } } - if ($tool->toolproxyid == 0) { - if (!$this->check_type($params['tool_code'], $params['context_id'], - 'ToolProxyBinding.memberships.url:get', null)) { - throw new \Exception(null, 403); - } - } else { - $toolproxy = $DB->get_record('lti_tool_proxies', array('id' => $tool->toolproxyid), 'guid', IGNORE_MISSING); - if (!$this->check_tool_proxy($toolproxy->guid, $response->get_request_data())) { - throw new \Exception(null, 403); - } - } - $json = memberships::get_users_json($this, $context, $course->id, $tool, $role, $limitfrom, $limitnum, $lti, $modinfo); - $response->set_content_type($this->formats[0]); + $json = $this->get_service()->get_members_json($this, $context, $course, $role, $limitfrom, $limitnum, $lti, + $modinfo, $response); + $response->set_body($json); } catch (\Exception $e) { $response->set_code($e->getCode()); - } - } - - /** - * get permissions from the config of the tool for that resource - * - * @param int $typeid - * @return array with the permissions related to this resource by the $lti_type or null if none. - */ - public function get_permissions($typeid) { - $tool = lti_get_type_type_config($typeid); - if ($tool->ltiservice_memberships == '1') { - return array('ToolProxyBinding.memberships.url:get'); - } else { - return array(); + $response->set_reason($e->getMessage()); } } @@ -154,14 +135,8 @@ class contextmemberships extends resource_base { $this->params['context_type'] = 'CourseSection'; } $this->params['context_id'] = $COURSE->id; - - $id = optional_param('id', 0, PARAM_INT); // Course Module ID. - if (!empty($id)) { - $cm = get_coursemodule_from_id('lti', $id, 0, false, IGNORE_MISSING); - $lti = $DB->get_record('lti', array('id' => $cm->instance), 'typeid', IGNORE_MISSING); - if ($lti && !empty($lti->typeid)) { - $this->params['tool_code'] = $lti->typeid; - } + if ($tool = $this->get_service()->get_type()) { + $this->params['tool_code'] = $tool->id; } $value = str_replace('$ToolProxyBinding.memberships.url', parent::get_endpoint(), $value); } diff --git a/mod/lti/service/memberships/classes/local/resources/linkmemberships.php b/mod/lti/service/memberships/classes/local/resources/linkmemberships.php index bcbb01ade8a..619efb7c802 100644 --- a/mod/lti/service/memberships/classes/local/resources/linkmemberships.php +++ b/mod/lti/service/memberships/classes/local/resources/linkmemberships.php @@ -79,6 +79,10 @@ class linkmemberships extends resource_base { $limitfrom = 0; } + if (!$this->check_tool(null, $response->get_request_data(), memberships::SCOPE_MEMBERSHIPS_READ)) { + $response->set_code(403); + return; + } if (empty($linkid)) { $response->set_code(404); return; @@ -87,19 +91,6 @@ class linkmemberships extends resource_base { $response->set_code(404); return; } - $tool = $DB->get_record('lti_types', array('id' => $lti->typeid)); - if ($tool->toolproxyid == 0) { // We wil use the same permission for this and contextmembers. - if (!$this->check_type($lti->typeid, $lti->course, 'ToolProxyBinding.memberships.url:get', null)) { - $response->set_code(403); - return; - } - } else { - $toolproxy = $DB->get_record('lti_tool_proxies', array('id' => $tool->toolproxyid)); - if (!$this->check_tool_proxy($toolproxy->guid, $response->get_request_data())) { - $response->set_code(403); - return; - } - } if (!($course = $DB->get_record('course', array('id' => $lti->course), 'id', IGNORE_MISSING))) { $response->set_code(404); return; @@ -115,7 +106,8 @@ class linkmemberships extends resource_base { if ($info->is_available_for_all()) { $info = null; } - $json = memberships::get_users_json($this, $context, $lti->course, $tool, $role, $limitfrom, $limitnum, $lti, $info); + $json = $this->get_service()->get_members_json($this, $context, $lti->course, $role, + $limitfrom, $limitnum, $lti, $info, $response); $response->set_content_type($this->formats[0]); $response->set_body($json); diff --git a/mod/lti/service/memberships/classes/local/service/memberships.php b/mod/lti/service/memberships/classes/local/service/memberships.php index 751a185fb7f..997661004be 100644 --- a/mod/lti/service/memberships/classes/local/service/memberships.php +++ b/mod/lti/service/memberships/classes/local/service/memberships.php @@ -45,10 +45,6 @@ class memberships extends \mod_lti\local\ltiservice\service_base { const CONTEXT_ROLE_LEARNER = 'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner'; /** Capability used to identify Instructors */ const INSTRUCTOR_CAPABILITY = 'moodle/course:manageactivities'; - /** Name of LTI service component */ - const LTI_SERVICE_COMPONENT = 'ltiservice_memberships'; - /** Membership services enabled */ - const MEMBERSHIP_ENABLED = 1; /** Always include field */ const ALWAYS_INCLUDE_FIELD = 1; /** Allow the instructor to decide if included */ @@ -57,6 +53,8 @@ class memberships extends \mod_lti\local\ltiservice\service_base { const INSTRUCTOR_INCLUDED = 1; /** Instructor delegated and approved for include */ const INSTRUCTOR_DELEGATE_INCLUDED = array(self::DELEGATE_TO_INSTRUCTOR && self::INSTRUCTOR_INCLUDED); + /** Scope for reading membership data */ + const SCOPE_MEMBERSHIPS_READ = 'https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly'; /** * Class constructor. @@ -65,7 +63,7 @@ class memberships extends \mod_lti\local\ltiservice\service_base { parent::__construct(); $this->id = 'memberships'; - $this->name = get_string('servicename', self::LTI_SERVICE_COMPONENT); + $this->name = get_string($this->get_component_id(), $this->get_component_id()); } @@ -86,6 +84,24 @@ class memberships extends \mod_lti\local\ltiservice\service_base { } + /** + * Get the scope(s) permitted for the tool relevant to this service. + * + * @return array + */ + public function get_permitted_scopes() { + + $scopes = array(); + $ok = !empty($this->get_type()); + if ($ok && isset($this->get_typeconfig()[$this->get_component_id()]) && + ($this->get_typeconfig()[$this->get_component_id()] == parent::SERVICE_ENABLED)) { + $scopes[] = self::SCOPE_MEMBERSHIPS_READ; + } + + return $scopes; + + } + /** * Get the JSON for members. * @@ -101,8 +117,44 @@ class memberships extends \mod_lti\local\ltiservice\service_base { * for LTI instance (null if context-level request) * * @return string + * @deprecated since Moodle 3.7 MDL-62599 - please do not use this function any more. + * @see memberships::get_members_json($resource, $context, $course, $role, $limitfrom, $limitnum, $lti, $info, $response) */ public static function get_users_json($resource, $context, $contextid, $tool, $role, $limitfrom, $limitnum, $lti, $info) { + global $DB; + + debugging('get_users_json() has been deprecated, ' . + 'please use memberships::get_members_json() instead.', DEBUG_DEVELOPER); + + $course = $DB->get_record('course', array('id' => $contextid), 'id,shortname,fullname', IGNORE_MISSING); + + $memberships = new memberships(); + $memberships->check_tool($tool->id, null, array(self::SCOPE_MEMBERSHIPS_READ)); + + $response = new \mod_lti\local\ltiservice\response(); + + $json = $memberships->get_members_json($resource, $context, $course, $role, $limitfrom, $limitnum, $lti, $info, $response); + + return $json; + } + + /** + * Get the JSON for members. + * + * @param \mod_lti\local\ltiservice\resource_base $resource Resource handling the request + * @param \context_course $context Course context + * @param \course $course Course + * @param string $role User role requested (empty if none) + * @param int $limitfrom Position of first record to be returned + * @param int $limitnum Maximum number of records to be returned + * @param object $lti LTI instance record + * @param \core_availability\info_module $info Conditional availability information + * for LTI instance (null if context-level request) + * @param \mod_lti\local\ltiservice\response $response Response object for the request + * + * @return string + */ + public function get_members_json($resource, $context, $course, $role, $limitfrom, $limitnum, $lti, $info, $response) { $withcapability = ''; $exclude = array(); @@ -117,18 +169,20 @@ class memberships extends \mod_lti\local\ltiservice\service_base { null, null, null, true)); } } - $users = get_enrolled_users($context, $withcapability, 0, 'u.*', null, $limitfrom, $limitnum, true); - if (count($users) < $limitnum) { - $limitfrom = 0; - $limitnum = 0; + $users = get_enrolled_users($context, $withcapability, 0, 'u.*', null, 0, 0, true); + if (($response->get_accept() === 'application/vnd.ims.lti-nrps.v2.membershipcontainer+json') || + (($response->get_accept() !== 'application/vnd.ims.lis.v2.membershipcontainer+json') && + ($this->get_type()->ltiversion === LTI_VERSION_1P3))) { + $json = $this->users_to_json($resource, $users, $course, $exclude, $limitfrom, $limitnum, $lti, $info, $response); + } else { + $json = $this->users_to_jsonld($resource, $users, $course->id, $exclude, $limitfrom, $limitnum, $lti, $info, $response); } - $json = self::users_to_json($resource, $users, $contextid, $tool, $exclude, $limitfrom, $limitnum, $lti, $info); return $json; } /** - * Get the JSON representation of the users. + * Get the JSON-LD representation of the users. * * Note that when a limit is set and the exclude array is not empty, then the number of memberships * returned may be less than the limit. @@ -136,34 +190,28 @@ class memberships extends \mod_lti\local\ltiservice\service_base { * @param \mod_lti\local\ltiservice\resource_base $resource Resource handling the request * @param array $users Array of user records * @param string $contextid Course ID - * @param object $tool Tool instance object * @param array $exclude Array of user records to be excluded from the response * @param int $limitfrom Position of first record to be returned * @param int $limitnum Maximum number of records to be returned * @param object $lti LTI instance record - * @param \core_availability\info_module $info Conditional availability information for LTI instance + * @param \core_availability\info_module $info Conditional availability information + * for LTI instance (null if context-level request) + * @param \mod_lti\local\ltiservice\response $response Response object for the request * * @return string */ - private static function users_to_json($resource, $users, $contextid, $tool, $exclude, $limitfrom, $limitnum, - $lti, $info) { + private function users_to_jsonld($resource, $users, $contextid, $exclude, $limitfrom, $limitnum, + $lti, $info, $response) { global $DB; + $tool = $this->get_type(); + $toolconfig = $this->get_typeconfig(); $arrusers = [ '@context' => 'http://purl.imsglobal.org/ctx/lis/v2/MembershipContainer', '@type' => 'Page', '@id' => $resource->get_endpoint(), ]; - if ($limitnum > 0) { - $limitfrom += $limitnum; - $nextpage = "{$resource->get_endpoint()}?limit={$limitnum}&from={$limitfrom}"; - if (!is_null($lti)) { - $nextpage .= "&rlid={$lti->id}"; - } - $arrusers['nextPage'] = $nextpage; - } - $arrusers['pageOf'] = [ '@type' => 'LISMembershipContainer', 'membershipSubject' => [ @@ -175,6 +223,8 @@ class memberships extends \mod_lti\local\ltiservice\service_base { $enabledcapabilities = lti_get_enabled_capabilities($tool); $islti2 = $tool->toolproxyid > 0; + $n = 0; + $more = false; foreach ($users as $user) { if (in_array($user->id, $exclude)) { continue; @@ -182,6 +232,16 @@ class memberships extends \mod_lti\local\ltiservice\service_base { if (!empty($info) && !$info->is_user_visible($info->get_course_module(), $user->id)) { continue; } + $n++; + if ($limitnum > 0) { + if ($n <= $limitfrom) { + continue; + } + if (count($arrusers['pageOf']['membershipSubject']['membership']) >= $limitnum) { + $more = true; + break; + } + } $member = new \stdClass(); $member->{"@type" } = 'LISPerson'; @@ -189,13 +249,12 @@ class memberships extends \mod_lti\local\ltiservice\service_base { $membership->status = 'Active'; $membership->role = explode(',', lti_get_ims_role($user->id, null, $contextid, true)); - $toolconfig = lti_get_type_type_config($tool->id); $instanceconfig = null; if (!is_null($lti)) { $instanceconfig = lti_get_type_config_from_instance($lti->id); } $isallowedlticonfig = self::is_allowed_field_set($toolconfig, $instanceconfig, - ['name' => 'lti_sendname', 'email' => 'lti_sendemailaddr']); + ['name' => 'sendname', 'email' => 'sendemailaddr']); $includedcapabilities = [ 'User.id' => ['type' => 'id', @@ -208,7 +267,7 @@ class memberships extends \mod_lti\local\ltiservice\service_base { 'member.field' => 'name', 'source.value' => format_string("{$user->firstname} {$user->lastname}")], 'Person.name.given' => ['type' => 'name', - 'member.field' => 'givenName', + 'member.field' => 'giveName', 'source.value' => format_string($user->firstname)], 'Person.name.family' => ['type' => 'name', 'member.field' => 'familyName', @@ -253,6 +312,161 @@ class memberships extends \mod_lti\local\ltiservice\service_base { $arrusers['pageOf']['membershipSubject']['membership'][] = $membership; } + if ($more) { + $nextlimitfrom = $limitfrom + $limitnum; + $nextpage = "{$resource->get_endpoint()}?limit={$limitnum}&from={$nextlimitfrom}"; + if (!is_null($lti)) { + $nextpage .= "&rlid={$lti->id}"; + } + $arrusers['nextPage'] = $nextpage; + } + + $response->set_content_type('application/vnd.ims.lis.v2.membershipcontainer+json'); + + return json_encode($arrusers); + } + + /** + * Get the NRP service JSON representation of the users. + * + * Note that when a limit is set and the exclude array is not empty, then the number of memberships + * returned may be less than the limit. + * + * @param \mod_lti\local\ltiservice\resource_base $resource Resource handling the request + * @param array $users Array of user records + * @param \course $course Course + * @param array $exclude Array of user records to be excluded from the response + * @param int $limitfrom Position of first record to be returned + * @param int $limitnum Maximum number of records to be returned + * @param object $lti LTI instance record + * @param \core_availability\info_module $info Conditional availability information for LTI instance + * @param \mod_lti\local\ltiservice\response $response Response object for the request + * + * @return string + */ + private function users_to_json($resource, $users, $course, $exclude, $limitfrom, $limitnum, + $lti, $info, $response) { + global $DB, $CFG; + + $tool = $this->get_type(); + $toolconfig = $this->get_typeconfig(); + + $context = new \stdClass(); + $context->id = $course->id; + $context->label = trim(html_to_text($course->shortname, 0)); + $context->title = trim(html_to_text($course->fullname, 0)); + + $arrusers = [ + 'id' => $resource->get_endpoint(), + 'context' => $context, + 'members' => [] + ]; + + $islti2 = $tool->toolproxyid > 0; + $n = 0; + $more = false; + foreach ($users as $user) { + if (in_array($user->id, $exclude)) { + continue; + } + if (!empty($info) && !$info->is_user_visible($info->get_course_module(), $user->id)) { + continue; + } + $n++; + if ($limitnum > 0) { + if ($n <= $limitfrom) { + continue; + } + if (count($arrusers['members']) >= $limitnum) { + $more = true; + break; + } + } + + $member = new \stdClass(); + $member->status = 'Active'; + $member->roles = explode(',', lti_get_ims_role($user->id, null, $course->id, true)); + + $instanceconfig = null; + if (!is_null($lti)) { + $instanceconfig = lti_get_type_config_from_instance($lti->id); + } + if (!$islti2) { + $isallowedlticonfig = self::is_allowed_field_set($toolconfig, $instanceconfig, + ['name' => 'sendname', 'givenname' => 'sendname', 'familyname' => 'sendname', + 'email' => 'sendemailaddr']); + } else { + $isallowedlticonfig = self::is_allowed_capability_set($tool, + ['name' => 'Person.name.full', 'givenname' => 'Person.name.given', + 'familyname' => 'Person.name.family', 'email' => 'Person.email.primary']); + } + $includedcapabilities = [ + 'User.id' => ['type' => 'id', + 'member.field' => 'user_id', + 'source.value' => $user->id], + 'Person.sourcedId' => ['type' => 'id', + 'member.field' => 'lis_person_sourcedid', + 'source.value' => format_string($user->idnumber)], + 'Person.name.full' => ['type' => 'name', + 'member.field' => 'name', + 'source.value' => format_string("{$user->firstname} {$user->lastname}")], + 'Person.name.given' => ['type' => 'givenname', + 'member.field' => 'given_name', + 'source.value' => format_string($user->firstname)], + 'Person.name.family' => ['type' => 'familyname', + 'member.field' => 'family_name', + 'source.value' => format_string($user->lastname)], + 'Person.email.primary' => ['type' => 'email', + 'member.field' => 'email', + 'source.value' => format_string($user->email)] + ]; + + if (!is_null($lti)) { + $message = new \stdClass(); + $message->{'https://purl.imsglobal.org/spec/lti/claim/message_type'} = 'LtiResourceLinkRequest'; + $conditions = array('courseid' => $course->id, 'itemtype' => 'mod', + 'itemmodule' => 'lti', 'iteminstance' => $lti->id); + + if (!empty($lti->servicesalt) && $DB->record_exists('grade_items', $conditions)) { + $basicoutcome = new \stdClass(); + $basicoutcome->lis_result_sourcedid = json_encode(lti_build_sourcedid($lti->id, + $user->id, + $lti->servicesalt, + $lti->typeid)); + // Add outcome service URL. + $serviceurl = new \moodle_url('/mod/lti/service.php'); + $serviceurl = $serviceurl->out(); + $forcessl = false; + if (!empty($CFG->mod_lti_forcessl)) { + $forcessl = true; + } + if ((isset($toolconfig['forcessl']) && ($toolconfig['forcessl'] == '1')) or $forcessl) { + $serviceurl = lti_ensure_url_is_https($serviceurl); + } + $basicoutcome->lis_outcome_service_url = $serviceurl; + $message->{'https://purl.imsglobal.org/spec/lti-bos/claim/basicoutcomesservice'} = $basicoutcome; + } + $member->message = [$message]; + } + + foreach ($includedcapabilities as $capabilityname => $capability) { + if (($capability['type'] === 'id') || $isallowedlticonfig[$capability['type']]) { + $member->{$capability['member.field']} = $capability['source.value']; + } + } + + $arrusers['members'][] = $member; + } + if ($more) { + $nextlimitfrom = $limitfrom + $limitnum; + $nextpage = "{$resource->get_endpoint()}?limit={$limitnum}&from={$nextlimitfrom}"; + if (!is_null($lti)) { + $nextpage .= "&rlid={$lti->id}"; + } + $response->add_additional_header("Link: {$nextpage};rel=next"); + } + + $response->set_content_type('application/vnd.ims.lti-nrps.v2.membershipcontainer+json'); return json_encode($arrusers); } @@ -267,11 +481,11 @@ class memberships extends \mod_lti\local\ltiservice\service_base { private static function is_allowed_field_set($toolconfig, $instanceconfig, $fields) { $isallowedstate = []; foreach ($fields as $key => $field) { - $allowed = self::ALWAYS_INCLUDE_FIELD == $toolconfig->{$field}; - if (!$allowed) { - if (self::DELEGATE_TO_INSTRUCTOR == $toolconfig->{$field} && !is_null($instanceconfig)) { - $allowed = $instanceconfig->{$field} == self::INSTRUCTOR_INCLUDED; - } + $allowed = isset($toolconfig[$field]) && (self::ALWAYS_INCLUDE_FIELD == $toolconfig[$field]); + if (!$allowed && isset($toolconfig[$field]) && (self::DELEGATE_TO_INSTRUCTOR == $toolconfig[$field]) && + !is_null($instanceconfig)) { + $allowed = isset($instanceconfig->{"lti_{$field}"}) && + ($instanceconfig->{"lti_{$field}"} == self::INSTRUCTOR_INCLUDED); } $isallowedstate[$key] = $allowed; } @@ -279,31 +493,20 @@ class memberships extends \mod_lti\local\ltiservice\service_base { } /** - * Adds form elements for membership add/edit page. + * Create form element for membership add/edit page. * - * @param \MoodleQuickForm $mform + * @return array of \HTML_QuickForm_element Form elements */ - public function get_configuration_options(&$mform) { - $elementname = 'ltiservice_memberships'; + public function get_configuration_elements() { + $elements = array(); $options = [ - get_string('notallow', self::LTI_SERVICE_COMPONENT), - get_string('allow', self::LTI_SERVICE_COMPONENT) + get_string('notallow', $this->get_component_id()), + get_string('allow', $this->get_component_id()) ]; + $elements[''] = new \MoodleQuickForm_select($this->get_component_id(), get_string($this->get_component_id(), + $this->get_component_id()), $options); - $mform->addElement('select', $elementname, get_string($elementname, self::LTI_SERVICE_COMPONENT), $options); - $mform->setType($elementname, 'int'); - $mform->setDefault($elementname, 0); - $mform->addHelpButton($elementname, $elementname, self::LTI_SERVICE_COMPONENT); - } - - /** - * Return an array with the names of the parameters that the service will be saving in the configuration - * - * @return array with the names of the parameters that the service will be saving in the configuration - * - */ - public function get_configuration_parameter_names() { - return array(self::LTI_SERVICE_COMPONENT); + return $elements; } /** @@ -325,16 +528,10 @@ class memberships extends \mod_lti\local\ltiservice\service_base { $launchparameters = array(); $tool = lti_get_type_type_config($typeid); - if (isset($tool->ltiservice_memberships)) { - if ($tool->ltiservice_memberships == '1' && $this->is_used_in_context($typeid, $courseid)) { - $endpoint = $this->get_service_path(); - if ($COURSE->id === SITEID) { - $contexttype = 'Group'; - } else { - $contexttype = 'CourseSection'; - } - $launchparameters['custom_context_memberships_url'] = $endpoint . - "/{$contexttype}/{$courseid}/bindings/{$typeid}/memberships"; + if (isset($tool->{$this->get_component_id()})) { + if ($tool->{$this->get_component_id()} == parent::SERVICE_ENABLED && $this->is_used_in_context($typeid, $courseid)) { + $launchparameters['context_memberships_url'] = '$ToolProxyBinding.memberships.url'; + $launchparameters['context_memberships_versions'] = '1.0,2.0'; } } return $launchparameters; diff --git a/mod/lti/service/memberships/lang/en/ltiservice_memberships.php b/mod/lti/service/memberships/lang/en/ltiservice_memberships.php index c077a149211..f7d74ac76d8 100644 --- a/mod/lti/service/memberships/lang/en/ltiservice_memberships.php +++ b/mod/lti/service/memberships/lang/en/ltiservice_memberships.php @@ -24,10 +24,14 @@ */ $string['allow'] = 'Use this service to retrieve members\' information as per privacy settings'; -$string['ltiservice_memberships'] = 'IMS LTI Membership: '; -$string['ltiservice_memberships_help'] = 'Allow the tool to retrieve member\'s info from the course using the IMS LTI Membership Service. The privacy settings will apply.'; +$string['ltiservice_memberships'] = 'IMS LTI Names and Role Provisioning'; +$string['ltiservice_memberships_help'] = 'Allow the tool to retrieve member\'s info from the course ' . + 'using the IMS LTI Names and Role Provisioning Service. The privacy settings will apply. For course-level ' . + 'requests these will be based on the tool confguration settings. If you wish to always send such details ' . + 'do not delegate the choice to teachers. Link-level requests will always use the privacy settings which ' . + 'apply to the link.'; $string['notallow'] = 'Do not use this service'; -$string['pluginname'] = 'Memberships LTI Service'; +$string['pluginname'] = 'Names and Role Provisioning LTI Service'; $string['privacy:metadata:email'] = 'The email of the user using the LTI consumer.'; $string['privacy:metadata:externalpurpose'] = 'This information is sent to an external LTI provider.'; $string['privacy:metadata:firstname'] = 'The firstname of the user using the LTI consumer.'; @@ -35,4 +39,3 @@ $string['privacy:metadata:fullname'] = 'The fullname of the user using the LTI c $string['privacy:metadata:lastname'] = 'The lastname of the user using the LTI consumer.'; $string['privacy:metadata:userid'] = 'The ID of the user using the LTI consumer.'; $string['privacy:metadata:useridnumber'] = 'The ID number of the user using the LTI consumer'; -$string['servicename'] = 'Memberships'; diff --git a/mod/lti/service/profile/classes/local/resources/profile.php b/mod/lti/service/profile/classes/local/resources/profile.php index bd55ac43b8b..53fef577807 100644 --- a/mod/lti/service/profile/classes/local/resources/profile.php +++ b/mod/lti/service/profile/classes/local/resources/profile.php @@ -83,13 +83,15 @@ class profile extends \mod_lti\local\ltiservice\resource_base { $version = service_base::LTI_VERSION2P0; $params = $this->parse_template(); - $ok = $this->get_service()->check_tool_proxy($params['tool_proxy_id']); - if (!$ok) { - $response->set_code(404); - } else if (optional_param('lti_version', '', PARAM_ALPHANUMEXT) != $version) { + if (optional_param('lti_version', service_base::LTI_VERSION2P0, PARAM_ALPHANUMEXT) != $version) { + $ok = false; $response->set_code(400); } else { - $toolproxy = $this->get_service()->get_tool_proxy(); + $toolproxy = lti_get_tool_proxy_from_guid($params['tool_proxy_id']); + $ok = $toolproxy !== false; + } + if ($ok) { + $this->get_service()->set_tool_proxy($toolproxy); $response->set_content_type($this->formats[0]); $servicepath = $this->get_service()->get_service_path(); @@ -111,7 +113,12 @@ class profile extends \mod_lti\local\ltiservice\resource_base { $formats = implode("\", \"", $resource->get_formats()); $methods = implode("\", \"", $resource->get_methods()); $capabilityofferedarr = array_merge($capabilityofferedarr, $resource->get_variables()); - $path = $servicepath . preg_replace('/\{?.*\}$/', '', $resource->get_path()); + $template = $resource->get_path(); + if (!empty($template)) { + $path = $servicepath . preg_replace('/[\(\)]/', '', $template); + } else { + $path = $resource->get_endpoint(); + } $serviceoffered .= <<< EOD {$sep} { @@ -199,17 +206,6 @@ EOD; } } - /** - * Get the resource fully qualified endpoint. - * - * @return string - */ - public function get_endpoint() { - - return parent::get_endpoint() . '?lti_version=' . service_base::LTI_VERSION2P0; - - } - /** * Parse a value for custom parameter substitution variables. * @@ -218,7 +214,7 @@ EOD; * @return string */ public function parse_value($value) { - if (strpos($value, '$ToolConsumerProfile.url') !== false) { + if (!empty($this->get_service()->get_tool_proxy()) && (strpos($value, '$ToolConsumerProfile.url') !== false)) { $value = str_replace('$ToolConsumerProfile.url', $this->get_endpoint(), $value); } return $value; diff --git a/mod/lti/service/toolproxy/classes/local/resources/toolproxy.php b/mod/lti/service/toolproxy/classes/local/resources/toolproxy.php index ccb2e055e28..16f1f39338e 100644 --- a/mod/lti/service/toolproxy/classes/local/resources/toolproxy.php +++ b/mod/lti/service/toolproxy/classes/local/resources/toolproxy.php @@ -66,10 +66,12 @@ class toolproxy extends \mod_lti\local\ltiservice\resource_base { */ public function execute($response) { - $ok = $this->check_tool_proxy(null, $response->get_request_data()); + $ok = $this->check_tool(null, $response->get_request_data()); + $ok = $ok && ($this->get_service()->get_tool_proxy()); if ($ok) { $toolproxy = $this->get_service()->get_tool_proxy(); - } else { + } + if (!$ok) { $toolproxy = null; $response->set_code(401); } @@ -118,6 +120,7 @@ class toolproxy extends \mod_lti\local\ltiservice\resource_base { } // Check all services requested were offered (only tool services currently supported). + $requestsbasicoutcomes = false; if ($ok && isset($toolproxyjson->security_contract->tool_service)) { $contexts = lti_get_contexts($toolproxyjson); $profileservice = lti_get_service_by_name('profile'); @@ -129,6 +132,7 @@ class toolproxy extends \mod_lti\local\ltiservice\resource_base { $errors = array(); foreach ($tpservices as $service) { $fqid = lti_get_fqid($contexts, $service->service); + $requestsbasicoutcomes = $requestsbasicoutcomes || (substr($fqid, -13) === 'Outcomes.LTI1'); if (substr($fqid, 0, strlen($context)) !== $context) { $errors[] = $service->service; } else { @@ -219,7 +223,15 @@ class toolproxy extends \mod_lti\local\ltiservice\resource_base { $type = new \stdClass(); $type->state = LTI_TOOL_STATE_PENDING; + $type->ltiversion = LTI_VERSION_2; $type->toolproxyid = $toolproxy->id; + // Ensure gradebook column is created. + if ($requestsbasicoutcomes && !in_array('BasicOutcome.url', $launchrequest->enabled_capability)) { + $launchrequest->enabled_capability[] = 'BasicOutcome.url'; + } + if ($requestsbasicoutcomes && !in_array('BasicOutcome.sourcedId', $launchrequest->enabled_capability)) { + $launchrequest->enabled_capability[] = 'BasicOutcome.sourcedId'; + } $type->enabledcapability = implode("\n", $launchrequest->enabled_capability); $type->parameter = self::lti_extract_parameters($launchrequest->parameter); diff --git a/mod/lti/service/toolsettings/classes/local/resources/contextsettings.php b/mod/lti/service/toolsettings/classes/local/resources/contextsettings.php index e7c7b5c29af..3a9b2fdfe37 100644 --- a/mod/lti/service/toolsettings/classes/local/resources/contextsettings.php +++ b/mod/lti/service/toolsettings/classes/local/resources/contextsettings.php @@ -49,7 +49,7 @@ class contextsettings extends \mod_lti\local\ltiservice\resource_base { parent::__construct($service); $this->id = 'ToolProxyBindingSettings'; - $this->template = '/{context_type}/{context_id}/bindings/{vendor_code}/{product_code}'; + $this->template = '/{context_type}/{context_id}/bindings/{vendor_code}/{product_code}(/custom)'; $this->variables[] = 'ToolProxyBinding.custom.url'; $this->formats[] = 'application/vnd.ims.lti.v2.toolsettings+json'; $this->formats[] = 'application/vnd.ims.lti.v2.toolsettings.simple+json'; @@ -71,85 +71,106 @@ class contextsettings extends \mod_lti\local\ltiservice\resource_base { $vendorcode = $params['vendor_code']; $productcode = $params['product_code']; $bubble = optional_param('bubble', '', PARAM_ALPHA); + $typeid = null; + if (($vendorcode === 'tool') && is_numeric($productcode)) { + $typeid = $productcode; + } $ok = !empty($contexttype) && !empty($contextid) && !empty($vendorcode) && !empty($productcode) && - $this->check_tool_proxy($productcode, $response->get_request_data()); + $this->check_tool($typeid, $response->get_request_data(), + array(toolsettings::SCOPE_TOOL_SETTINGS)); if (!$ok) { $response->set_code(401); - } - $contenttype = $response->get_accept(); - $simpleformat = !empty($contenttype) && ($contenttype == $this->formats[1]); - if ($ok) { - $ok = (empty($bubble) || ((($bubble == 'distinct') || ($bubble == 'all')))) && - (!$simpleformat || empty($bubble) || ($bubble != 'all')) && - (empty($bubble) || ($response->get_request_method() == 'GET')); - } - - if (!$ok) { - $response->set_code(404); } else { - $systemsetting = null; - $contextsettings = lti_get_tool_settings($this->get_service()->get_tool_proxy()->id, $contextid); - if (!empty($bubble)) { - $systemsetting = new systemsettings($this->get_service()); - $systemsetting->params['tool_proxy_id'] = $productcode; - $systemsettings = lti_get_tool_settings($this->get_service()->get_tool_proxy()->id); - if ($bubble == 'distinct') { - toolsettings::distinct_settings($systemsettings, $contextsettings, null); - } + $toolproxy = $this->get_service()->get_tool_proxy(); + if (!empty($toolproxy)) { + $ok = $toolproxy->guid === $productcode; + $typeid = null; + $id = $toolproxy->id; } else { - $systemsettings = null; + $ok = $vendorcode === 'tool'; + $typeid = intval($productcode); + $id = -$typeid; } - if ($response->get_request_method() == 'GET') { - $json = ''; - if ($simpleformat) { - $response->set_content_type($this->formats[1]); - $json .= "{"; + $contenttype = $response->get_accept(); + $simpleformat = !empty($contenttype) && ($contenttype == $this->formats[1]); + if ($ok) { + $ok = (empty($bubble) || ((($bubble == 'distinct') || ($bubble == 'all')))) && + (!$simpleformat || empty($bubble) || ($bubble != 'all')) && + (empty($bubble) || ($response->get_request_method() == 'GET')); + } + + if (!$ok) { + $response->set_code(404); + } else { + $systemsetting = null; + $contextsettings = lti_get_tool_settings($id, $contextid); + if (!empty($bubble)) { + $systemsetting = new systemsettings($this->get_service()); + $systemsetting->params['tool_proxy_id'] = $productcode; + if ($id >= 0) { + $systemsetting->params['config_type'] = 'toolproxy'; + } else { + $systemsetting->params['config_type'] = 'tool'; + } + $systemsettings = lti_get_tool_settings($id); + if ($bubble == 'distinct') { + toolsettings::distinct_settings($systemsettings, $contextsettings, null); + } } else { - $response->set_content_type($this->formats[0]); - $json .= "{\n \"@context\":\"http://purl.imsglobal.org/ctx/lti/v2/ToolSettings\",\n \"@graph\":[\n"; + $systemsettings = null; } - $settings = toolsettings::settings_to_json($systemsettings, $simpleformat, 'ToolProxy', $systemsetting); - $json .= $settings; - $isfirst = strlen($settings) <= 0; - $settings = toolsettings::settings_to_json($contextsettings, $simpleformat, 'ToolProxyBinding', $this); - if ((strlen($settings) > 0) && !$isfirst) { - $json .= ","; - } - $json .= $settings; - if ($simpleformat) { - $json .= "\n}"; - } else { - $json .= "\n ]\n}"; - } - $response->set_body($json); - } else { // PUT. - $settings = null; - if ($response->get_content_type() == $this->formats[0]) { - $json = json_decode($response->get_request_data()); - $ok = !empty($json); - if ($ok) { - $ok = isset($json->{"@graph"}) && is_array($json->{"@graph"}) && (count($json->{"@graph"}) == 1) && - ($json->{"@graph"}[0]->{"@type"} == 'ToolProxyBinding'); + if ($response->get_request_method() == 'GET') { + $json = ''; + if ($simpleformat) { + $response->set_content_type($this->formats[1]); + $json .= "{"; + } else { + $response->set_content_type($this->formats[0]); + $json .= "{\n \"@context\":\"http://purl.imsglobal.org/ctx/lti/v2/ToolSettings\",\n \"@graph\":[\n"; + } + $settings = toolsettings::settings_to_json($systemsettings, $simpleformat, 'ToolProxy', $systemsetting); + $json .= $settings; + $isfirst = strlen($settings) <= 0; + $settings = toolsettings::settings_to_json($contextsettings, $simpleformat, 'ToolProxyBinding', $this); + if ((strlen($settings) > 0) && !$isfirst) { + $json .= ","; + } + $json .= $settings; + if ($simpleformat) { + $json .= "\n}"; + } else { + $json .= "\n ]\n}"; + } + $response->set_body($json); + } else { // PUT. + $settings = null; + if ($response->get_content_type() == $this->formats[0]) { + $json = json_decode($response->get_request_data()); + $ok = !empty($json); + if ($ok) { + $ok = isset($json->{"@graph"}) && is_array($json->{"@graph"}) && (count($json->{"@graph"}) == 1) && + ($json->{"@graph"}[0]->{"@type"} == 'ToolProxyBinding'); + } + if ($ok) { + $settings = $json->{"@graph"}[0]->custom; + unset($settings->{'@id'}); + } + } else { // Simple JSON. + $json = json_decode($response->get_request_data(), true); + $ok = !empty($json); + if ($ok) { + $ok = is_array($json); + } + if ($ok) { + $settings = $json; + } } if ($ok) { - $settings = $json->{"@graph"}[0]->custom; - unset($settings->{'@id'}); + lti_set_tool_settings($settings, $id, $contextid); + } else { + $response->set_code(406); } - } else { // Simple JSON. - $json = json_decode($response->get_request_data(), true); - $ok = !empty($json); - if ($ok) { - $ok = is_array($json); - } - if ($ok) { - $settings = $json; - } - } - if ($ok) { - lti_set_tool_settings($settings, $this->get_service()->get_tool_proxy()->id, $contextid); - } else { - $response->set_code(406); } } } @@ -172,8 +193,13 @@ class contextsettings extends \mod_lti\local\ltiservice\resource_base { $this->params['context_type'] = 'CourseSection'; } $this->params['context_id'] = $COURSE->id; - $this->params['vendor_code'] = $this->get_service()->get_tool_proxy()->vendorcode; - $this->params['product_code'] = $this->get_service()->get_tool_proxy()->guid; + if (!empty($this->get_service()->get_tool_proxy())) { + $this->params['vendor_code'] = $this->get_service()->get_tool_proxy()->vendorcode; + $this->params['product_code'] = $this->get_service()->get_tool_proxy()->guid; + } else { + $this->params['vendor_code'] = 'tool'; + $this->params['product_code'] = $this->get_service()->get_type()->id; + } $value = str_replace('$ToolProxyBinding.custom.url', parent::get_endpoint(), $value); } return $value; diff --git a/mod/lti/service/toolsettings/classes/local/resources/linksettings.php b/mod/lti/service/toolsettings/classes/local/resources/linksettings.php index 2c770062074..f1623994a04 100644 --- a/mod/lti/service/toolsettings/classes/local/resources/linksettings.php +++ b/mod/lti/service/toolsettings/classes/local/resources/linksettings.php @@ -48,7 +48,7 @@ class linksettings extends \mod_lti\local\ltiservice\resource_base { parent::__construct($service); $this->id = 'LtiLinkSettings'; - $this->template = '/links/{link_id}'; + $this->template = '/links/{link_id}(/custom)'; $this->variables[] = 'LtiLink.custom.url'; $this->formats[] = 'application/vnd.ims.lti.v2.toolsettings+json'; $this->formats[] = 'application/vnd.ims.lti.v2.toolsettings.simple+json'; @@ -72,7 +72,7 @@ class linksettings extends \mod_lti\local\ltiservice\resource_base { $simpleformat = !empty($contenttype) && ($contenttype == $this->formats[1]); $ok = (empty($bubble) || ((($bubble == 'distinct') || ($bubble == 'all')))) && (!$simpleformat || empty($bubble) || ($bubble != 'all')) && - (empty($bubble) || ($response->get_request_method() == 'GET')); + (empty($bubble) || ($response->get_request_method() == self::HTTP_GET)); if (!$ok) { $response->set_code(406); } @@ -84,38 +84,51 @@ class linksettings extends \mod_lti\local\ltiservice\resource_base { $ok = !empty($linkid); if ($ok) { $lti = $DB->get_record('lti', array('id' => $linkid), 'course,typeid', MUST_EXIST); - $ltitype = $DB->get_record('lti_types', array('id' => $lti->typeid)); - $toolproxy = $DB->get_record('lti_tool_proxies', array('id' => $ltitype->toolproxyid)); - $ok = $this->check_tool_proxy($toolproxy->guid, $response->get_request_data()); + $ok = $this->check_tool($lti->typeid, $response->get_request_data(), + array(toolsettings::SCOPE_TOOL_SETTINGS)); } if (!$ok) { $response->set_code(401); } } if ($ok) { - $linksettings = lti_get_tool_settings($this->get_service()->get_tool_proxy()->id, $lti->course, $linkid); - if (!empty($bubble)) { - $contextsetting = new contextsettings($this->get_service()); - if ($COURSE == 'site') { - $contextsetting->params['context_type'] = 'Group'; - } else { - $contextsetting->params['context_type'] = 'CourseSection'; - } - $contextsetting->params['context_id'] = $lti->course; - $contextsetting->params['vendor_code'] = $this->get_service()->get_tool_proxy()->vendorcode; - $contextsetting->params['product_code'] = $this->get_service()->get_tool_proxy()->id; - $contextsettings = lti_get_tool_settings($this->get_service()->get_tool_proxy()->id, $lti->course); - $systemsetting = new systemsettings($this->get_service()); - $systemsetting->params['tool_proxy_id'] = $this->get_service()->get_tool_proxy()->id; - $systemsettings = lti_get_tool_settings($this->get_service()->get_tool_proxy()->id); - if ($bubble == 'distinct') { - toolsettings::distinct_settings($systemsettings, $contextsettings, $linksettings); - } + if (!empty($this->get_service()->get_tool_proxy())) { + $id = $this->get_service()->get_tool_proxy()->id; } else { - $contextsettings = null; - $systemsettings = null; + $id = -$this->get_service()->get_type()->id; } if ($response->get_request_method() == 'GET') { + $linksettings = lti_get_tool_settings($id, $lti->course, $linkid); + if (!empty($bubble)) { + $contextsetting = new contextsettings($this->get_service()); + if ($COURSE == 'site') { + $contextsetting->params['context_type'] = 'Group'; + } else { + $contextsetting->params['context_type'] = 'CourseSection'; + } + $contextsetting->params['context_id'] = $lti->course; + if ($id >= 0) { + $contextsetting->params['vendor_code'] = $this->get_service()->get_tool_proxy()->vendorcode; + } else { + $contextsetting->params['vendor_code'] = 'tool'; + } + $contextsetting->params['product_code'] = abs($id); + $contextsettings = lti_get_tool_settings($id, $lti->course); + $systemsetting = new systemsettings($this->get_service()); + if ($id >= 0) { + $systemsetting->params['config_type'] = 'toolproxy'; + } else { + $systemsetting->params['config_type'] = 'tool'; + } + $systemsetting->params['tool_proxy_id'] = abs($id); + $systemsettings = lti_get_tool_settings($id); + if ($bubble == 'distinct') { + toolsettings::distinct_settings($systemsettings, $contextsettings, $linksettings); + } + } else { + $contextsettings = null; + $systemsettings = null; + } $json = ''; if ($simpleformat) { $response->set_content_type($this->formats[1]); @@ -176,7 +189,7 @@ class linksettings extends \mod_lti\local\ltiservice\resource_base { } } if ($ok) { - lti_set_tool_settings($settings, $this->get_service()->get_tool_proxy()->id, $lti->course, $linkid); + lti_set_tool_settings($settings, $id, $lti->course, $linkid); } else { $response->set_code(406); } diff --git a/mod/lti/service/toolsettings/classes/local/resources/systemsettings.php b/mod/lti/service/toolsettings/classes/local/resources/systemsettings.php index b6be719216a..6d250099f59 100644 --- a/mod/lti/service/toolsettings/classes/local/resources/systemsettings.php +++ b/mod/lti/service/toolsettings/classes/local/resources/systemsettings.php @@ -50,12 +50,12 @@ class systemsettings extends resource_base { parent::__construct($service); $this->id = 'ToolProxySettings'; - $this->template = '/toolproxy/{tool_proxy_id}'; + $this->template = '/{config_type}/{tool_proxy_id}(/custom)'; $this->variables[] = 'ToolProxy.custom.url'; $this->formats[] = 'application/vnd.ims.lti.v2.toolsettings+json'; $this->formats[] = 'application/vnd.ims.lti.v2.toolsettings.simple+json'; - $this->methods[] = 'GET'; - $this->methods[] = 'PUT'; + $this->methods[] = self::HTTP_GET; + $this->methods[] = self::HTTP_PUT; } @@ -68,10 +68,27 @@ class systemsettings extends resource_base { $params = $this->parse_template(); $tpid = $params['tool_proxy_id']; - $bubble = optional_param('bubble', '', PARAM_ALPHA); - $ok = !empty($tpid) && $this->check_tool_proxy($tpid, $response->get_request_data()); + $configtype = $params['config_type']; + $ok = (in_array($configtype, array('toolproxy', 'tool'))); + if ($ok) { + $typeid = null; + if (($configtype === 'tool') && is_numeric($tpid)) { + $typeid = $tpid; + } + $bubble = optional_param('bubble', '', PARAM_ALPHA); + $ok = !empty($tpid) && $this->check_tool($typeid, $response->get_request_data(), + array(toolsettings::SCOPE_TOOL_SETTINGS)); + } if (!$ok) { $response->set_code(401); + } else if (!empty($this->get_service()->get_tool_proxy())) { + $ok = ($this->get_service()->get_tool_proxy()->guid === $tpid); + $id = $this->get_service()->get_tool_proxy()->id; + } else if (!empty($typeid)) { + $id = -$typeid; + } else { + $ok = false; + $response->set_code(404); } $contenttype = $response->get_accept(); $simpleformat = !empty($contenttype) && ($contenttype == $this->formats[1]); @@ -85,7 +102,7 @@ class systemsettings extends resource_base { } if ($ok) { - $systemsettings = lti_get_tool_settings($this->get_service()->get_tool_proxy()->id); + $systemsettings = lti_get_tool_settings($id); if ($response->get_request_method() == 'GET') { $json = ''; if ($simpleformat) { @@ -127,7 +144,7 @@ class systemsettings extends resource_base { } } if ($ok) { - lti_set_tool_settings($settings, $this->get_service()->get_tool_proxy()->id); + lti_set_tool_settings($settings, $id); } else { $response->set_code(406); } diff --git a/mod/lti/service/toolsettings/classes/local/service/toolsettings.php b/mod/lti/service/toolsettings/classes/local/service/toolsettings.php index f7969a9be61..046c8208db6 100644 --- a/mod/lti/service/toolsettings/classes/local/service/toolsettings.php +++ b/mod/lti/service/toolsettings/classes/local/service/toolsettings.php @@ -38,6 +38,9 @@ defined('MOODLE_INTERNAL') || die(); */ class toolsettings extends \mod_lti\local\ltiservice\service_base { + /** Scope for managing tool settings */ + const SCOPE_TOOL_SETTINGS = 'https://purl.imsglobal.org/spec/lti-ts/scope/toolsetting'; + /** * Class constructor. */ @@ -67,6 +70,24 @@ class toolsettings extends \mod_lti\local\ltiservice\service_base { } + /** + * Get the scope(s) permitted for the tool relevant to this service. + * + * @return array + */ + public function get_permitted_scopes() { + + $scopes = array(); + $ok = !empty($this->get_type()); + if ($ok && isset($this->get_typeconfig()[$this->get_component_id()]) && + ($this->get_typeconfig()[$this->get_component_id()] == parent::SERVICE_ENABLED)) { + $scopes[] = self::SCOPE_TOOL_SETTINGS; + } + + return $scopes; + + } + /** * Get the distinct settings from each level by removing any duplicates from higher levels. * @@ -111,11 +132,10 @@ class toolsettings extends \mod_lti\local\ltiservice\service_base { if (!$simpleformat) { $json .= " {\n \"@type\":\"{$type}\",\n"; $json .= " \"@id\":\"{$resource->get_endpoint()}\",\n"; - $json .= " \"custom\":{\n"; - $json .= " \"@id\":\"{$resource->get_endpoint()}/custom\""; + $json .= " \"custom\":{"; $indent = ' '; } - $isfirst = $simpleformat; + $isfirst = true; if (!empty($settings)) { foreach ($settings as $key => $value) { if (!$isfirst) { @@ -135,4 +155,52 @@ class toolsettings extends \mod_lti\local\ltiservice\service_base { } + /** + * Create form element for membership add/edit page. + * + * @return array of \HTML_QuickForm_element Form elements + */ + public function get_configuration_elements() { + $elements = array(); + $options = [ + get_string('notallow', $this->get_component_id()), + get_string('allow', $this->get_component_id()) + ]; + $elements[''] = new \MoodleQuickForm_select($this->get_component_id(), get_string($this->get_component_id(), + $this->get_component_id()), $options); + + return $elements; + } + + /** + * Return an array of key/values to add to the launch parameters. + * + * @param string $messagetype 'basic-lti-launch-request' or 'ContentItemSelectionRequest'. + * @param string $courseid The course id. + * @param string $user The user id. + * @param string $typeid The tool lti type id. + * @param string $modlti The id of the lti activity. + * + * The type is passed to check the configuration + * and not return parameters for services not used. + * + * @return array of key/value pairs to add as launch parameters. + */ + public function get_launch_parameters($messagetype, $courseid, $user, $typeid, $modlti = null) { + global $COURSE; + + $launchparameters = array(); + $tool = lti_get_type_type_config($typeid); + if (isset($tool->{$this->get_component_id()})) { + if ($tool->{$this->get_component_id()} == self::SERVICE_ENABLED && $this->is_used_in_context($typeid, $courseid)) { + $launchparameters['system_setting_url'] = '$ToolProxy.custom.url'; + $launchparameters['context_setting_url'] = '$ToolProxyBinding.custom.url'; + if ($messagetype === 'basic-lti-launch-request') { + $launchparameters['link_setting_url'] = '$LtiLink.custom.url'; + } + } + } + return $launchparameters; + } + } diff --git a/mod/lti/service/toolsettings/lang/en/ltiservice_toolsettings.php b/mod/lti/service/toolsettings/lang/en/ltiservice_toolsettings.php index 59f4bbe5453..47ffe1d8e75 100644 --- a/mod/lti/service/toolsettings/lang/en/ltiservice_toolsettings.php +++ b/mod/lti/service/toolsettings/lang/en/ltiservice_toolsettings.php @@ -23,5 +23,9 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +$string['allow'] = 'Use this service'; +$string['ltiservice_toolsettings'] = 'Tool Settings'; +$string['ltiservice_toolsettings_help'] = 'Allow the tool to save and retrieve setting values.'; +$string['notallow'] = 'Do not use this service'; $string['pluginname'] = 'Tool Settings Service'; $string['privacy:metadata'] = 'The Tool Settings Service plugin does not store any personal data.'; diff --git a/mod/lti/servicelib.php b/mod/lti/servicelib.php index 0dae3d0c6f6..60964a4e8b1 100644 --- a/mod/lti/servicelib.php +++ b/mod/lti/servicelib.php @@ -71,7 +71,9 @@ function lti_parse_message_id($xml) { function lti_parse_grade_replace_message($xml) { $node = $xml->imsx_POXBody->replaceResultRequest->resultRecord->sourcedGUID->sourcedId; $resultjson = json_decode((string)$node); - + if ( is_null($resultjson) ) { + throw new Exception('Invalid sourcedId in result message'); + } $node = $xml->imsx_POXBody->replaceResultRequest->resultRecord->result->resultScore->textString; $score = (string) $node; @@ -100,6 +102,9 @@ function lti_parse_grade_replace_message($xml) { function lti_parse_grade_read_message($xml) { $node = $xml->imsx_POXBody->readResultRequest->resultRecord->sourcedGUID->sourcedId; $resultjson = json_decode((string)$node); + if ( is_null($resultjson) ) { + throw new Exception('Invalid sourcedId in result message'); + } $parsed = new stdClass(); $parsed->instanceid = $resultjson->data->instanceid; @@ -116,6 +121,9 @@ function lti_parse_grade_read_message($xml) { function lti_parse_grade_delete_message($xml) { $node = $xml->imsx_POXBody->deleteResultRequest->resultRecord->sourcedGUID->sourcedId; $resultjson = json_decode((string)$node); + if ( is_null($resultjson) ) { + throw new Exception('Invalid sourcedId in result message'); + } $parsed = new stdClass(); $parsed->instanceid = $resultjson->data->instanceid; @@ -146,7 +154,7 @@ function lti_accepts_grades($ltiinstance) { } } else { $enabledcapabilities = explode("\n", $ltitype->enabledcapability); - $acceptsgrades = in_array('Result.autocreate', $enabledcapabilities); + $acceptsgrades = in_array('Result.autocreate', $enabledcapabilities) || in_array('BasicOutcome.url', $enabledcapabilities); } return $acceptsgrades; diff --git a/mod/lti/services.php b/mod/lti/services.php index 547ea0e1347..b1fdca6b67b 100644 --- a/mod/lti/services.php +++ b/mod/lti/services.php @@ -32,19 +32,20 @@ require_once($CFG->dirroot . '/mod/lti/locallib.php'); $response = new \mod_lti\local\ltiservice\response(); -$isget = $response->get_request_method() == 'GET'; -$isdelete = $response->get_request_method() == 'DELETE'; +$isget = $response->get_request_method() === mod_lti\local\ltiservice\resource_base::HTTP_GET; +$isdelete = $response->get_request_method() === mod_lti\local\ltiservice\resource_base::HTTP_DELETE; if ($isget) { $response->set_accept(isset($_SERVER['HTTP_ACCEPT']) ? $_SERVER['HTTP_ACCEPT'] : ''); } else { - $response->set_content_type(isset($_SERVER['CONTENT_TYPE']) ? $_SERVER['CONTENT_TYPE'] : ''); + $response->set_content_type(isset($_SERVER['CONTENT_TYPE']) ? explode(';', $_SERVER['CONTENT_TYPE'], 2)[0] : ''); } $ok = false; $path = isset($_SERVER['PATH_INFO']) ? $_SERVER['PATH_INFO'] : ''; $accept = $response->get_accept(); +$contenttype = $response->get_content_type(); $services = lti_get_services(); foreach ($services as $service) { @@ -52,14 +53,15 @@ foreach ($services as $service) { foreach ($resources as $resource) { if (($isget && !empty($accept) && (strpos($accept, '*/*') === false) && !in_array($accept, $resource->get_formats())) || - ((!$isget && !$isdelete) && !in_array($response->get_content_type(), $resource->get_formats()))) { + ((!$isget && !$isdelete) && !in_array($contenttype, $resource->get_formats()))) { continue; } $template = $resource->get_template(); + $template = preg_replace('/{config_type}/', '(toolproxy|tool)', $template); $template = preg_replace('/\{[a-zA-Z_]+\}/', '[^/]+', $template); - $template = preg_replace('/\{\?[0-9a-zA-Z_\-,]+\}$/', '', $template); + $template = preg_replace('/\(([0-9a-zA-Z_\-,\/]+)\)/', '(\\1|)', $template); $template = str_replace('/', '\/', $template); - if (preg_match("/{$template}/", $path) === 1) { + if (preg_match("/^{$template}$/", $path) === 1) { $ok = true; break 2; } @@ -67,6 +69,7 @@ foreach ($services as $service) { } if (!$ok) { $response->set_code(400); + $response->set_reason("No handler found for {$path} {$accept} {$contenttype}"); } else { $body = file_get_contents('php://input'); $response->set_request_data($body); diff --git a/mod/lti/templates/tool_card.mustache b/mod/lti/templates/tool_card.mustache index 4a3b08502c1..56b2dbcb06c 100644 --- a/mod/lti/templates/tool_card.mustache +++ b/mod/lti/templates/tool_card.mustache @@ -35,12 +35,28 @@ * urls ** course ** icon + ** publickeyset + ** accesstoken + ** authrequest * name * description + * platformid + * clientid + * deploymentid * instancecount }} -
+
diff --git a/mod/lti/templates/tool_config_modal_body.mustache b/mod/lti/templates/tool_config_modal_body.mustache new file mode 100644 index 00000000000..38f4c0e0faf --- /dev/null +++ b/mod/lti/templates/tool_config_modal_body.mustache @@ -0,0 +1,58 @@ +{{! + 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 . +}} +{{! + @template mod_lti/tool_config_modal_body + + This template provides the layout for the body of the configuration summary modal for + a single tool opened from the tool configuration page. + + Classes required for JS: + * none + + Data attributes required for JS: + * none + + Context variables required for this template: + * platformid + * clientid + * deploymentid + * urls + ** publickeyset + ** accesstoken + ** authrequest + + Example context (json): + { + "platformid": "htps://...", + "clientid": "123", + "deploymentid": "1", + "urls": { + "publickeyset": "https://...", + "accesstoken": "https://...", + "authrequest": "https://..." + } + } + +}} +
    +
  • {{#str}} tooldetailsplatformid, mod_lti {{/str}}: {{platformid}}
  • +
  • {{#str}} tooldetailsclientid, mod_lti {{/str}}: {{clientid}}
  • +
  • {{#str}} tooldetailsdeploymentid, mod_lti {{/str}}: {{deploymentid}}
  • +
  • {{#str}} tooldetailspublickeyseturl, mod_lti {{/str}}: {{urls.publickeyset}}
  • +
  • {{#str}} tooldetailsaccesstokenurl, mod_lti {{/str}}: {{urls.accesstoken}}
  • +
  • {{#str}} tooldetailsauthrequesturl, mod_lti {{/str}}: {{urls.authrequest}}
  • +
diff --git a/mod/lti/templates/tool_config_modal_footer.mustache b/mod/lti/templates/tool_config_modal_footer.mustache new file mode 100644 index 00000000000..ff73ae6ebe6 --- /dev/null +++ b/mod/lti/templates/tool_config_modal_footer.mustache @@ -0,0 +1,41 @@ +{{! + 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 . +}} +{{! + @template mod_lti/tool_config_modal_footer + + This template provides the layout for the footer of the configuration summary modal for + a single tool opened from the tool configuration page. + + Classes required for JS: + * none + + Data attributes required for JS: + * none + + Context variables required for this template: + * mailto + + Example context (json): + { + "mailto": "mailto:?subject=...&body=..." + } + +}} +
+ + +
diff --git a/mod/lti/tests/locallib_test.php b/mod/lti/tests/locallib_test.php index cdc31603355..e29c305dfce 100644 --- a/mod/lti/tests/locallib_test.php +++ b/mod/lti/tests/locallib_test.php @@ -68,23 +68,26 @@ class mod_lti_locallib_testcase extends advanced_testcase { $tool = new stdClass(); $tool->enabledcapability = ''; $tool->parameter = ''; + $tool->ltiversion = 'LTI-1p0'; $this->assertEquals(lti_split_custom_parameters(null, $tool, array(), "x=1\ny=2", false), array('custom_x' => '1', 'custom_y' => '2')); // Check params with caps. - $this->assertEquals(lti_split_custom_parameters(null, $tool, array(), "X=1", false), + $this->assertEquals(lti_split_custom_parameters(null, $tool, array(), "X=1", true), array('custom_x' => '1', 'custom_X' => '1')); // Removed repeat of previous test with a semicolon separator. - $this->assertEquals(lti_split_custom_parameters(null, $tool, array(), 'Review:Chapter=1.2.56', false), - array('custom_review_chapter' => '1.2.56', 'custom_Review_Chapter' => '1.2.56')); + $this->assertEquals(lti_split_custom_parameters(null, $tool, array(), 'Review:Chapter=1.2.56', true), + array( + 'custom_review_chapter' => '1.2.56', + 'custom_Review:Chapter' => '1.2.56')); $this->assertEquals(lti_split_custom_parameters(null, $tool, array(), - 'Complex!@#$^*(){}[]KEY=Complex!@#$^*;(){}[]½Value', false), + 'Complex!@#$^*(){}[]KEY=Complex!@#$^*;(){}[]½Value', true), array( 'custom_complex____________key' => 'Complex!@#$^*;(){}[]½Value', - 'custom_Complex____________KEY' => 'Complex!@#$^*;(){}[]½Value')); + 'custom_Complex!@#$^*(){}[]KEY' => 'Complex!@#$^*;(){}[]½Value')); // Test custom parameter that returns $USER property. $user = $this->getDataGenerator()->create_user(array('middlename' => 'SOMETHING')); @@ -369,7 +372,7 @@ class mod_lti_locallib_testcase extends advanced_testcase { $url = $result->url; $this->assertEquals($typeconfig['toolurl'], $url); $this->assertEquals('ContentItemSelectionRequest', $params['lti_message_type']); - $this->assertEquals(LTI_VERSION_2, $params['lti_version']); + $this->assertEquals(LTI_VERSION_1, $params['lti_version']); $this->assertEquals('application/vnd.ims.lti.v1.ltilink', $params['accept_media_types']); $this->assertEquals('frame,iframe,window', $params['accept_presentation_document_targets']); $this->assertEquals($returnurl->out(false), $params['content_item_return_url']); diff --git a/mod/lti/tests/task_clean_access_tokens_test.php b/mod/lti/tests/task_clean_access_tokens_test.php new file mode 100644 index 00000000000..5af37f08204 --- /dev/null +++ b/mod/lti/tests/task_clean_access_tokens_test.php @@ -0,0 +1,84 @@ +. + +/** + * Tests cleaning up the access tokens task. + * + * @package mod_lti + * @category test + * @copyright 2019 Mark Nelson + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +/** + * Tests cleaning up the access tokens task. + * + * @package mod_lti + * @category test + * @copyright 2019 Mark Nelson + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class mod_lti_clean_access_tokens_testcase extends advanced_testcase { + + /** + * Test set up. + * + * This is executed before running any test in this file. + */ + public function setUp() { + $this->resetAfterTest(); + } + + /** + * Test the cleanup task. + */ + public function test_cleanup_task() { + global $DB; + + $time = time(); + + // Create an expired access token. + $token = new stdClass(); + $token->typeid = 1; + $token->scope = 'scope'; + $token->token = 'token'; + $token->validuntil = $time - DAYSECS; + $token->timecreated = $time - DAYSECS; + + $t1id = $DB->insert_record('lti_access_tokens', $token); + + // New token, in the future. + $token->validuntil = $time + DAYSECS; + + $token->token = 'token2'; + $t2id = $DB->insert_record('lti_access_tokens', $token); + + // Run the task. + $task = new \mod_lti\task\clean_access_tokens(); + $task->execute(); + + // Check there is only one token now. + $tokens = $DB->get_records('lti_access_tokens'); + + $this->assertCount(1, $tokens); + + $token = reset($tokens); + + $this->assertEquals($t2id, $token->id); + } +} diff --git a/mod/lti/token.php b/mod/lti/token.php new file mode 100644 index 00000000000..3d90ca8a385 --- /dev/null +++ b/mod/lti/token.php @@ -0,0 +1,118 @@ +. + +/** + * This file contains a service for issuing access tokens + * + * @package mod_lti + * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +define('NO_DEBUG_DISPLAY', true); +define('NO_MOODLE_COOKIES', true); + +use Firebase\JWT\JWT as JWT; + +require_once(__DIR__ . '/../../config.php'); +require_once($CFG->dirroot . '/mod/lti/locallib.php'); + + +$response = new \mod_lti\local\ltiservice\response(); + +$contenttype = isset($_SERVER['CONTENT_TYPE']) ? explode(';', $_SERVER['CONTENT_TYPE'], 2)[0] : ''; + +$ok = ($_SERVER['REQUEST_METHOD'] === 'POST') && ($contenttype === 'application/x-www-form-urlencoded'); +$error = 'invalid_request'; + +$clientassertion = optional_param('client_assertion', '', PARAM_TEXT); +$clientassertiontype = optional_param('client_assertion_type', '', PARAM_TEXT); +$granttype = optional_param('grant_type', '', PARAM_TEXT); +$scope = optional_param('scope', '', PARAM_TEXT); + +if ($ok) { + $ok = !empty($clientassertion) && !empty($clientassertiontype) && + !empty($granttype) && !empty($scope); +} + +if ($ok) { + $ok = ($clientassertiontype === 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer') && + ($granttype === 'client_credentials'); + $error = 'unsupported_grant_type'; +} + +if ($ok) { + $parts = explode('.', $clientassertion); + $ok = (count($parts) === 3); + if ($ok) { + $payload = JWT::urlsafeB64Decode($parts[1]); + $claims = json_decode($payload, true); + $ok = !is_null($claims) && !empty($claims['sub']); + } + $error = 'invalid_request'; +} + +if ($ok) { + $error = 'invalid_client'; + $tool = $DB->get_record('lti_types', array('clientid' => $claims['sub'])); + if ($tool) { + $typeconfig = lti_get_type_config($tool->id); + if (!empty($typeconfig['publickey'])) { + try { + $jwt = JWT::decode($clientassertion, $typeconfig['publickey'], array('RS256')); + $ok = true; + } catch (Exception $e) { + $ok = false; + } + } + } else { + $ok = false; + } +} + +if ($ok) { + $scopes = array(); + $requestedscopes = explode(' ', $scope); + $permittedscopes = lti_get_permitted_service_scopes($tool, $typeconfig); + $scopes = array_intersect($requestedscopes, $permittedscopes); + $ok = !empty($scopes); + $error = 'invalid_scope'; +} + +if ($ok) { + $token = lti_new_access_token($tool->id, $scopes); + $expiry = LTI_ACCESS_TOKEN_LIFE; + $permittedscopes = implode(' ', $scopes); + $body = <<< EOD +{ + "access_token" : "{$token->token}", + "token_type" : "Bearer", + "expires_in" : {$expiry}, + "scope" : "{$permittedscopes}" +} +EOD; +} else { + $response->set_code(400); + $body = <<< EOD +{ + "error" : "{$error}" +} +EOD; +} + +$response->set_body($body); + +$response->send(); diff --git a/mod/lti/typessettings.php b/mod/lti/typessettings.php index 40fbf141bc4..d5f9524a0af 100644 --- a/mod/lti/typessettings.php +++ b/mod/lti/typessettings.php @@ -84,6 +84,8 @@ if (!empty($id)) { // Assign a default empty value for the lti_icon. $type->lti_icon = ''; $type->lti_secureicon = ''; + + $type->lti_clientid = null; } $pageurl = new moodle_url('/mod/lti/typessettings.php'); @@ -119,7 +121,8 @@ if (lti_request_is_using_ssl() && !empty($type->lti_secureicon)) { $type->oldicon = $type->lti_icon; } -$form = new mod_lti_edit_types_form($pageurl, (object)array('isadmin' => true, 'istool' => false)); +$form = new mod_lti_edit_types_form($pageurl, + (object)array('isadmin' => true, 'istool' => false, 'id' => $id, 'clientid' => $type->lti_clientid)); if ($data = $form->get_data()) { $type = new stdClass(); diff --git a/mod/lti/upgrade.txt b/mod/lti/upgrade.txt index c6d2a52f875..0c2e46165ec 100644 --- a/mod/lti/upgrade.txt +++ b/mod/lti/upgrade.txt @@ -1,5 +1,37 @@ This files describes API changes in the lti code. +=== 3.7 === + +* Deprecated functions to add support for LTI 1 tools to access services: + - mod_lti\local\ltiservice\service_base->check_tool_proxy() + - mod_lti\local\ltiservice\service_base->check_type() + - mod_lti\local\ltiservice\resource_base->check_tool_proxy() + - mod_lti\local\ltiservice\resource_base->check_type() + - mod_lti_edit_types_form->get_lti_advantage_services() + replaced by: + - mod_lti\local\ltiservice\service_base->check_tool() + - mod_lti\local\ltiservice\resource_base->check_tool() + - mod_lti_edit_types_form->get_lti_services() +* Deprecated functions to prevent name clashes with service-related parameters: + - mod_lti\local\ltiservice\service_base->get_configuration_parameter_names() + - mod_lti\local\ltiservice\service_base->get_configuration_options() + - ltiservice_gradebookservices\local\service\gradebookservices\get_configuration_options() +* Deprecated functions to simplify implementation of LTI scopes for accessing services: + - mod_lti\local\ltiservice\resource_base->get_permissions() + - ltiservice_gradebookservices\local\resources\lineitem\get_permissions() + - ltiservice_gradebookservices\local\resources\lineitems\get_permissions() + - ltiservice_gradebookservices\local\resources\results\get_permissions() + - ltiservice_gradebookservices\local\resources\scores\get_permissions() + - ltiservice_memberships\local\resources\contextmemberships\get_permissions() + replaced by: + - mod_lti\local\ltiservice\service_base->get_permitted_scopes() + - ltiservice_gradebookservices\local\service\gradebookservices->get_permitted_scopes() + - ltiservice_memberships\local\service\memberships\->get_permitted_scopes() +* Deprecated function to remove definition as static: + - ltiservice_memberships\local\service\memberships\get_users_json() + replaced by: + - ltiservice_memberships\local\service\memberships\get_members_json() + === 3.4 === * The following functions, previously used (exclusively) by upgrade steps are not available @@ -21,7 +53,8 @@ This files describes API changes in the lti code. === 3.0 === * Function lti_view (locallib.php) was renamed to lti_launch_tool. - A new and different lti_view function was created in the module lib.php containing the code for launching events and update the completion status for the module. + A new and different lti_view function was created in the module lib.php containing the code + for launching events and update the completion status for the module. === 2.8 === @@ -35,4 +68,3 @@ This files describes API changes in the lti code. * mod_lti\event\unknown_service_api_called now has less data stored in 'other' but everything is still available for event observers via method get_message_data() - diff --git a/mod/lti/version.php b/mod/lti/version.php index 5590f32e6a2..c1cdb78a837 100644 --- a/mod/lti/version.php +++ b/mod/lti/version.php @@ -48,7 +48,7 @@ defined('MOODLE_INTERNAL') || die; -$plugin->version = 2018120300; // The current module version (Date: YYYYMMDDXX). +$plugin->version = 2019031303; // The current module version (Date: YYYYMMDDXX). $plugin->requires = 2018112800; // Requires this Moodle version. $plugin->component = 'mod_lti'; // Full name of the plugin (used for diagnostics). $plugin->cron = 0; diff --git a/mod/lti/view.php b/mod/lti/view.php index 0bbf63d8f84..e186f6cb039 100644 --- a/mod/lti/view.php +++ b/mod/lti/view.php @@ -116,7 +116,17 @@ if ($lti->showdescriptionlaunch && $lti->intro) { echo $OUTPUT->box(format_module_intro('lti', $lti, $cm->id), 'generalbox description', 'intro'); } -if ( $launchcontainer == LTI_LAUNCH_CONTAINER_WINDOW ) { +$typeid = $lti->typeid; +if ($typeid) { + $config = lti_get_type_type_config($typeid); +} else { + $config = new stdClass(); + $config->lti_ltiversion = LTI_VERSION_1; +} + +if (($launchcontainer == LTI_LAUNCH_CONTAINER_WINDOW) && + (($config->lti_ltiversion !== LTI_VERSION_1P3) || isset($SESSION->lti_initiatelogin_status))) { + unset($SESSION->lti_initiatelogin_status); if (!$forceview) { echo "