MDL-69542 auth_lti: add mode based user provisioning

Three modes are initially introduced here, for use by dependent code:
1. Automatic - where accounts will be automatically created for users
2. Prompt new or existing - where the user can choose to use an existing
account or have a new account created for them.
3. Prompt existing only - where users must link an existing account.
This change also adds linked logins, for use with provisioning.
This commit is contained in:
Jake Dallimore 2022-01-24 17:50:02 +08:00
parent 66b76c4545
commit 55cbb9c655
15 changed files with 2629 additions and 24 deletions

View File

@ -14,17 +14,12 @@
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* LTI Authentication plugin.
*
* @package auth_lti
* @copyright 2016 Mark Nelson <markn@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
use auth_lti\local\ltiadvantage\entity\user_migration_claim;
defined('MOODLE_INTERNAL') || die();
require_once($CFG->libdir.'/authlib.php');
require_once($CFG->libdir.'/accesslib.php');
/**
* LTI Authentication plugin.
@ -33,7 +28,28 @@ require_once($CFG->libdir.'/authlib.php');
* @copyright 2016 Mark Nelson <markn@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class auth_plugin_lti extends auth_plugin_base {
class auth_plugin_lti extends \auth_plugin_base {
/**
* @var int constant representing the automatic account provisioning mode.
* On first launch, for a previously unbound user, this mode dictates that a new Moodle account will be created automatically
* for the user and bound to their platform credentials {iss, sub}.
*/
public const PROVISIONING_MODE_AUTO_ONLY = 1;
/**
* @var int constant representing the prompt for new or existing provisioning mode.
* On first launch, for a previously unbound user, the mode dictates that the launch user will be presented with an options
* view, allowing them to select either 'link an existing account' or 'create a new account for me'.
*/
public const PROVISIONING_MODE_PROMPT_NEW_EXISTING = 2;
/**
* @var int constant representing the prompt for existing only provisioning mode.
* On first launch, for a previously unbound user, the mode dictates that the launch user will be presented with a view allowing
* them to link an existing account only. This is useful for situations like deep linking, where an existing account is needed.
*/
public const PROVISIONING_MODE_PROMPT_EXISTING_ONLY = 3;
/**
* Constructor.
@ -52,4 +68,387 @@ class auth_plugin_lti extends auth_plugin_base {
public function user_login($username, $password) {
return false;
}
/**
* Authenticate the user based on the unique {iss, sub} tuple present in the OIDC JWT.
*
* This method ensures a Moodle user account has been found or is created, that the user is linked to the relevant
* LTI Advantage credentials (iss, sub) and that the user account is logged in.
*
* Launch code can therefore rely on this method to get a session before doing things like calling require_login().
*
* This method supports two workflows:
* 1. Automatic account provisioning - where the complete_login() call will ALWAYS create/find a user and return to
* calling code directly. No user interaction is required.
*
* 2. Manual account provisioning - where the complete_login() call will redirect ONLY ONCE to a login page,
* where the user can decide whether to use an automatically provisioned account, or bind an existing user account.
* When the decision has been made, the relevant account is bound and the user is redirected back to $returnurl.
* Once an account has been bound via this selection process, subsequent calls to complete_login() will return to
* calling code directly. Any calling code must provide its $returnurl to support the return from the account
* selection process and must also take care to cache any JWT data appropriately, since the return will not inlude
* that information.
*
* Which workflow is chosen depends on the roles present in the JWT.
* For teachers/admins, manual provisioning will take place. These user type are likely to have existing accounts.
* For learners, automatic provisioning will take place.
*
* Migration of legacy users is supported, however, only for the Learner role (automatic provisioning). Admins and
* teachers are likely to have existing accounts and we want them to be able to select and bind these, rather than
* binding an automatically provisioned legacy account which doesn't represent their real user account.
*
* The JWT data must be verified elsewhere. The code here assumes its integrity/authenticity.
*
* @param array $launchdata the JWT data provided in the link launch.
* @param moodle_url $returnurl the local URL to return to if authentication workflows are required.
* @param int $provisioningmode the desired account provisioning mode, which controls the auth flow for unbound users.
* @param array $legacyconsumersecrets an array of secrets used by the legacy consumer if a migration claim exists.
* @throws coding_exception if the specified provisioning mode is invalid.
*/
public function complete_login(array $launchdata, moodle_url $returnurl, int $provisioningmode,
array $legacyconsumersecrets = []): void {
// The platform user is already linked with a user account.
if ($this->get_user_binding($launchdata['iss'], $launchdata['sub'])) {
// Always sync the PII, regardless of whether we're already authenticated as this user or not.
$user = $this->find_or_create_user_from_launch($launchdata, true);
if (isloggedin()) {
// If a different user is currently logged in, authenticate the linked user instead.
global $USER;
if ((int) $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
// workflows on sites publishing and consuming resources on the same site, due to the regenerated sesskey.
return;
} else {
complete_user_login($user);
return;
}
}
// The platform user is not bound to a user account, check provisioning mode now.
if (!$this->is_valid_provisioning_mode($provisioningmode)) {
throw new coding_exception('Invalid account provisioning mode provided to complete_login().');
}
switch ($provisioningmode) {
case self::PROVISIONING_MODE_AUTO_ONLY:
// Automatic provisioning - this will create/migrate a user account and log the user in.
complete_user_login($this->find_or_create_user_from_launch($launchdata, true, $legacyconsumersecrets));
break;
case self::PROVISIONING_MODE_PROMPT_NEW_EXISTING:
case self::PROVISIONING_MODE_PROMPT_EXISTING_ONLY:
default:
// Allow linking an existing account or creation of a new account via an intermediate account options page.
// Cache the relevant data and take the user to the account options page.
// Note: This mode also depends on the global auth config 'authpreventaccountcreation'. If set, only existing
// accounts can be bound in this provisioning mode.
global $SESSION;
$SESSION->auth_lti = (object)[
'launchdata' => $launchdata,
'returnurl' => $returnurl,
'provisioningmode' => $provisioningmode
];
redirect(new moodle_url('/auth/lti/login.php', [
'sesskey' => sesskey(),
]));
break;
}
}
/**
* Get a Moodle user account for the LTI user based on the user details returned by a NRPS 2 membership call.
*
* This method expects a single member structure, in array format, as defined here:
* See: https://www.imsglobal.org/spec/lti-nrps/v2p0#membership-container-media-type.
*
* This method supports migration of user accounts used in legacy launches, provided the legacy consumerkey corresponding to
* to the legacy consumer is provided. Calling code will have verified the migration claim during initial launches and should
* have the consumer key mapped to the deployment, ready to pass in.
*
* @param array $member the member data, in array format.
* @param string $iss the issuer to which the member relates.
* @param string $legacyconsumerkey optional consumer key mapped to the deployment to facilitate user migration.
* @return stdClass a Moodle user record.
*/
public function find_or_create_user_from_membership(array $member, string $iss,
string $legacyconsumerkey = ''): stdClass {
// Picture is not synced with membership-based auths because sync tasks may wish to perform slow operations like this a
// different way.
unset($member['picture']);
if ($binduser = $this->get_user_binding($iss, $member['user_id'])) {
$user = \core_user::get_user((int) $binduser);
$this->update_user_account($user, $member, $iss);
return \core_user::get_user($user->id);
} else {
if (!empty($legacyconsumerkey)) {
// Consumer key is required to attempt user migration because legacy users were identified by a
// username consisting of the consumer key and user id.
// See the legacy \enrol_lti\helper::create_username() for details.
$legacyuserid = $member['lti11_legacy_user_id'] ?? $member['user_id'];
$username = 'enrol_lti' .
sha1($legacyconsumerkey . '::' . $legacyconsumerkey . ':' . $legacyuserid);
if ($user = \core_user::get_user_by_username($username)) {
$this->create_user_binding($iss, $member['user_id'], $user->id);
$this->update_user_account($user, $member, $iss);
return \core_user::get_user($user->id);
}
}
$user = $this->create_new_account($member, $iss);
$this->update_user_account($user, $member, $iss);
return \core_user::get_user($user->id);
}
}
/**
* Get a Moodle user account for the LTI user corresponding to the user defined in a link launch.
*
* This method supports migration of user accounts used in legacy launches, provided the legacy consumer secrets corresponding
* to the legacy consumer are provided. If calling code wishes migration to be role-specific, it should check roles accordingly
* itself and pass relevant data in - as auth_plugin_lti::complete_login() does.
*
* @param array $launchdata all data in the decoded JWT including iss and sub.
* @param bool $syncpicture whether to sync the user's picture with the picture sent in the launch.
* @param array $legacyconsumersecrets all secrets found for the legacy consumer, facilitating user migration.
* @return stdClass the Moodle user who is mapped to the platform user identified in the JWT data.
*/
public function find_or_create_user_from_launch(array $launchdata, bool $syncpicture = false,
array $legacyconsumersecrets = []): stdClass {
if (!$syncpicture) {
unset($launchdata['picture']);
}
if ($binduser = $this->get_user_binding($launchdata['iss'], $launchdata['sub'])) {
$user = \core_user::get_user((int) $binduser);
$this->update_user_account($user, $launchdata, $launchdata['iss']);
return \core_user::get_user($user->id);
} else {
// Is the intent to migrate a user account used in legacy launches?
if (!empty($legacyconsumersecrets)) {
try {
// Validate the migration claim and try to find a legacy user.
$usermigrationclaim = new user_migration_claim($launchdata, $legacyconsumersecrets);
$username = 'enrol_lti' .
sha1($usermigrationclaim->get_consumer_key() . '::' .
$usermigrationclaim->get_consumer_key() .':' .$usermigrationclaim->get_user_id());
if ($user = \core_user::get_user_by_username($username)) {
$this->create_user_binding($launchdata['iss'], $launchdata['sub'], $user->id);
$this->update_user_account($user, $launchdata, $launchdata['iss']);
return \core_user::get_user($user->id);
}
} catch (Exception $e) {
// There was an issue validating the user migration claim. We don't want to fail auth entirely though.
// Rather, we want to fall back to new account creation and log the attempt.
debugging("There was a problem migrating the LTI user '{$launchdata['sub']}' on the platform " .
"'{$launchdata['iss']}'. The migration claim could not be validated. A new account will be created.");
}
}
$user = $this->create_new_account($launchdata, $launchdata['iss']);
$this->update_user_account($user, $launchdata, $launchdata['iss']);
return \core_user::get_user($user->id);
}
}
/**
* Create a binding between the LTI user, as identified by {iss, sub} tuple and the user id.
*
* @param string $iss the issuer URL identifying the platform to which to user belongs.
* @param string $sub the sub string identifying the user on the platform.
* @param int $userid the id of the Moodle user account to bind.
*/
public function create_user_binding(string $iss, string $sub, int $userid): void {
global $DB;
$timenow = time();
$issuer256 = hash('sha256', $iss);
$sub256 = hash('sha256', $sub);
if ($DB->record_exists('auth_lti_linked_login', ['issuer256' => $issuer256, 'sub256' => $sub256])) {
return;
}
$rec = [
'userid' => $userid,
'issuer' => $iss,
'issuer256' => $issuer256,
'sub' => $sub,
'sub256' => $sub256,
'timecreated' => $timenow,
'timemodified' => $timenow
];
$DB->insert_record('auth_lti_linked_login', $rec);
}
/**
* Gets the id of the linked Moodle user account for an LTI user, or null if not found.
*
* @param string $issuer the issuer to which the user belongs.
* @param string $sub the sub string identifying the user on the issuer.
* @return int|null the id of the corresponding Moodle user record, or null if not found.
*/
public function get_user_binding(string $issuer, string $sub): ?int {
global $DB;
$issuer256 = hash('sha256', $issuer);
$sub256 = hash('sha256', $sub);
try {
$binduser = $DB->get_field('auth_lti_linked_login', 'userid',
['issuer256' => $issuer256, 'sub256' => $sub256], MUST_EXIST);
} catch (\dml_exception $e) {
$binduser = null;
}
return $binduser;
}
/**
* Check whether a provisioning mode is valid or not.
*
* @param int $mode the mode
* @return bool true if valid for use, false otherwise.
*/
protected function is_valid_provisioning_mode(int $mode): bool {
$validmodes = [
self::PROVISIONING_MODE_AUTO_ONLY,
self::PROVISIONING_MODE_PROMPT_NEW_EXISTING,
self::PROVISIONING_MODE_PROMPT_EXISTING_ONLY
];
return in_array($mode, $validmodes);
}
/**
* Create a new user account based on the user data either in the launch JWT or from a membership call.
*
* @param array $userdata the user data coming from either a launch or membership service call.
* @param string $iss the issuer to which the user belongs.
* @return stdClass a complete Moodle user record.
*/
protected function create_new_account(array $userdata, string $iss): stdClass {
global $CFG;
require_once($CFG->dirroot.'/user/lib.php');
// Launches and membership calls handle the user id differently.
// Launch uses 'sub', whereas member uses 'user_id'.
$userid = !empty($userdata['sub']) ? $userdata['sub'] : $userdata['user_id'];
$user = new stdClass();
$user->username = 'enrol_lti_13_' . sha1($iss . '_' . $userid);
// If the email was stripped/not set then fill it with a default one.
// This stops the user from being redirected to edit their profile page.
$email = !empty($userdata['email']) ? $userdata['email'] :
'enrol_lti_13_' . sha1($iss . '_' . $userid) . "@example.com";
$email = \core_user::clean_field($email, 'email');
$user->email = $email;
$user->auth = 'lti';
$user->mnethostid = $CFG->mnet_localhost_id;
$user->firstname = $userdata['given_name'] ?? $userid;
$user->lastname = $userdata['family_name'] ?? $iss;
$user->password = '';
$user->confirmed = 1;
$user->id = user_create_user($user, false);
// Link this user with the LTI credentials for future use.
$this->create_user_binding($iss, $userid, $user->id);
return (object) get_complete_user_data('id', $user->id);
}
/**
* Update the personal fields of the user account, based on data present in either a launch of member sync call.
*
* @param stdClass $user the Moodle user account to update.
* @param array $userdata the user data coming from either a launch or membership service call.
* @param string $iss the issuer to which the user belongs.
*/
protected function update_user_account(stdClass $user, array $userdata, string $iss): void {
global $CFG;
require_once($CFG->dirroot.'/user/lib.php');
if ($user->auth !== 'lti') {
return;
}
// Launches and membership calls handle the user id differently.
// Launch uses 'sub', whereas member uses 'user_id'.
$platformuserid = !empty($userdata['sub']) ? $userdata['sub'] : $userdata['user_id'];
$email = !empty($userdata['email']) ? $userdata['email'] :
'enrol_lti_13_' . sha1($iss . '_' . $platformuserid) . "@example.com";
$email = \core_user::clean_field($email, 'email');
$update = [
'id' => $user->id,
'firstname' => $userdata['given_name'] ?? $platformuserid,
'lastname' => $userdata['family_name'] ?? $iss,
'email' => $email
];
user_update_user($update);
if (!empty($userdata['picture'])) {
try {
$this->update_user_picture($user->id, $userdata['picture']);
} catch (Exception $e) {
debugging("Error syncing the profile picture for user '$user->id' during LTI authentication.");
}
}
}
/**
* Update the user's picture with the image stored at $url.
*
* @param int $userid the id of the user to update.
* @param string $url the string URL where the new image can be found.
* @throws moodle_exception if there were any problems updating the picture.
*/
protected function update_user_picture(int $userid, string $url): void {
global $CFG, $DB;
require_once($CFG->libdir . '/filelib.php');
require_once($CFG->libdir . '/gdlib.php');
$fs = get_file_storage();
$context = \context_user::instance($userid, MUST_EXIST);
$fs->delete_area_files($context->id, 'user', 'newicon');
$filerecord = array(
'contextid' => $context->id,
'component' => 'user',
'filearea' => 'newicon',
'itemid' => 0,
'filepath' => '/'
);
$urlparams = array(
'calctimeout' => false,
'timeout' => 5,
'skipcertverify' => true,
'connecttimeout' => 5
);
try {
$fs->create_file_from_url($filerecord, $url, $urlparams);
} catch (\file_exception $e) {
throw new moodle_exception(get_string($e->errorcode, $e->module, $e->a));
}
$iconfile = $fs->get_area_files($context->id, 'user', 'newicon', false, 'itemid', false);
// There should only be one.
$iconfile = reset($iconfile);
// Something went wrong while creating temp file - remove the uploaded file.
if (!$iconfile = $iconfile->copy_content_to_temp()) {
$fs->delete_area_files($context->id, 'user', 'newicon');
throw new moodle_exception('There was a problem copying the profile picture to temp.');
}
// Copy file to temporary location and the send it for processing icon.
$newpicture = (int) process_new_icon($context, 'user', 'icon', 0, $iconfile);
// Delete temporary file.
@unlink($iconfile);
// Remove uploaded file.
$fs->delete_area_files($context->id, 'user', 'newicon');
// Set the user's picture.
$DB->set_field('user', 'picture', $newpicture, array('id' => $userid));
}
}

View File

@ -0,0 +1,150 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
namespace auth_lti\local\ltiadvantage\entity;
/**
* A simplified representation of a 'https://purl.imsglobal.org/spec/lti/claim/lti1p1' migration claim.
*
* This serves the purpose of migrating a legacy user account only. Claim properties that do not relate to user migration are not
* included or handled by this representation.
*
* See https://www.imsglobal.org/spec/lti/v1p3/migr#lti-1-1-migration-claim
*
* @package auth_lti
* @copyright 2021 Jake Dallimore <jrhdallimore@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class user_migration_claim {
/** @var string the LTI 1.1 consumer key */
private $consumerkey;
/** @var string the LTI 1.1 user identifier.
* This is only included in the claim if it differs to the value included in the LTI 1.3 'sub' claim.
* If not included, the value will be taken from 'sub'.
*/
private $userid;
/**
* The migration_claim constructor.
*
* The signature of a migration claim must be verifiable. To achieve this, the constructor takes a list of secrets
* corresponding to the 'oauth_consumer_key' provided in the 'https://purl.imsglobal.org/spec/lti/claim/lti1p1'
* claim. How these secrets are determined is not the responsibility of this class. The constructor assumes these
* correspond.
*
* @param array $jwt the array of claim data, as received in a resource link launch JWT.
* @param array $consumersecrets a list of consumer secrets for the consumerkey included in the migration claim.
* @throws \coding_exception if the claim data is invalid.
*/
public function __construct(array $jwt, array $consumersecrets) {
// Can't get a claim instance without the claim data.
if (empty($jwt['https://purl.imsglobal.org/spec/lti/claim/lti1p1'])) {
throw new \coding_exception("Missing the 'https://purl.imsglobal.org/spec/lti/claim/lti1p1' JWT claim");
}
$claim = $jwt['https://purl.imsglobal.org/spec/lti/claim/lti1p1'];
// The oauth_consumer_key property MUST be sent.
// See: https://www.imsglobal.org/spec/lti/v1p3/migr#oauth_consumer_key.
if (empty($claim['oauth_consumer_key'])) {
throw new \coding_exception("Missing 'oauth_consumer_key' property in lti1p1 migration claim.");
}
// The oauth_consumer_key_sign property MAY be sent.
// For user migration to take place, however, this is deemed a required property since Moodle identified its
// legacy users through a combination of consumerkey and userid.
// See: https://www.imsglobal.org/spec/lti/v1p3/migr#oauth_consumer_key_sign.
if (empty($claim['oauth_consumer_key_sign'])) {
throw new \coding_exception("Missing 'oauth_consumer_key_sign' property in lti1p1 migration claim.");
}
if (!$this->verify_signature(
$claim['oauth_consumer_key'],
$claim['oauth_consumer_key_sign'],
$jwt['https://purl.imsglobal.org/spec/lti/claim/deployment_id'],
$jwt['iss'],
$jwt['aud'],
$jwt['exp'],
$jwt['nonce'],
$consumersecrets
)) {
throw new \coding_exception("Invalid 'oauth_consumer_key_sign' signature in lti1p1 claim.");
}
$this->consumerkey = $claim['oauth_consumer_key'];
$this->userid = $claim['user_id'] ?? $jwt['sub'];
}
/**
* Verify the claim signature by recalculating it using the launch data and cross-checking consumer secrets.
*
* @param string $consumerkey the LTI 1.1 consumer key.
* @param string $signature a signature of the LTI 1.1 consumer key and associated launch data.
* @param string $deploymentid the deployment id included in the launch.
* @param string $platform the platform included in the launch.
* @param string $clientid the client id included in the launch.
* @param string $exp the exp included in the launch.
* @param string $nonce the nonce included in the launch.
* @param array $consumersecrets the list of consumer secrets used with the given $consumerkey param
* @return bool true if the signature was verified, false otherwise.
*/
private function verify_signature(string $consumerkey, string $signature, string $deploymentid, string $platform,
string $clientid, string $exp, string $nonce, array $consumersecrets): bool {
$base = [
$consumerkey,
$deploymentid,
$platform,
$clientid,
$exp,
$nonce
];
$basestring = implode('&', $base);
// Legacy enrol_lti code permits tools to share a consumer key but use different secrets. This results in
// potentially many secrets per mapped tool consumer. As such, when generating the migration claim it's
// impossible to know which secret the platform will use to sign the consumer key. The consumer key in the
// migration claim is thus verified by trying all known secrets for the consumer, until either a match is found
// or no signatures match.
foreach ($consumersecrets as $consumersecret) {
$calculatedsignature = base64_encode(hash_hmac('sha256', $basestring, $consumersecret));
if ($signature === $calculatedsignature) {
return true;
}
}
return false;
}
/**
* Return the consumer key stored in the claim.
*
* @return string the consumer key included in the claim.
*/
public function get_consumer_key(): string {
return $this->consumerkey;
}
/**
* Return the LTI 1.1 user id stored in the claim.
*
* @return string the user id, or null if not provided in the claim.
*/
public function get_user_id(): string {
return $this->userid;
}
}

View File

@ -0,0 +1,87 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
namespace auth_lti\output;
use core\output\notification;
/**
* Renderer class for auth_lti.
*
* @package auth_lti
* @copyright 2021 Jake Dallimore <jrhdallimore@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class renderer extends \plugin_renderer_base {
/**
* Render the account options view, displayed to instructors on first launch if no account binding exists.
*
* @param int $provisioningmode the desired account provisioning mode, see auth_plugin_lti constants for details.
* @return string the html.
*/
public function render_account_binding_options_page(int $provisioningmode): string {
$formaction = new \moodle_url('/auth/lti/login.php');
$notification = new notification(get_string('firstlaunchnotice', 'auth_lti'), \core\notification::INFO, false);
$noauthnotice = new notification(get_string('firstlaunchnoauthnotice', 'auth_lti', get_docs_url('Publish_as_LTI_tool')),
\core\notification::WARNING, false);
$cancreateaccounts = !get_config('moodle', 'authpreventaccountcreation');
if ($provisioningmode == \auth_plugin_lti::PROVISIONING_MODE_PROMPT_EXISTING_ONLY) {
$cancreateaccounts = false;
}
$accountinfo = ['isloggedin' => isloggedin()];
if (isloggedin()) {
global $USER;
$accountinfo = array_merge($accountinfo, [
'firstname' => $USER->firstname,
'lastname' => $USER->lastname,
'email' => $USER->email,
'picturehtml' => $this->output->user_picture($USER, ['size' => 35, 'class' => 'round']),
]);
}
$context = [
'info' => $notification->export_for_template($this),
'formaction' => $formaction->out(),
'sesskey' => sesskey(),
'accountinfo' => $accountinfo,
'cancreateaccounts' => $cancreateaccounts,
'noauthnotice' => $noauthnotice->export_for_template($this)
];
return parent::render_from_template('auth_lti/local/ltiadvantage/login', $context);
}
/**
* Render the page displayed when the account binding is complete, letting the user continue to the launch.
*
* Callers can provide different messages depending on which type of binding took place. For example, a newly
* provisioned account may require a slightly different message to an existing account being linked.
*
* The return URL is the page the user will be taken back to when they click 'Continue'. This is likely the launch
* or deeplink launch endpoint but could be any calling code in LTI which wants to use the account binding workflow.
*
* @param notification $notification the notification containing the message describing the binding success.
* @param \moodle_url $returnurl the URL to return to when the user clicks continue on the rendered page.
* @return string the rendered HTML
*/
public function render_account_binding_complete(notification $notification, \moodle_url $returnurl): string {
$context = (object) [
'notification' => $notification->export_for_template($this),
'returnurl' => $returnurl->out()
];
return parent::render_from_template('auth_lti/local/ltiadvantage/account_binding_complete', $context);
}
}

View File

@ -13,29 +13,172 @@
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Privacy Subsystem implementation for auth_lti.
*
* @package auth_lti
* @copyright 2018 Carlos Escobedo <carlos@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace auth_lti\privacy;
defined('MOODLE_INTERNAL') || die();
use core_privacy\local\metadata\collection;
use core_privacy\local\request\approved_contextlist;
use core_privacy\local\request\approved_userlist;
use core_privacy\local\request\context;
use core_privacy\local\request\contextlist;
use core_privacy\local\request\transform;
use core_privacy\local\request\userlist;
use core_privacy\local\request\writer;
/**
* Privacy Subsystem for auth_lti implementing null_provider.
*
* @copyright 2018 Carlos Escobedo <carlos@moodle.com>
* @package auth_lti
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class provider implements \core_privacy\local\metadata\null_provider {
class provider implements
\core_privacy\local\metadata\provider,
\core_privacy\local\request\plugin\provider,
\core_privacy\local\request\core_userlist_provider {
/**
* Get the language string identifier with the component's language
* file to explain why this plugin stores no data.
* Get all contexts contain user information for the given user.
*
* @return string
* @param int $userid the id of the user.
* @return contextlist the list of contexts containing user information.
*/
public static function get_reason() : string {
return 'privacy:metadata';
public static function get_contexts_for_userid(int $userid): contextlist {
$sql = "SELECT ctx.id
FROM {auth_lti_linked_login} ll
JOIN {context} ctx ON ctx.instanceid = ll.userid AND ctx.contextlevel = :contextlevel
WHERE ll.userid = :userid";
$params = ['userid' => $userid, 'contextlevel' => CONTEXT_USER];
$contextlist = new contextlist();
$contextlist->add_from_sql($sql, $params);
return $contextlist;
}
}
/**
* Export all user data for the user in the identified contexts.
*
* @param approved_contextlist $contextlist the list of approved contexts for the user.
*/
public static function export_user_data(approved_contextlist $contextlist) {
global $DB;
$user = $contextlist->get_user();
$linkedlogins = $DB->get_records('auth_lti_linked_login', ['userid' => $user->id], '',
'issuer, issuer256, sub, sub256, timecreated, timemodified');
foreach ($linkedlogins as $login) {
$data = (object)[
'timecreated' => transform::datetime($login->timecreated),
'timemodified' => transform::datetime($login->timemodified),
'issuer' => $login->issuer,
'issuer256' => $login->issuer256,
'sub' => $login->sub,
'sub256' => $login->sub256
];
writer::with_context(\context_user::instance($user->id))->export_data([
get_string('privacy:metadata:auth_lti', 'auth_lti'), $login->issuer
], $data);
}
}
/**
* Delete all user data for this context.
*
* @param \context $context The context to delete data for.
*/
public static function delete_data_for_all_users_in_context(\context $context) {
if ($context->contextlevel != CONTEXT_USER) {
return;
}
static::delete_user_data($context->instanceid);
}
/**
* Delete user data in the list of given contexts.
*
* @param approved_contextlist $contextlist the list of contexts.
*/
public static function delete_data_for_user(approved_contextlist $contextlist) {
if (empty($contextlist->count())) {
return;
}
$userid = $contextlist->get_user()->id;
foreach ($contextlist->get_contexts() as $context) {
if ($context->contextlevel != CONTEXT_USER) {
continue;
}
if ($context->instanceid == $userid) {
static::delete_user_data($context->instanceid);
}
}
}
/**
* Get the list of users within a specific 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) {
$context = $userlist->get_context();
if (!$context instanceof \context_user) {
return;
}
$sql = "SELECT userid
FROM {auth_lti_linked_login}
WHERE userid = ?";
$params = [$context->instanceid];
$userlist->add_from_sql('userid', $sql, $params);
}
/**
* 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) {
$context = $userlist->get_context();
if ($context instanceof \context_user) {
static::delete_user_data($context->instanceid);
}
}
/**
* Description of the metadata stored for users in auth_lti.
*
* @param collection $collection a collection to add to.
* @return collection the collection, with relevant metadata descriptions for auth_lti added.
*/
public static function get_metadata(collection $collection): collection {
$authfields = [
'userid' => 'privacy:metadata:auth_lti:userid',
'issuer' => 'privacy:metadata:auth_lti:issuer',
'issuer256' => 'privacy:metadata:auth_lti:issuer256',
'sub' => 'privacy:metadata:auth_lti:sub',
'sub256' => 'privacy:metadata:auth_lti:sub256',
'timecreated' => 'privacy:metadata:auth_lti:timecreated',
'timemodified' => 'privacy:metadata:auth_lti:timemodified'
];
$collection->add_database_table('auth_lti_linked_login', $authfields, 'privacy:metadata:auth_lti:tableexplanation');
$collection->link_subsystem('core_auth', 'privacy:metadata:auth_lti:authsubsystem');
return $collection;
}
/**
* Delete user data for the user.
*
* @param int $userid The id of the user.
*/
protected static function delete_user_data(int $userid) {
global $DB;
// Because we only use user contexts the instance ID is the user ID.
$DB->delete_records('auth_lti_linked_login', ['userid' => $userid]);
}
}

25
auth/lti/db/install.xml Normal file
View File

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8" ?>
<XMLDB PATH="auth/lti/db" VERSION="20211005" COMMENT="XMLDB file for Moodle auth/lti"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="../../../lib/xmldb/xmldb.xsd"
>
<TABLES>
<TABLE NAME="auth_lti_linked_login" COMMENT="Accounts linked to a users Moodle account.">
<FIELDS>
<FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
<FIELD NAME="userid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="The user account the LTI user is linked to."/>
<FIELD NAME="issuer" TYPE="text" NOTNULL="true" SEQUENCE="false"/>
<FIELD NAME="issuer256" TYPE="char" LENGTH="64" NOTNULL="true" SEQUENCE="false" COMMENT="SHA256 hash of the issuer from which the platform user originates."/>
<FIELD NAME="sub" TYPE="char" LENGTH="255" NOTNULL="true" SEQUENCE="false"/>
<FIELD NAME="sub256" TYPE="char" LENGTH="64" NOTNULL="true" SEQUENCE="false" COMMENT="SHA256 hash of the subject identifying the user for the issuer."/>
<FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
<FIELD NAME="timemodified" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
</FIELDS>
<KEYS>
<KEY NAME="primary" TYPE="primary" FIELDS="id"/>
<KEY NAME="userid_key" TYPE="foreign" FIELDS="userid" REFTABLE="user" REFFIELDS="id"/>
<KEY NAME="unique_key" TYPE="unique" FIELDS="userid, issuer256, sub256"/>
</KEYS>
</TABLE>
</TABLES>
</XMLDB>

67
auth/lti/db/upgrade.php Normal file
View File

@ -0,0 +1,67 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
/**
* LTI authentication plugin upgrade code
*
* @package auth_lti
* @copyright 2021 Jake Dallimore <jrhdallimore@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
/**
* Upgrade function.
*
* @param int $oldversion the version we are upgrading from.
* @return bool result.
*/
function xmldb_auth_lti_upgrade($oldversion) {
global $DB;
$dbman = $DB->get_manager();
if ($oldversion < 2021100500) {
// Define table auth_lti_linked_login to be created.
$table = new xmldb_table('auth_lti_linked_login');
// Adding fields to table auth_lti_linked_login.
$table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null);
$table->add_field('userid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
$table->add_field('issuer', XMLDB_TYPE_TEXT, null, null, XMLDB_NOTNULL, null, null);
$table->add_field('issuer256', XMLDB_TYPE_CHAR, '64', null, XMLDB_NOTNULL, null, null);
$table->add_field('sub', XMLDB_TYPE_CHAR, '255', null, XMLDB_NOTNULL, null, null);
$table->add_field('sub256', XMLDB_TYPE_CHAR, '64', null, XMLDB_NOTNULL, null, null);
$table->add_field('timecreated', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
$table->add_field('timemodified', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
// Adding keys to table auth_lti_linked_login.
$table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']);
$table->add_key('userid_key', XMLDB_KEY_FOREIGN, ['userid'], 'user', ['id']);
$table->add_key('unique_key', XMLDB_KEY_UNIQUE, ['userid, issuer256, sub256']);
// Conditionally launch create table for auth_lti_linked_login.
if (!$dbman->table_exists($table)) {
$dbman->create_table($table);
}
// Auth LTI savepoint reached.
upgrade_plugin_savepoint(true, 2021100500, 'auth', 'lti');
}
return true;
}

View File

@ -22,6 +22,36 @@
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
$string['accountcreatedsuccess'] = 'Your account has been created and is now ready to use.';
$string['accountlinkedsuccess'] = 'Your existing account has been successfully linked.';
$string['auth_ltidescription'] = 'The LTI authentication plugin, together with the \'Publish as LTI tool\' enrolment plugin, allows remote users to access selected courses and activities. In other words, Moodle functions as an LTI tool provider.';
$string['cannotcreateaccounts'] = 'Account creation is currently prohibited on this site.';
$string['createaccount'] = 'Create account';
$string['createaccountforme'] = 'Create an account for me';
$string['createnewaccount'] = 'I\'d like to create a new account';
$string['currentlyloggedinas'] = 'You are currently logged in as:';
$string['firstlaunchnotice'] = 'It looks like this is your first time here. Please select from one of the following account options.';
$string['firstlaunchnoauthnotice'] = 'To link your existing account you must be logged in to the site. Please log in to the site in a new tab/window and then relaunch the tool here. For further information, see the documentation <a href="{$a}" target="_blank">Publish as LTI tool</a>.';
$string['getstartedwithnewaccount'] = 'Get started with a new account';
$string['haveexistingaccount'] = 'I have an existing account';
$string['linkthisaccount'] = 'Link this account';
$string['mustbeloggedin'] = 'You need to be logged in to your existing account';
$string['pluginname'] = 'LTI';
$string['privacy:metadata:auth_lti'] = 'LTI authentication';
$string['privacy:metadata:auth_lti:authsubsystem'] = 'This plugin is connected to the authentication subsystem.';
$string['privacy:metadata:auth_lti:issuer'] = 'The issuer URL identifying the platform to which the linked user belongs.';
$string['privacy:metadata:auth_lti:issuer256'] = 'The SHA256 hash of the issuer URL.';
$string['privacy:metadata:auth_lti:sub'] = 'The subject string identifying the user on the issuer.';
$string['privacy:metadata:auth_lti:sub256'] = 'The SHA256 hash of the subject string identifying the user on the issuer.';
$string['privacy:metadata:auth_lti:tableexplanation'] = 'LTI accounts linked to a user\'s Moodle account.';
$string['privacy:metadata:auth_lti:timecreated'] = 'The timestamp when the user account was linked to the LTI login.';
$string['privacy:metadata:auth_lti:timemodified'] = 'The timestamp when this record was modified.';
$string['privacy:metadata:auth_lti:userid'] = 'The ID of the user account which the LTI login is linked to';
$string['provisioningmodeauto'] = 'New accounts only (automatic)';
$string['provisioningmodenewexisting'] = 'Existing and new accounts (prompt)';
$string['provisioningmodeexistingonly'] = 'Existing accounts only (prompt)';
$string['useexistingaccount'] = 'Use existing account';
$string['welcome'] = 'Welcome!';
// Deprecated since Moodle 4.0.
$string['privacy:metadata'] = 'The LTI authentication plugin does not store any personal data.';

View File

@ -0,0 +1 @@
privacy:metadata,auth_lti

35
auth/lti/lib.php Normal file
View File

@ -0,0 +1,35 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
/**
* Callbacks for auth_lti.
*
* @package auth_lti
* @copyright 2021 Jake Dallimore <jrhdallimore@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
/**
* Callback to remove linked logins for deleted users.
*
* @param stdClass $user the user being deleted.
*/
function auth_lti_pre_user_delete($user) {
global $DB;
$DB->delete_records('auth_lti_linked_login', ['userid' => $user->id]);
}

124
auth/lti/login.php Normal file
View File

@ -0,0 +1,124 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
/**
* Page allowing a platform user, identified by their {iss, sub} tuple, to be bound to a new or existing Moodle account.
*
* This is an LTI Advantage specific login feature.
*
* The auth flow defined in auth_lti\auth::complete_login() redirects here when a launching user does not have an
* account binding yet. This page prompts the user to select between:
* a) An auto provisioned account.
* An account with auth type 'lti' is created for the user. This account is bound to the launch credentials.
* Or
* b) Use an existing account
* The standard Moodle auth flow is leveraged to get an existing user account. This account is then bound to the launch
* credentials.
*
* @package auth_lti
* @copyright 2021 Jake Dallimore <jrhdallimore@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
use core\event\user_login_failed;
use core\output\notification;
require_once(__DIR__ . '/../../config.php');
global $OUTPUT, $PAGE, $SESSION;
// Form fields dealing with the user's choice about account types (new, existing).
$newaccount = optional_param('new_account', false, PARAM_BOOL);
$existingaccount = optional_param('existing_account', false, PARAM_BOOL);
if (empty($SESSION->auth_lti) || empty($SESSION->auth_lti->launchdata)) {
throw new coding_exception('Missing LTI launch credentials.');
}
if (empty($SESSION->auth_lti->returnurl)) {
throw new coding_exception('Missing return URL.');
}
if ($newaccount) {
require_sesskey();
$launchdata = $SESSION->auth_lti->launchdata;
$returnurl = $SESSION->auth_lti->returnurl;
unset($SESSION->auth_lti);
if (!empty($CFG->authpreventaccountcreation)) {
// If 'authpreventaccountcreation' is enabled, the option to create a new account isn't presented to users in the form.
// This just ensures no action is taken were the 'newaccount' value to be present in the submitted data.
// Trigger login failed event.
$failurereason = AUTH_LOGIN_UNAUTHORISED;
$event = user_login_failed::create(['other' => ['reason' => $failurereason]]);
$event->trigger();
// Site settings prevent creating new accounts.
$errormsg = get_string('cannotcreateaccounts', 'auth_lti');
$SESSION->loginerrormsg = $errormsg;
redirect(new moodle_url('/login/index.php'));
} else {
// Create a new account and link it, logging the user in.
$auth = get_auth_plugin('lti');
$newuser = $auth->find_or_create_user_from_launch($launchdata, true);
complete_user_login($newuser);
$PAGE->set_context(context_system::instance());
$PAGE->set_url(new moodle_url('/auth/lti/login.php'));
$PAGE->set_pagelayout('popup');
$renderer = $PAGE->get_renderer('auth_lti');
echo $OUTPUT->header();
echo $renderer->render_account_binding_complete(
new notification(get_string('accountcreatedsuccess', 'auth_lti'), notification::NOTIFY_SUCCESS, false),
$returnurl
);
echo $OUTPUT->footer();
exit();
}
} else if ($existingaccount) {
// Only when authenticated can an account be bound, allowing the user to continue to the original launch action.
require_login(null, false);
require_sesskey();
$launchdata = $SESSION->auth_lti->launchdata;
$returnurl = $SESSION->auth_lti->returnurl;
unset($SESSION->auth_lti);
global $USER;
$auth = get_auth_plugin('lti');
$auth->create_user_binding($launchdata['iss'], $launchdata['sub'], $USER->id);
$PAGE->set_context(context_system::instance());
$PAGE->set_url(new moodle_url('/auth/lti/login.php'));
$PAGE->set_pagelayout('popup');
$renderer = $PAGE->get_renderer('auth_lti');
echo $OUTPUT->header();
echo $renderer->render_account_binding_complete(
new notification(get_string('accountlinkedsuccess', 'auth_lti'), notification::NOTIFY_SUCCESS, false),
$returnurl
);
echo $OUTPUT->footer();
exit();
}
// Render the relevant account provisioning page, based on the provisioningmode set in the calling code.
$PAGE->set_context(context_system::instance());
$PAGE->set_url(new moodle_url('/auth/lti/login.php'));
$PAGE->set_pagelayout('popup');
$renderer = $PAGE->get_renderer('auth_lti');
echo $OUTPUT->header();
require_once($CFG->dirroot . '/auth/lti/auth.php');
echo $renderer->render_account_binding_options_page($SESSION->auth_lti->provisioningmode);
echo $OUTPUT->footer();

View File

@ -0,0 +1,53 @@
{{!
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 <http://www.gnu.org/licenses/>.
}}
{{!
@template auth_lti/account_binding_complete
Template which displays the confirmation after the user has either signed in and has their account linked, or
has had an account automatically provisioned and linked.
Classes required for JS:
* none
Data attributes required for JS:
* none
Context variables required for this template:
* notification
* returnurl
Example context (json):
{
"notification": {
"message": "Your account was successfully linked!",
"extraclasses": "",
"announce": true,
"closebutton": false,
"issuccess": true,
"isinfo": false,
"iswarning": false,
"iserror": false
},
"returnurl": "https://your.site/enrol/lti/launch_deeplink.php?id=123abc"
}
}}
<div id="lti_adv_account_binding_complete">
{{#notification}}
{{> core/notification}}
{{/notification}}
<a class="btn btn-primary" href="{{returnurl}}">{{#str}}continue, core{{/str}}</a>
</div>

View File

@ -0,0 +1,122 @@
{{!
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 <http://www.gnu.org/licenses/>.
}}
{{!
@template auth_lti/login
Template which displays a choice screen for instructors on first launch, allowing them to select whether to use an
existing account in the tool, or to auto provision a new one.
Classes required for JS:
* none
Data attributes required for JS:
* none
Context variables required for this template:
* formaction
* sesskey
* info - a notification describing the first launch options
* cancreateaccounts - whether or not the user is allowed to create auth_lti accounts
* accountinfo - information about the user, importantly whether they are logged in or not.
* noauthnotice - a notification telling the user they must be authenticated to link accounts. Only relevant when not logged in.
Example context (json):
{
"formaction": "auth/lti/login.php",
"sesskey": "1a2b3c4dfg",
"info": {
"message": "Looks like this is your first time here...",
"extraclasses": "",
"announce": false,
"closebutton": false,
"issuccess": true
},
"cancreateaccounts": true,
"accountinfo": {
"isloggedin": true,
"firstname": "John",
"lastname": "Smith",
"email": "john@example.com",
"picturehtml": "<img src=\"http://site.example.com/pluginfile.php/5/user/icon/boost/f2?rev=99\" class=\"round\" alt=\"\" width=\"35\" height=\"35\">"
},
"noauthnotice": {
"message": "To link your existing account you must be logged in to the site...",
"extraclasses": "",
"announce": false,
"closebutton": false,
"iswarning": true
}
}
}}
<div id="lti_adv_account_binding_options">
<form action="{{formaction}}" method="POST">
<input type="hidden" name="sesskey" value="{{sesskey}}">
<div class="container-fluid">
<h2>{{#str}} welcome, auth_lti {{/str}}</h2>
{{#info}}
{{> core/notification}}
{{/info}}
<div class="row">
<div class="{{#cancreateaccounts}}col-sm-6{{/cancreateaccounts}}{{^cancreateaccounts}}col-sm-12{{/cancreateaccounts}} d-flex">
<div class="card w-100">
<div class="card-header">
{{#str}} haveexistingaccount, auth_lti {{/str}}
</div>
<div class="card-body text-center d-flex flex-column">
<i class="fa fa-user-circle-o fa-2x link"></i>
<h4 class="card-title">{{#str}} useexistingaccount, auth_lti {{/str}}</h4>
{{#accountinfo}}
{{#isloggedin}}
<p class="card-text mt-2">
<span class="text-muted">
{{#str}} currentlyloggedinas, auth_lti {{/str}}
</span>
<br>
{{{picturehtml}}}
{{firstname}} {{lastname}} ({{email}})
</p>
<input type="submit" class="btn btn-primary mt-auto" name="existing_account" value="{{#str}} linkthisaccount, auth_lti {{/str}}">
{{/isloggedin}}
{{^isloggedin}}
<p class="card-text text-muted">{{#str}} mustbeloggedin, auth_lti {{/str}}</p>
{{#noauthnotice}}
{{> core/notification}}
{{/noauthnotice}}
{{/isloggedin}}
{{/accountinfo}}
</div>
</div>
</div>
{{#cancreateaccounts}}
<div class="col-sm-6 d-flex">
<div class="card w-100">
<div class="card-header">
{{#str}} createnewaccount, auth_lti {{/str}}
</div>
<div class="card-body text-center d-flex flex-column">
<i class="fa fa-user-plus fa-2x"></i>
<h4 class="card-title">{{#str}} createaccount, auth_lti {{/str}}</h4>
<p class="card-text text-muted">{{#str}} getstartedwithnewaccount, auth_lti {{/str}}</p>
<input type="submit" class="btn btn-secondary mt-auto" name="new_account" value="{{#str}} createaccountforme, auth_lti {{/str}}">
</div>
</div>
</div>
{{/cancreateaccounts}}
</div>
</div>
</form>
</div>

1129
auth/lti/tests/auth_test.php Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,240 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
namespace auth_lti\privacy;
use core_privacy\local\request\approved_contextlist;
use core_privacy\local\request\userlist;
use core_privacy\local\request\writer;
use core_privacy\tests\provider_testcase;
use core_privacy\local\request\approved_userlist;
/**
* Test for the auth_lti privacy provider.
*
* @package auth_lti
* @copyright 2021 Jake Dallimore <jrhdallimore@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @coversDefaultClass \auth_lti\privacy\provider
*/
class provider_test extends provider_testcase {
/**
* Set up method.
*/
public function setUp(): void {
$this->resetAfterTest();
$this->setAdminUser();
}
/**
* Check that a user context is returned if there is any user data for this user.
*
* @covers ::get_contexts_for_userid
*/
public function test_get_contexts_for_userid() {
$user = $this->getDataGenerator()->create_user();
$this->assertEmpty(provider::get_contexts_for_userid($user->id));
$auth = get_auth_plugin('lti');
$auth->create_user_binding('https://lms.example.com', 'abc123', $user->id);
$contextlist = provider::get_contexts_for_userid($user->id);
// Check that we only get back one context.
$this->assertCount(1, $contextlist);
// Check that a context is returned is the expected.
$usercontext = \context_user::instance($user->id);
$this->assertEquals($usercontext->id, $contextlist->get_contextids()[0]);
}
/**
* Test that user data is exported correctly.
*
* @covers ::export_user_data
*/
public function test_export_user_data() {
$user = $this->getDataGenerator()->create_user();
$auth = get_auth_plugin('lti');
$auth->create_user_binding('https://lms.example.com', 'abc123', $user->id);
$usercontext = \context_user::instance($user->id);
$writer = writer::with_context($usercontext);
$this->assertFalse($writer->has_any_data());
$approvedlist = new approved_contextlist($user, 'auth_lti', [$usercontext->id]);
provider::export_user_data($approvedlist);
$data = $writer->get_data([get_string('privacy:metadata:auth_lti', 'auth_lti'), 'https://lms.example.com']);
$this->assertEquals('https://lms.example.com', $data->issuer);
$this->assertEquals(hash('sha256', 'https://lms.example.com'), $data->issuer256);
$this->assertEquals('abc123', $data->sub);
$this->assertEquals(hash('sha256', 'abc123'), $data->sub256);
}
/**
* Test deleting all user data for a specific context.
*
* @covers ::delete_data_for_all_users_in_context
*/
public function test_delete_data_for_all_users_in_context() {
global $DB;
$auth = get_auth_plugin('lti');
$user1 = $this->getDataGenerator()->create_user();
$auth->create_user_binding('https://lms.example.com', 'abc123', $user1->id);
$user1context = \context_user::instance($user1->id);
$user2 = $this->getDataGenerator()->create_user();
$auth->create_user_binding('https://lms.example.com', 'def456', $user2->id);
// Verify there are two linked logins.
$ltiaccounts = $DB->get_records('auth_lti_linked_login');
$this->assertCount(2, $ltiaccounts);
// Delete everything for the first user context.
provider::delete_data_for_all_users_in_context($user1context);
// Get all LTI linked accounts match with user1.
$ltiaccounts = $DB->get_records('auth_lti_linked_login', ['userid' => $user1->id]);
$this->assertCount(0, $ltiaccounts);
// Verify there is now only one linked login.
$ltiaccounts = $DB->get_records('auth_lti_linked_login');
$this->assertCount(1, $ltiaccounts);
}
/**
* This should work identical to the above test.
*
* @covers ::delete_data_for_user
*/
public function test_delete_data_for_user() {
global $DB;
$auth = get_auth_plugin('lti');
$user1 = $this->getDataGenerator()->create_user();
$auth->create_user_binding('https://lms.example.com', 'abc123', $user1->id);
$user1context = \context_user::instance($user1->id);
$user2 = $this->getDataGenerator()->create_user();
$auth->create_user_binding('https://lms.example.com', 'def456', $user2->id);
// Verify there are two linked logins.
$ltiaccounts = $DB->get_records('auth_lti_linked_login');
$this->assertCount(2, $ltiaccounts);
// Delete everything for the first user.
$approvedlist = new approved_contextlist($user1, 'auth_lti', [$user1context->id]);
provider::delete_data_for_user($approvedlist);
// Get all LTI accounts linked with user1.
$ltiaccounts = $DB->get_records('auth_lti_linked_login', ['userid' => $user1->id]);
$this->assertCount(0, $ltiaccounts);
// Verify there is only one linked login now.
$ltiaccounts = $DB->get_records('auth_lti_linked_login', array());
$this->assertCount(1, $ltiaccounts);
}
/**
* Test that only users with a user context are fetched.
*
* @covers ::get_users_in_context
*/
public function test_get_users_in_context() {
$auth = get_auth_plugin('lti');
$component = 'auth_lti';
$user = $this->getDataGenerator()->create_user();
$usercontext = \context_user::instance($user->id);
// The list of users should not return anything yet (no linked login yet).
$userlist = new userlist($usercontext, $component);
provider::get_users_in_context($userlist);
$this->assertCount(0, $userlist);
$auth->create_user_binding('https://lms.example.com', 'abc123', $user->id);
// The list of users for user context should return the user.
provider::get_users_in_context($userlist);
$this->assertCount(1, $userlist);
$expected = [$user->id];
$actual = $userlist->get_userids();
$this->assertEquals($expected, $actual);
// The list of users for system context should not return any users.
$systemcontext = \context_system::instance();
$userlist = new userlist($systemcontext, $component);
provider::get_users_in_context($userlist);
$this->assertCount(0, $userlist);
}
/**
* Test that data for users in approved userlist is deleted.
*
* @covers ::delete_data_for_users
*/
public function test_delete_data_for_users() {
$auth = get_auth_plugin('lti');
$component = 'auth_lti';
$user1 = $this->getDataGenerator()->create_user();
$usercontext1 = \context_user::instance($user1->id);
$user2 = $this->getDataGenerator()->create_user();
$usercontext2 = \context_user::instance($user2->id);
$auth->create_user_binding('https://lms.example.com', 'abc123', $user1->id);
$auth->create_user_binding('https://lms.example.com', 'def456', $user2->id);
// The list of users for usercontext1 should return user1.
$userlist1 = new userlist($usercontext1, $component);
provider::get_users_in_context($userlist1);
$this->assertCount(1, $userlist1);
$expected = [$user1->id];
$actual = $userlist1->get_userids();
$this->assertEquals($expected, $actual);
// The list of users for usercontext2 should return user2.
$userlist2 = new userlist($usercontext2, $component);
provider::get_users_in_context($userlist2);
$this->assertCount(1, $userlist2);
$expected = [$user2->id];
$actual = $userlist2->get_userids();
$this->assertEquals($expected, $actual);
// Add userlist1 to the approved user list.
$approvedlist = new approved_userlist($usercontext1, $component, $userlist1->get_userids());
// Delete user data using delete_data_for_user for usercontext1.
provider::delete_data_for_users($approvedlist);
// Re-fetch users in usercontext1 - The user list should now be empty.
$userlist1 = new userlist($usercontext1, $component);
provider::get_users_in_context($userlist1);
$this->assertCount(0, $userlist1);
// Re-fetch users in usercontext2 - The user list should not be empty (user2).
$userlist2 = new userlist($usercontext2, $component);
provider::get_users_in_context($userlist2);
$this->assertCount(1, $userlist2);
// User data should be only removed in the user context.
$systemcontext = \context_system::instance();
// Add userlist2 to the approved user list in the system context.
$approvedlist = new approved_userlist($systemcontext, $component, $userlist2->get_userids());
// Delete user1 data using delete_data_for_user.
provider::delete_data_for_users($approvedlist);
// Re-fetch users in usercontext2 - The user list should not be empty (user2).
$userlist2 = new userlist($usercontext2, $component);
provider::get_users_in_context($userlist2);
$this->assertCount(1, $userlist2);
}
}

View File

@ -24,6 +24,6 @@
defined('MOODLE_INTERNAL') || die();
$plugin->version = 2021052500; // The current plugin version (Date: YYYYMMDDXX).
$plugin->version = 2021100500; // The current plugin version (Date: YYYYMMDDXX).
$plugin->requires = 2021052500; // Requires this Moodle version.
$plugin->component = 'auth_lti'; // Full name of the plugin (used for diagnostics).