diff --git a/auth/lti/auth.php b/auth/lti/auth.php index b7ded5f2369..29cb47893a1 100644 --- a/auth/lti/auth.php +++ b/auth/lti/auth.php @@ -115,7 +115,7 @@ class auth_plugin_lti extends \auth_plugin_base { if (isloggedin()) { // If a different user is currently logged in, authenticate the linked user instead. global $USER; - if ((int) $USER->id !== $user->id) { + if ($USER->id !== $user->id) { complete_user_login($user); } // If the linked user is already logged in, skip the call to complete_user_login() because this affects deep linking diff --git a/auth/lti/classes/local/ltiadvantage/event/event_handler.php b/auth/lti/classes/local/ltiadvantage/event/event_handler.php new file mode 100644 index 00000000000..a3817f7e46e --- /dev/null +++ b/auth/lti/classes/local/ltiadvantage/event/event_handler.php @@ -0,0 +1,50 @@ +. + +namespace auth_lti\local\ltiadvantage\event; + +use auth_lti\local\ltiadvantage\utility\cookie_helper; +use core\event\user_loggedin; + +/** + * Event handler for auth_lti. + * + * @package auth_lti + * @copyright 2024 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class event_handler { + + /** + * Allows the plugin to augment Set-Cookie headers when the user_loggedin event is fired as part of complete_user_login() calls. + * + * @param user_loggedin $event the event + * @return void + */ + public static function handle_user_loggedin(user_loggedin $event): void { + // The event data isn't important here. The intent of this listener is to ensure that the MoodleSession cookie gets the + // 'Partitioned' attribute, when required - an opt-in flag needed to use Chrome's partitioning mechanism, CHIPS. During LTI + // auth, the auth class (auth/lti/auth.php) calls complete_user_login(), which generates a new session cookie as part of its + // login process. This handler makes sure that this new cookie is intercepted and partitioned, if needed. + if (cookie_helper::cookies_supported()) { + if (cookie_helper::get_cookies_supported_method() == cookie_helper::COOKIE_METHOD_EXPLICIT_PARTITIONING) { + global $CFG; + cookie_helper::add_attributes_to_cookie_response_header('MoodleSession' . $CFG->sessioncookie, + ['Partitioned', 'Secure']); + } + } + } +} diff --git a/auth/lti/classes/local/ltiadvantage/utility/cookie_helper.php b/auth/lti/classes/local/ltiadvantage/utility/cookie_helper.php new file mode 100644 index 00000000000..4a1d6189b48 --- /dev/null +++ b/auth/lti/classes/local/ltiadvantage/utility/cookie_helper.php @@ -0,0 +1,295 @@ +. + +namespace auth_lti\local\ltiadvantage\utility; + +/** + * Helper class providing utils dealing with cookies in LTI, particularly 3rd party cookies. + * + * This class primarily provides a means to augment outbound cookie headers, in order to satisfy browser-specific + * requirements for setting 3rd party cookies. + * + * @package auth_lti + * @copyright 2024 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +final class cookie_helper { + + /** @var int Cookies are not supported. */ + public const COOKIE_METHOD_NOT_SUPPORTED = 0; + + /** @var int Cookies are supported without explicit partitioning. */ + public const COOKIE_METHOD_NO_PARTITIONING = 1; + + /** @var int Cookies are supported via explicit partitioning. */ + public const COOKIE_METHOD_EXPLICIT_PARTITIONING = 2; + + /** + * Make sure the given attributes are set on the Set-Cookie response header identified by name=$cookiename. + * + * This function only affects Set-Cookie headers and modifies the headers directly with the required changes, if any. + * + * @param string $cookiename the cookie name. + * @param array $attributes the attributes to set/ensure are set. + * @return void + */ + public static function add_attributes_to_cookie_response_header(string $cookiename, array $attributes): void { + + $setcookieheaders = array_filter(headers_list(), function($val) { + return preg_match("/Set-Cookie:/i", $val); + }); + if (empty($setcookieheaders)) { + return; + } + + $updatedheaders = self::cookie_response_headers_add_attributes($setcookieheaders, [$cookiename], $attributes); + + // Note: The header_remove() method is quite crude and removes all headers of that header name. + header_remove('Set-Cookie'); + foreach ($updatedheaders as $header) { + header($header, false); + } + } + + /** + * Given a list of HTTP header strings, return a list of HTTP header strings where the matched 'Set-Cookie' headers + * have been updated with the attributes defined in $attribute - an array of strings. + * + * This method does not verify whether a given attribute is valid or not. It blindly sets it and returns the header + * strings. It's up to calling code to determine whether an attribute makes sense or not. + * + * @param array $headerstrings the array of header strings. + * @param array $cookiestomatch the array of cookie names to match. + * @param array $attributes the attributes to set on each matched 'Set-Cookie' header. + * @param bool $casesensitive whether to match the attribute in a case-sensitive way. + * @return array the updated array of header strings. + */ + public static function cookie_response_headers_add_attributes(array $headerstrings, array $cookiestomatch, array $attributes, + bool $casesensitive = false): array { + + return array_map(function($headerstring) use ($attributes, $casesensitive, $cookiestomatch) { + if (!self::cookie_response_header_matches_names($headerstring, $cookiestomatch)) { + return $headerstring; + } + foreach ($attributes as $attribute) { + if (!self::cookie_response_header_contains_attribute($headerstring, $attribute, $casesensitive)) { + $headerstring = self::cookie_response_header_append_attribute($headerstring, $attribute); + } + } + return $headerstring; + }, $headerstrings); + } + + /** + * Check whether cookies can be used with the current user agent and, if so, via what method they are set. + * + * Currently, this tries 2 modes of setting a test cookie: + * 1. Setting a SameSite=None, Secure cookie. This will work in any first party context, and in 3rd party contexts for + * any browsers supporting automatic partitioning of 3rd party cookies (E.g. Firefox, Brave). + * 2. If 1 fails, setting a cookie with the Chrome 'Partitioned' attribute included, opting that cookie into CHIPS. This will + * work for Chrome. + * + * Upon completion of the cookie check, the check sets a SESSION flag indicating the method used to set the cookie, and upgrades + * the session cookie ('MoodleSession') using the respective method. This ensure the session cookie will continue to be sent. + * + * Then, the following methods can be used by client code to query whether the UA supports cookies, and how: + * @see self::cookies_supported() - whether it could be set at all. + * @see self::get_cookies_supported_mode() - if a cookie could be set, what mode was used to set it. + * + * This permits client code to make sure it's setting its cookies appropriately (via the advertised method), and allows it to + * present notices - such as in the case where a given UA is found to be lacking the requisite cookie support. + * E.g. + * cookie_helper::do_cookie_check($mypageurl); + * if (!cookie_helper::cookies_supported()) { + * // Print a notice stating that cookie support is required. + * } + * // Elsewhere in other client code... + * if (cookie_helper::get_cookies_supported_mode() === cookie_helper::COOKIE_METHOD_EXPLICIT_PARTITIONING) { + * // Set a cookie, making sure to use the helper to also opt-in to partitioning. + * setcookie('myauthcookie', 'myauthcookievalue', ['samesite' => 'None', 'secure' => true]); + * cookie_helper::add_partitioning_to_cookie('myauthcookie'); + * } + * + * @param \moodle_url $pageurl the URL of the page making the check, used to redirect back to after setting test cookies. + * @return void + */ + public static function do_cookie_check(\moodle_url $pageurl): void { + global $_COOKIE, $SESSION, $CFG; + $cookiecheck1 = optional_param('cookiecheck1', null, PARAM_INT); + $cookiecheck2 = optional_param('cookiecheck2', null, PARAM_INT); + + if (empty($cookiecheck1)) { + // Start the cookie check. Set two test cookies - one samesite none, and one partitioned - and redirect. + // Set cookiecheck to show the check has started. + self::set_test_cookie('cookiecheck1', self::COOKIE_METHOD_NO_PARTITIONING); + self::set_test_cookie('cookiecheck2', self::COOKIE_METHOD_EXPLICIT_PARTITIONING, true); + $pageurl->params([ + 'cookiecheck1' => self::COOKIE_METHOD_NO_PARTITIONING, + 'cookiecheck2' => self::COOKIE_METHOD_EXPLICIT_PARTITIONING, + ]); + + // LTI needs to guarantee the 'SameSite=None', 'Secure' (and sometimes 'Partitioned') attributes are set on the + // MoodleSession cookie. This is done via manipulation of the outgoing headers after the cookie check redirect. To + // guarantee these outgoing Set-Cookie headers will be created after the redirect, expire the current cookie. + self::expire_moodlesession(); + + redirect($pageurl); + } else { + // Have already started a cookie check, so check the result. + $cookie1received = isset($_COOKIE['cookiecheck1']) && $_COOKIE['cookiecheck1'] == $cookiecheck1; + $cookie2received = isset($_COOKIE['cookiecheck2']) && $_COOKIE['cookiecheck2'] == $cookiecheck2; + + if ($cookie1received || $cookie2received) { + // The test cookie could be set and received. + // Set a session flag storing the method used to set it, and make sure the session cookie uses this method. + $cookiemethod = $cookie1received ? self::COOKIE_METHOD_NO_PARTITIONING : self::COOKIE_METHOD_EXPLICIT_PARTITIONING; + $SESSION->auth_lti_cookie_method = $cookiemethod; + if ($cookiemethod === self::COOKIE_METHOD_EXPLICIT_PARTITIONING) { + // This assumes secure is set, since that's the only way a paritioned test cookie have been set. + self::add_attributes_to_cookie_response_header('MoodleSession'.$CFG->sessioncookie, ['Partitioned', 'Secure']); + } + } + } + } + + /** + * If a cookie check has been made, returns whether cookies could be set or not. + * + * @return bool whether cookies are supported or not. + */ + public static function cookies_supported(): bool { + return self::get_cookies_supported_method() !== self::COOKIE_METHOD_NOT_SUPPORTED; + } + + /** + * If a cookie check has been made, gets the method used to set a cookie, or self::COOKIE_METHOD_NOT_SUPPORTED if not supported. + * + * For cookie methods: + * @see self::COOKIE_METHOD_NOT_SUPPORTED + * @see self::COOKIE_METHOD_NO_PARTITIONING + * @see self::COOKIE_METHOD_EXPLICIT_PARTITIONING + * + * @return int the constant representing the method by which the cookie was set, or not. + */ + public static function get_cookies_supported_method(): int { + global $SESSION; + return $SESSION->auth_lti_cookie_method ?? self::COOKIE_METHOD_NOT_SUPPORTED; + } + + /** + * Forces the expiry of the MoodleSession cookie. + * + * This is useful to force a new Set-Cookie header on the next redirect. + * + * @return void + */ + private static function expire_moodlesession(): void { + global $CFG; + + $setcookieheader = array_filter(headers_list(), function($val) use ($CFG) { + return self::cookie_response_header_matches_name($val, 'MoodleSession'.$CFG->sessioncookie); + }); + if (!empty($setcookieheader)) { + $expirestr = 'Expires='.gmdate(DATE_RFC7231, time() - 60); + self::add_attributes_to_cookie_response_header('MoodleSession'.$CFG->sessioncookie, [$expirestr]); + } else { + setcookie('MoodleSession'.$CFG->sessioncookie, '', time() - 60); + } + } + + /** + * Set a test cookie, using SameSite=None; Secure; attributes if possible, and with or without partitioning opt-in. + * + * @param string $name cookie name + * @param string $value cookie value + * @param bool $partitioned whether to try to add partitioning opt-in, which requires secure cookies (https sites). + * @return void + */ + private static function set_test_cookie(string $name, string $value, bool $partitioned = false): void { + global $CFG; + require_once($CFG->libdir . '/sessionlib.php'); + + $atts = ['expires' => time() + 30]; + if (is_moodle_cookie_secure()) { + $atts['samesite'] = 'none'; + $atts['secure'] = true; + } + setcookie($name, $value, $atts); + + if (is_moodle_cookie_secure() && $partitioned) { + self::add_attributes_to_cookie_response_header($name, ['Partitioned']); + } + } + + /** + * Check whether the header string is a 'Set-Cookie' header for the cookie identified by $cookiename. + * + * @param string $headerstring the header string to check. + * @param string $cookiename the name of the cookie to match. + * @return bool true if the header string is a Set-Cookie header for the named cookie, false otherwise. + */ + private static function cookie_response_header_matches_name(string $headerstring, string $cookiename): bool { + // Generally match the format, but in a case-insensitive way so that 'set-cookie' and "SET-COOKIE" are both valid. + return preg_match("/Set-Cookie: *$cookiename=/i", $headerstring) + // Case-sensitive match on cookiename, which is case-sensitive. + && preg_match("/: *$cookiename=/", $headerstring); + } + + /** + * Check whether the header string is a 'Set-Cookie' header for the cookies identified in the $cookienames array. + * + * @param string $headerstring the header string to check. + * @param array $cookienames the array of cookie names to match. + * @return bool true if the header string is a Set-Cookie header for one of the named cookies, false otherwise. + */ + private static function cookie_response_header_matches_names(string $headerstring, array $cookienames): bool { + foreach ($cookienames as $cookiename) { + if (self::cookie_response_header_matches_name($headerstring, $cookiename)) { + return true; + } + } + return false; + } + + /** + * Check whether the header string contains the given attribute. + * + * @param string $headerstring the header string to check. + * @param string $attribute the attribute to check for. + * @param bool $casesensitive whether to perform a case-sensitive check. + * @return bool true if the header contains the attribute, false otherwise. + */ + private static function cookie_response_header_contains_attribute(string $headerstring, string $attribute, + bool $casesensitive): bool { + + if ($casesensitive) { + return str_contains($headerstring, $attribute); + } + return str_contains(strtolower($headerstring), strtolower($attribute)); + } + + /** + * Append the given attribute to the header string. + * + * @param string $headerstring the header string to append to. + * @param string $attribute the attribute to append. + * @return string the updated header string. + */ + private static function cookie_response_header_append_attribute(string $headerstring, string $attribute): string { + $headerstring = rtrim($headerstring, ';'); // Sometimes included. + return "$headerstring; $attribute;"; + } +} diff --git a/auth/lti/db/events.php b/auth/lti/db/events.php new file mode 100644 index 00000000000..19b4df8e344 --- /dev/null +++ b/auth/lti/db/events.php @@ -0,0 +1,33 @@ +. + +/** + * LTI Auth plugin event handler definition. + * + * @package auth_lti + * @category event + * @copyright 2024 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$observers = [ + [ + 'eventname' => '\core\event\user_loggedin', + 'callback' => '\auth_lti\local\ltiadvantage\event\event_handler::handle_user_loggedin', + ], +]; diff --git a/auth/lti/tests/local/ltiadvantage/utility/cookie_helper_test.php b/auth/lti/tests/local/ltiadvantage/utility/cookie_helper_test.php new file mode 100644 index 00000000000..b7d79d79b39 --- /dev/null +++ b/auth/lti/tests/local/ltiadvantage/utility/cookie_helper_test.php @@ -0,0 +1,215 @@ +. + +namespace auth_lti\local\ltiadvantage\utility; + +/** + * Tests for the cookie_helper utility class. + * + * @package auth_lti + * @copyright 2024 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \auth_lti\local\ltiadvantage\utility\cookie_helper + */ +class cookie_helper_test extends \advanced_testcase { + + /** + * Testing cookie_response_headers_add_attributes(). + * + * @dataProvider cookie_response_headers_provider + * + * @param array $headers the headers to search + * @param array $cookienames the cookienames to match + * @param array $attributes the attributes to add + * @param bool $casesensitive whether to do a case-sensitive lookup for the attribute + * @param array $expectedheaders the expected, updated headers + * @return void + */ + public function test_cookie_response_headers_add_attributes(array $headers, array $cookienames, array $attributes, + bool $casesensitive, array $expectedheaders): void { + + $updated = cookie_helper::cookie_response_headers_add_attributes($headers, $cookienames, $attributes, $casesensitive); + $this->assertEquals($expectedheaders, $updated); + } + + /** + * Data provider for testing cookie_response_headers_add_attributes(). + * + * @return array the inputs and expected outputs. + */ + public static function cookie_response_headers_provider(): array { + return [ + 'Only one matching cookie header, without any of the attributes' => [ + 'headers' => [ + 'Set-Cookie: testcookie=value; path=/test/; HttpOnly;', + ], + 'cookienames' => [ + 'testcookie', + ], + 'attributes' => [ + 'Partitioned', + 'SameSite=None', + 'Secure', + ], + 'casesensitive' => false, + 'output' => [ + 'Set-Cookie: testcookie=value; path=/test/; HttpOnly; Partitioned; SameSite=None; Secure;', + ], + ], + 'Several matching cookie headers, without attributes' => [ + 'headers' => [ + 'Set-Cookie: testcookie=value; path=/test/; HttpOnly;', + 'Set-Cookie: mytestcookie=value; path=/test/; HttpOnly;', + ], + 'cookienames' => [ + 'testcookie', + 'mytestcookie', + ], + 'attributes' => [ + 'Partitioned', + 'SameSite=None', + 'Secure', + ], + 'casesensitive' => false, + 'output' => [ + 'Set-Cookie: testcookie=value; path=/test/; HttpOnly; Partitioned; SameSite=None; Secure;', + 'Set-Cookie: mytestcookie=value; path=/test/; HttpOnly; Partitioned; SameSite=None; Secure;', + ], + ], + 'Several matching cookie headers, several non-matching, all missing all attributes' => [ + 'headers' => [ + 'Set-Cookie: testcookie=value; path=/test/; HttpOnly;', + 'Set-Cookie: mytestcookie=value; path=/test/; HttpOnly;', + 'Set-Cookie: anothertestcookie=value; path=/test/; HttpOnly;', + ], + 'cookienames' => [ + 'testcookie', + 'mytestcookie', + 'blah', + 'etc', + ], + 'attributes' => [ + 'Partitioned', + 'SameSite=None', + 'Secure', + ], + 'casesensitive' => false, + 'output' => [ + 'Set-Cookie: testcookie=value; path=/test/; HttpOnly; Partitioned; SameSite=None; Secure;', + 'Set-Cookie: mytestcookie=value; path=/test/; HttpOnly; Partitioned; SameSite=None; Secure;', + 'Set-Cookie: anothertestcookie=value; path=/test/; HttpOnly;', + ], + ], + 'Matching cookie headers, some with existing attributes' => [ + 'headers' => [ + 'Set-Cookie: testcookie=value; path=/test/; secure; HttpOnly; Partitioned; SameSite=None', + 'Set-Cookie: mytestcookie=value; path=/test/; secure; HttpOnly; SameSite=None', + ], + 'cookienames' => [ + 'testcookie', + 'mytestcookie', + 'etc', + ], + 'attributes' => [ + 'Partitioned', + 'SameSite=None', + 'Secure', + ], + 'casesensitive' => false, + 'output' => [ + 'Set-Cookie: testcookie=value; path=/test/; secure; HttpOnly; Partitioned; SameSite=None', + 'Set-Cookie: mytestcookie=value; path=/test/; secure; HttpOnly; SameSite=None; Partitioned;', + ], + ], + 'Matching headers, some with existing attributes, case sensitive' => [ + 'headers' => [ + 'Set-Cookie: testcookie=value; path=/test/; secure; HttpOnly; SameSite=None; partitioned', + 'Set-Cookie: mytestcookie=value; path=/test/; secure; HttpOnly; SameSite=None', + ], + 'cookienames' => [ + 'testcookie', + 'mytestcookie', + 'etc', + ], + 'attributes' => [ + 'Partitioned', + 'SameSite=None', + 'Secure', + ], + 'casesensitive' => true, + 'output' => [ + 'Set-Cookie: testcookie=value; path=/test/; secure; HttpOnly; SameSite=None; partitioned; Partitioned; Secure;', + 'Set-Cookie: mytestcookie=value; path=/test/; secure; HttpOnly; SameSite=None; Partitioned; Secure;', + ], + ], + 'Empty list of cookie names to match, so unmodified inputs' => [ + 'headers' => [ + 'Set-Cookie: testcookie=value; path=/test/; secure; HttpOnly; SameSite=None; partitioned', + 'Set-Cookie: mytestcookie=value; path=/test/; secure; HttpOnly; SameSite=None', + ], + 'cookienames' => [], + 'attributes' => [ + 'Partitioned', + 'SameSite=None', + 'Secure', + ], + 'casesensitive' => false, + 'output' => [ + 'Set-Cookie: testcookie=value; path=/test/; secure; HttpOnly; SameSite=None; partitioned', + 'Set-Cookie: mytestcookie=value; path=/test/; secure; HttpOnly; SameSite=None', + ], + ], + 'Empty list of attributes to set, so unmodified inputs' => [ + 'headers' => [ + 'Set-Cookie: testcookie=value; path=/test/; secure; HttpOnly; SameSite=None; partitioned', + 'Set-Cookie: mytestcookie=value; path=/test/; secure; HttpOnly; SameSite=None', + ], + 'cookienames' => [ + 'testcookie', + 'mycookie', + ], + 'attributes' => [], + 'casesensitive' => false, + 'output' => [ + 'Set-Cookie: testcookie=value; path=/test/; secure; HttpOnly; SameSite=None; partitioned', + 'Set-Cookie: mytestcookie=value; path=/test/; secure; HttpOnly; SameSite=None', + ], + ], + 'Other HTTP headers, some matching Set-Cookie, some not' => [ + 'headers' => [ + 'Authorization: blah', + 'Set-Cookie: testcookie=value; path=/test/; secure; HttpOnly; SameSite=None; Partitioned', + 'Set-Cookie: mytestcookie=value; path=/test/; secure; HttpOnly; SameSite=None', + ], + 'cookienames' => [ + 'testcookie', + 'mytestcookie', + ], + 'attributes' => [ + 'Partitioned', + 'SameSite=None', + 'Secure', + ], + 'casesensitive' => false, + 'output' => [ + 'Authorization: blah', + 'Set-Cookie: testcookie=value; path=/test/; secure; HttpOnly; SameSite=None; Partitioned', + 'Set-Cookie: mytestcookie=value; path=/test/; secure; HttpOnly; SameSite=None; Partitioned;', + ], + ], + ]; + } +} diff --git a/auth/lti/version.php b/auth/lti/version.php index 5338604fe3e..5e824c205a1 100644 --- a/auth/lti/version.php +++ b/auth/lti/version.php @@ -24,6 +24,6 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2023042400; // The current plugin version (Date: YYYYMMDDXX). +$plugin->version = 2023042401; // The current plugin version (Date: YYYYMMDDXX). $plugin->requires = 2023041800; // Requires this Moodle version. $plugin->component = 'auth_lti'; // Full name of the plugin (used for diagnostics). diff --git a/enrol/lti/classes/output/renderer.php b/enrol/lti/classes/output/renderer.php index 7c17e6e82af..57e4427ce75 100644 --- a/enrol/lti/classes/output/renderer.php +++ b/enrol/lti/classes/output/renderer.php @@ -263,4 +263,19 @@ class renderer extends plugin_renderer_base { return parent::render_from_template('enrol_lti/local/ltiadvantage/registration_view', $tcontext); } + + /** + * Render a warning, indicating to the user that cookies are require but couldn't be set. + * + * @return string the html. + */ + public function render_cookies_required_notice(): string { + $notification = new notification(get_string('cookiesarerequiredinfo', 'enrol_lti'), notification::NOTIFY_WARNING, false); + $tcontext = [ + 'heading' => get_string('cookiesarerequired', 'enrol_lti'), + 'notification' => $notification->export_for_template($this), + ]; + + return parent::render_from_template('enrol_lti/local/ltiadvantage/cookies_required_notice', $tcontext); + } } diff --git a/enrol/lti/lang/en/enrol_lti.php b/enrol/lti/lang/en/enrol_lti.php index dce5d50a21a..64d282374c4 100644 --- a/enrol/lti/lang/en/enrol_lti.php +++ b/enrol/lti/lang/en/enrol_lti.php @@ -31,6 +31,10 @@ $string['addtocourse'] = 'Add to course'; $string['addtogradebook'] = 'Add to gradebook'; $string['allowframeembedding'] = 'Note: It is recommended that the site administration setting \'Allow frame embedding\' is enabled, so that tools are displayed within a frame rather than in a new window.'; $string['authltimustbeenabled'] = 'Note: This plugin requires the LTI authentication plugin to be enabled too.'; +$string['cookiesarerequired'] = 'Cookies are blocked by your browser'; +$string['cookiesarerequiredinfo'] = 'This tool can\'t be launched because your browser seems to be blocking third-party cookies. +

+To use this tool, try changing your browser cookie settings or using a different browser.'; $string['copiedtoclipboard'] = '{$a} copied to clipboard'; $string['copytoclipboard'] = 'Copy to clipboard'; $string['couldnotestablishproxy'] = 'Could not establish proxy with consumer.'; diff --git a/enrol/lti/login.php b/enrol/lti/login.php index cb9b4b4adf8..33b168bd436 100644 --- a/enrol/lti/login.php +++ b/enrol/lti/login.php @@ -26,6 +26,7 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +use auth_lti\local\ltiadvantage\utility\cookie_helper; use enrol_lti\local\ltiadvantage\lib\issuer_database; use enrol_lti\local\ltiadvantage\lib\launch_cache_session; use enrol_lti\local\ltiadvantage\repository\application_registration_repository; @@ -76,6 +77,30 @@ if (empty($_REQUEST['client_id']) && !empty($_REQUEST['id'])) { $_REQUEST['client_id'] = $_REQUEST['id']; } +// Before beginning the OIDC authentication, ensure the MoodleSession cookie can be used. Browser-specific steps may need to be +// taken to set cookies in 3rd party contexts. Skip the check if the user is already auth'd. This means that either cookies aren't +// an issue in the current browser/launch context. +if (!isloggedin()) { + cookie_helper::do_cookie_check(new moodle_url('/enrol/lti/login.php', [ + 'iss' => $iss, + 'login_hint' => $loginhint, + 'target_link_uri' => $targetlinkuri, + 'lti_message_hint' => $ltimessagehint, + 'client_id' => $_REQUEST['client_id'], + ])); + if (!cookie_helper::cookies_supported()) { + global $OUTPUT, $PAGE; + $PAGE->set_context(context_system::instance()); + $PAGE->set_url(new moodle_url('/enrol/lti/login.php')); + $PAGE->set_pagelayout('popup'); + echo $OUTPUT->header(); + $renderer = $PAGE->get_renderer('enrol_lti'); + echo $renderer->render_cookies_required_notice(); + echo $OUTPUT->footer(); + die(); + } +} + // Now, do the OIDC login. LtiOidcLogin::new( new issuer_database(new application_registration_repository(), new deployment_repository()), diff --git a/enrol/lti/templates/local/ltiadvantage/cookies_required_notice.mustache b/enrol/lti/templates/local/ltiadvantage/cookies_required_notice.mustache new file mode 100644 index 00000000000..2b14840d4af --- /dev/null +++ b/enrol/lti/templates/local/ltiadvantage/cookies_required_notice.mustache @@ -0,0 +1,50 @@ +{{! + 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 enrol_lti/local/ltiadvantage/cookies_required_notice + + Displays a notice, reporting that cookies are required but couldn't be set. + + Classes required for JS: + * none + + Data attributes required for JS: + * none + + Context variables required for this template: + * heading + * notification + + Example context (json): + { + "heading": "Cookies are required", + "notification": { + "message": "You appear to be using an unsupported browser...", + "extraclasses": "", + "announce": true, + "closebutton": false, + "issuccess": false, + "isinfo": false, + "iswarning": true, + "iserror": false + } + } +}} +

{{heading}}

+{{#notification}} + {{> core/notification}} +{{/notification}} diff --git a/lib/lti1p3/readme_moodle.txt b/lib/lti1p3/readme_moodle.txt index 4468b2f759d..8f1d1d352f4 100644 --- a/lib/lti1p3/readme_moodle.txt +++ b/lib/lti1p3/readme_moodle.txt @@ -4,6 +4,7 @@ This library is a patched for use in Moodle - it requires the following changes 1. Removal of phpseclib dependency (replaces a single call with openssl equivalent) 2. Apply the fix from https://github.com/packbackbooks/lti-1-3-php-library/pull/107. If this is already merged upstream, please remove this line. +3. The Packback\Lti1p3\ImsStorage\ImsCookie::setCookie() method has been locally patched to opt-in to Chrome cookie partitioning. To upgrade to a new version of this library: 1. Clone the latest version of the upstream library from github: diff --git a/lib/lti1p3/src/ImsStorage/ImsCookie.php b/lib/lti1p3/src/ImsStorage/ImsCookie.php index a98175e2d79..6c6b4c479e7 100644 --- a/lib/lti1p3/src/ImsStorage/ImsCookie.php +++ b/lib/lti1p3/src/ImsStorage/ImsCookie.php @@ -2,6 +2,7 @@ namespace Packback\Lti1p3\ImsStorage; +use auth_lti\local\ltiadvantage\utility\cookie_helper; use Packback\Lti1p3\Interfaces\ICookie; class ImsCookie implements ICookie @@ -33,6 +34,9 @@ class ImsCookie implements ICookie setcookie($name, $value, array_merge($cookie_options, $same_site_options, $options)); + // Necessary, since partitioned can't be set via setcookie yet. + cookie_helper::add_attributes_to_cookie_response_header($name, ['Partitioned']); + // Set a second fallback cookie in the event that "SameSite" is not supported setcookie('LEGACY_'.$name, $value, array_merge($cookie_options, $options)); }