diff --git a/inginious/frontend/app.py b/inginious/frontend/app.py index fd565f788..f433acdae 100644 --- a/inginious/frontend/app.py +++ b/inginious/frontend/app.py @@ -16,6 +16,7 @@ from binascii import hexlify from pymongo import MongoClient from werkzeug.exceptions import InternalServerError +from flask_caching import Cache import inginious.frontend.pages.course_admin.utils as course_admin_utils import inginious.frontend.pages.taskset_admin.utils as taskset_admin_utils @@ -87,6 +88,8 @@ def _put_configuration_defaults(config): config["SESSION_USE_SIGNER"] = True config["PERMANENT_SESSION_LIFETIME"] = config['session_parameters']["timeout"] config["SECRET_KEY"] = config['session_parameters']["secret_key"] + config["CACHE_TYPE"] = "simple" + config["CACHE_DEFAULT_TIMEOUT"] = 600 smtp_conf = config.get('smtp', None) if smtp_conf is not None: @@ -314,6 +317,8 @@ def flask_internalerror(e): flask_app.privacy_page = config.get("privacy_page", None) flask_app.static_directory = config.get("static_directory", "./static") flask_app.webdav_host = config.get("webdav_host", None) + cache = Cache(flask_app) + flask_app.cache = cache # Init the mapping of the app init_flask_mapping(flask_app) diff --git a/inginious/frontend/courses.py b/inginious/frontend/courses.py index 74fc6e1cc..3c29e9196 100644 --- a/inginious/frontend/courses.py +++ b/inginious/frontend/courses.py @@ -7,8 +7,9 @@ import copy import gettext +import hashlib import re -from typing import List +from typing import Iterable, List from inginious.frontend.user_settings.course_user_setting import CourseUserSetting from inginious.common.tags import Tag @@ -18,6 +19,7 @@ from inginious.frontend.task_dispensers.toc import TableOfContents from inginious.frontend.tasksets import _migrate_from_v_0_6 +from pylti1p3.tool_config import ToolConfDict class Course(object): """ A course with some modification for users """ @@ -54,7 +56,7 @@ def __init__(self, courseid, content, taskset_factory, task_factory, plugin_mana self._allow_preview = self._content.get('allow_preview', False) self._is_lti = self._content.get('is_lti', False) self._lti_url = self._content.get('lti_url', '') - self._lti_keys = self._content.get('lti_keys', {}) + self._lti_config = self._content.get('lti_config', {}) self._lti_send_back_grade = self._content.get('lti_send_back_grade', False) self._tags = {key: Tag(key, tag_dict, self.gettext) for key, tag_dict in self._content.get("tags", {}).items()} self._course_user_setting = {key: CourseUserSetting(key, @@ -82,7 +84,7 @@ def __init__(self, courseid, content, taskset_factory, task_factory, plugin_mana self._groups_student_choice = False self._allow_unregister = False else: - self._lti_keys = {} + self._lti_config = {} self._lti_url = '' self._lti_send_back_grade = False @@ -162,9 +164,28 @@ def is_lti(self): """ True if the current course is in LTI mode """ return self._is_lti - def lti_keys(self): - """ {name: key} for the LTI customers """ - return self._lti_keys if self._is_lti else {} + def lti_config(self): + """ LTI Tool config dictionary. Specs are at https://github.com/dmitry-viskov/pylti1.3/blob/master/README.rst?plain=1#L70-L98 """ + return self._lti_config if self._is_lti else {} + + def lti_tool(self) -> ToolConfDict: + """ LTI Tool object. """ + lti_tool = ToolConfDict(self._lti_config) + for iss in self._lti_config: + for client_config in self._lti_config[iss]: + lti_tool.set_private_key(iss, client_config['private_key'], client_id=client_config['client_id']) + lti_tool.set_public_key(iss, client_config['public_key'], client_id=client_config['client_id']) + return lti_tool + + def lti_platform_instances_ids(self) -> Iterable[str]: + """ Set of LTI Platform instance ids registered for this course. """ + for iss in self._lti_config: + for client_config in self._lti_config[iss]: + for deployment_id in client_config['deployment_ids']: + yield '/'.join([iss, client_config['client_id'], deployment_id]) + + def lti_keyset_hash(self, issuer: str, client_id: str) -> str: + return hashlib.md5((issuer + client_id).encode('utf-8')).digest().hex() def lti_url(self): """ Returns the URL to the external platform the course is hosted on """ diff --git a/inginious/frontend/flask/mapping.py b/inginious/frontend/flask/mapping.py index dc66c6706..055395c52 100644 --- a/inginious/frontend/flask/mapping.py +++ b/inginious/frontend/flask/mapping.py @@ -25,7 +25,7 @@ from inginious.frontend.pages.course_register import CourseRegisterPage from inginious.frontend.pages.course import CoursePage from inginious.frontend.pages.tasks import TaskPage, TaskPageStaticDownload -from inginious.frontend.pages.lti import LTITaskPage, LTILaunchPage, LTIBindPage, LTIAssetPage, LTILoginPage +from inginious.frontend.pages.lti import LTITaskPage, LTIBindPage, LTIAssetPage, LTILaunchPage, LTIOIDCLoginPage, LTIJWKSPage, LTILoginPage from inginious.frontend.pages.group import GroupPage from inginious.frontend.pages.marketplace import MarketplacePage from inginious.frontend.pages.marketplace_taskset import MarketplaceTasksetPage @@ -91,9 +91,12 @@ def init_flask_mapping(flask_app): view_func=BindingsPage.as_view('bindingspage')) flask_app.add_url_rule('/preferences/delete', view_func=DeletePage.as_view('deletepage')) flask_app.add_url_rule('/preferences/profile', view_func=ProfilePage.as_view('profilepage')) - flask_app.add_url_rule('/lti/task', view_func=LTITaskPage.as_view('ltitaskpage')) - flask_app.add_url_rule('/lti//', + flask_app.add_url_rule('/lti/oidc_login/', + view_func=LTIOIDCLoginPage.as_view('ltioidcloginpage')) + flask_app.add_url_rule('/lti/launch//', view_func=LTILaunchPage.as_view('ltilaunchpage')) + flask_app.add_url_rule('/lti/jwks//', view_func=LTIJWKSPage.as_view('ltijwkspage')) + flask_app.add_url_rule('/lti/task', view_func=LTITaskPage.as_view('ltitaskpage')) flask_app.add_url_rule('/lti/bind', view_func=LTIBindPage.as_view('ltibindpage')) flask_app.add_url_rule('/lti/login', view_func=LTILoginPage.as_view('ltiloginpage')) flask_app.add_url_rule('/lti/asset/', diff --git a/inginious/frontend/lti_request_validator.py b/inginious/frontend/lti_request_validator.py deleted file mode 100644 index 8117c9f7e..000000000 --- a/inginious/frontend/lti_request_validator.py +++ /dev/null @@ -1,55 +0,0 @@ -# coding=utf-8 -import datetime -from oauthlib.oauth1 import RequestValidator -from pymongo.errors import DuplicateKeyError - - -class LTIValidator(RequestValidator): # pylint: disable=abstract-method - enforce_ssl = True - client_key_length = (1, 30) - nonce_length = (20, 64) - realms = [""] - - @property - def dummy_client(self): - return "" # Not used: validation works for all - - @property - def dummy_request_token(self): - return "" # Not used: validation works for all - - @property - def dummy_access_token(self): - return "" # Not used: validation works for all - - def __init__(self, collection, keys, nonce_validity=datetime.timedelta(minutes=10), debug=False): - """ - :param collection: Pymongo collection. The collection must have a unique index on ("timestamp","nonce") and a TTL expiration on ("expiration") - :param keys: dictionnary of allowed client keys, and their associated secret - :param nonce_validity: timedelta representing the time during which a nonce is considered as valid - :param debug: - """ - super().__init__() - - self.enforce_ssl = debug - self._collection = collection - self._nonce_validity = nonce_validity - self._keys = keys - - def validate_client_key(self, client_key, request): - return client_key in self._keys - - def validate_timestamp_and_nonce(self, client_key, timestamp, nonce, request, request_token=None, access_token=None): - try: - date = datetime.datetime.utcfromtimestamp(int(timestamp)) - self._collection.insert_one({"timestamp": date, - "nonce": nonce, - "expiration": date + self._nonce_validity}) - return True - except ValueError: # invalid timestamp - return False - except DuplicateKeyError: - return False - - def get_client_secret(self, client_key, request): - return self._keys[client_key] if client_key in self._keys else None diff --git a/inginious/frontend/lti_tool_provider.py b/inginious/frontend/lti_tool_provider.py deleted file mode 100644 index 51fb9c850..000000000 --- a/inginious/frontend/lti_tool_provider.py +++ /dev/null @@ -1,26 +0,0 @@ -# -*- coding: utf-8 -*- -# -# This file is part of INGInious. See the LICENSE and the COPYRIGHTS files for -# more information about the licensing of this file. - -import flask -from lti import ToolProvider - - -class LTIWebPyToolProvider(ToolProvider): - ''' - ToolProvider that works with Web.py requests - ''' - - @classmethod - def from_webpy_request(cls, secret=None): - params = flask.request.form.copy() - headers = flask.request.headers.environ.copy() - - headers = dict([(k, headers[k]) - for k in headers if - k.upper().startswith('HTTP_') or - k.upper().startswith('CONTENT_')]) - - url = flask.request.url - return cls.from_unpacked_request(secret, params, url, headers) diff --git a/inginious/frontend/pages/course_admin/settings.py b/inginious/frontend/pages/course_admin/settings.py index fa230129f..06fe84a8f 100644 --- a/inginious/frontend/pages/course_admin/settings.py +++ b/inginious/frontend/pages/course_admin/settings.py @@ -3,6 +3,7 @@ # This file is part of INGInious. See the LICENSE and the COPYRIGHTS files for # more information about the licensing of this file. +import json import re import flask @@ -11,6 +12,8 @@ from inginious.frontend.accessible_time import AccessibleTime from inginious.frontend.pages.course_admin.utils import INGIniousAdminPage +from pylti1p3.tool_config import ToolConfDict +from jwcrypto.jwk import JWK # type: ignore class CourseSettingsPage(INGIniousAdminPage): """ Couse settings """ @@ -82,11 +85,37 @@ def POST_AUTH(self, courseid): # pylint: disable=arguments-differ course_content['is_lti'] = 'lti' in data and data['lti'] == "true" course_content['lti_url'] = data.get("lti_url", "") - course_content['lti_keys'] = dict([x.split(":") for x in data['lti_keys'].splitlines() if x]) - for lti_key in course_content['lti_keys'].keys(): - if not re.match("^[a-zA-Z0-9]*$", lti_key): - errors.append(_("LTI keys must be alphanumerical.")) + try: + lti_config = json.loads(data['lti_config']) + assert isinstance(lti_config, dict), 'Not a JSON object' + for iss in lti_config: + iss_config = lti_config[iss] + assert type(iss_config) is list, f'Issuer {iss} must have a list of client_id configuration' + for i, client_config in enumerate(iss_config): + required_keys = {'default', 'client_id', 'auth_login_url', 'auth_token_url', 'key_set_url', 'private_key', 'public_key', 'deployment_ids'} + for key in required_keys: + assert key in client_config, f'Missing {key} in client config {i} of issuer {iss}' + tool_conf = ToolConfDict(lti_config) + for iss in lti_config: + for i, client_config in enumerate(lti_config[iss]): + tool_conf.set_private_key(iss, client_config['private_key'], client_id=client_config['client_id']) + try: + JWK.from_pem(client_config['private_key'].encode('utf-8')).export(private_key=True) # Checks the private key format + except ValueError: + raise Exception(f"Error in private key of client config {i} of issuer {iss}") + tool_conf.set_public_key(iss, client_config['public_key'], client_id=client_config['client_id']) + try: + JWK.from_pem(client_config['public_key'].encode('utf-8')).export(private_key=False) # Checks the public key format + except ValueError: + raise Exception(f"Error in public key of client config {i} of issuer {iss}") + course_content['lti_config'] = lti_config + except json.JSONDecodeError as ex: + errors.append(_("LTI config couldn't parse as JSON") + ' - ' + str(ex)) + except AssertionError as ex: + errors.append(_('LTI config is incorrect') + ' - ' + str(ex)) + except Exception as ex: + errors.append(_('LTI config is incorrect') + ' - ' + str(ex)) course_content['lti_send_back_grade'] = 'lti_send_back_grade' in data and data['lti_send_back_grade'] == "true" diff --git a/inginious/frontend/pages/lti.py b/inginious/frontend/pages/lti.py index 6be4f6f4b..8f91746bf 100644 --- a/inginious/frontend/pages/lti.py +++ b/inginious/frontend/pages/lti.py @@ -4,16 +4,16 @@ # more information about the licensing of this file. import flask -from flask import redirect -from werkzeug.exceptions import Forbidden, NotFound, MethodNotAllowed -from inginious.frontend.lti_request_validator import LTIValidator +from flask import jsonify, redirect +from werkzeug.exceptions import Forbidden, NotFound from inginious.frontend.pages.utils import INGIniousPage, INGIniousAuthPage from itsdangerous import want_bytes from inginious.frontend import exceptions -from inginious.frontend.lti_tool_provider import LTIWebPyToolProvider from inginious.frontend.pages.tasks import BaseTaskPage +from pylti1p3.contrib.flask import FlaskOIDCLogin, FlaskMessageLaunch, FlaskRequest, FlaskCacheDataStorage + class LTITaskPage(INGIniousAuthPage): def is_lti_page(self): @@ -53,6 +53,7 @@ def is_lti_page(self): return False def fetch_lti_data(self, session_id): + """ Retrieves the corresponding session. """ # TODO : Flask session interface does not allow to open a specific session # It could be worth putting these information outside of the session dict sess = self.database.sessions.find_one({"_id": session_id}) @@ -91,7 +92,7 @@ def POST_AUTH(self): try: course = self.course_factory.get_course(data["task"][0]) - if data["consumer_key"] not in course.lti_keys().keys(): + if data["platform_instance_id"] not in course.lti_platform_instances_ids(): raise Exception() except: return self.template_helper.render("lti_bind.html", success=False, session_id="", @@ -100,20 +101,20 @@ def POST_AUTH(self): if data: user_profile = self.database.users.find_one({"username": self.user_manager.session_username()}) lti_user_profile = self.database.users.find_one( - {"ltibindings." + data["task"][0] + "." + data["consumer_key"]: data["username"]}) - if not user_profile.get("ltibindings", {}).get(data["task"][0], {}).get(data["consumer_key"], + {"ltibindings." + data["task"][0] + "." + data["platform_instance_id"]: data["username"]}) + if not user_profile.get("ltibindings", {}).get(data["task"][0], {}).get(data["platform_instance_id"], "") and not lti_user_profile: # There is no binding yet, so bind LTI to this account self.database.users.find_one_and_update({"username": self.user_manager.session_username()}, {"$set": { - "ltibindings." + data["task"][0] + "." + data["consumer_key"]: data["username"]}}) + "ltibindings." + data["task"][0] + "." + data["platform_instance_id"]: data["username"]}}) elif not (lti_user_profile and user_profile["username"] == lti_user_profile["username"]): # There exists an LTI binding for another account, refuse auth! self.logger.info("User %s tried to bind LTI user %s in for %s:%s, but %s is already bound.", user_profile["username"], data["username"], data["task"][0], - data["consumer_key"], - user_profile.get("ltibindings", {}).get(data["task"][0], {}).get(data["consumer_key"], "")) + data["platform_instance_id"], + user_profile.get("ltibindings", {}).get(data["task"][0], {}).get(data["platform_instance_id"], "")) return self.template_helper.render("lti_bind.html", success=False, session_id=cookieless_session_id, data=data, @@ -123,6 +124,122 @@ def POST_AUTH(self): session_id=cookieless_session_id, data=data, error="") +class LTIJWKSPage(INGIniousPage): + endpoint = 'ltijwkspage' + + def GET(self, courseid, keyset_hash): + try: + course = self.course_factory.get_course(courseid) + except exceptions.CourseNotFoundException as ex: + raise NotFound(description=_(str(ex))) + + lti_config = course.lti_config() + for issuer in lti_config: + for client_config in lti_config[issuer]: + if keyset_hash == course.lti_keyset_hash(issuer, client_config['client_id']): + tool_conf = course.lti_tool() + return jsonify(tool_conf.get_jwks(iss=issuer, client_id=client_config['client_id'])) + + raise NotFound(description=_("Keyset not found")) + + +class LTIOIDCLoginPage(INGIniousPage): + endpoint = 'ltioidcloginpage' + + def _handle_oidc_login_request(self, courseid): + """ Initiates the LTI 1.3 OIDC login. """ + try: + course = self.course_factory.get_course(courseid) + except exceptions.CourseNotFoundException as ex: + raise NotFound(description=_(str(ex))) + + tool_conf = course.lti_tool() + launch_data_storage = FlaskCacheDataStorage(self.app.cache) + + flask_request = FlaskRequest() + target_link_uri = flask_request.get_param('target_link_uri') + if not target_link_uri: + raise Exception('Missing "target_link_uri" param') + + oidc_login = FlaskOIDCLogin(flask_request, tool_conf, launch_data_storage=launch_data_storage) + return oidc_login.enable_check_cookies().redirect(target_link_uri) + + def GET(self, courseid): + return self._handle_oidc_login_request(courseid) + + def POST(self, courseid): + return self._handle_oidc_login_request(courseid) + + +class LTILaunchPage(INGIniousPage): + endpoint = 'ltilaunchpage' + + def _handle_message_launch(self, courseid, taskid): + """ Decrypt and process the LTI Launch message. """ + try: + course = self.course_factory.get_course(courseid) + except exceptions.CourseNotFoundException as ex: + raise NotFound(description=_(str(ex))) + + tool_conf = course.lti_tool() + launch_data_storage = FlaskCacheDataStorage(self.app.cache) + flask_request = FlaskRequest() + message_launch = FlaskMessageLaunch(flask_request, tool_conf, launch_data_storage=launch_data_storage) + + _launch_id = message_launch.get_launch_id() # TODO(mp): With a good use of the cache, this could be used as a non-session id + launch_data = message_launch.get_launch_data() + + user_id = launch_data['sub'] + roles = launch_data['https://purl.imsglobal.org/spec/lti/claim/roles'] + realname = self._find_realname(launch_data) + email = launch_data.get('email', '') + platform_instance_id = '/'.join([launch_data['iss'], message_launch.get_client_id(), launch_data['https://purl.imsglobal.org/spec/lti/claim/deployment_id']]) + outcome_service_url = launch_data.get('lis_outcome_service_url') # TODO(mp): Port the Outcome service to LTI 1.3 + outcome_result_id = launch_data.get('lis_result_sourcedid') # TODO(mp): Port the Outcome service to LTI 1.3 + tool = launch_data.get('https://purl.imsglobal.org/spec/lti/claim/tool_platform', {}) + tool_name = tool.get('name', 'N/A') + tool_desc = tool.get('description', 'N/A') + tool_url = tool.get('url', 'N/A') + context = launch_data['https://purl.imsglobal.org/spec/lti/claim/context'] + context_title = context.get('context_title', 'N/A') + context_label = context.get('context_label', 'N/A') + + self.user_manager.create_lti_session(user_id, roles, realname, email, courseid, taskid, platform_instance_id, + outcome_service_url, outcome_result_id, tool_name, tool_desc, tool_url, + context_title, context_label) + loggedin = self.user_manager.attempt_lti_login() + if loggedin: + return redirect(self.app.get_path("lti", "task")) + else: + return redirect(self.app.get_path("lti", "login")) + + def GET(self, courseid, taskid): + return self._handle_message_launch(courseid, taskid) + + def POST(self, courseid, taskid): + return self._handle_message_launch(courseid, taskid) + + def _find_realname(self, launch_data): + """ Returns the most appropriate name to identify the user """ + + # First, try the full name + if "name" in launch_data: + return launch_data["name"] + if "given" in launch_data and "family_name" in launch_data: + return launch_data["given"] + launch_data["family_name"] + + # Then the email + if "email" in launch_data: + return launch_data["email"] + + # Then only part of the full name + if "family_name" in launch_data: + return launch_data["family_name"] + if "given" in launch_data: + return launch_data["given"] + + return launch_data["sub"] + class LTILoginPage(INGIniousPage): def is_lti_page(self): return True @@ -138,13 +255,13 @@ def GET(self): try: course = self.course_factory.get_course(data["task"][0]) - if data["consumer_key"] not in course.lti_keys().keys(): + if data["platform_instance_id"] not in course.lti_platform_instances_ids(): raise Exception() except: return self.template_helper.render("lti_bind.html", success=False, session_id="", - data=None, error="Invalid LTI data") + data=None, error=_("Invalid LTI data")) - user_profile = self.database.users.find_one({"ltibindings." + data["task"][0] + "." + data["consumer_key"]: data["username"]}) + user_profile = self.database.users.find_one({"ltibindings." + data["task"][0] + "." + data["platform_instance_id"]: data["username"]}) if user_profile: self.user_manager.connect_user(user_profile["username"], user_profile["realname"], user_profile["email"], user_profile["language"], user_profile.get("tos_accepted", False)) @@ -161,94 +278,3 @@ def POST(self): """ return self.GET() - -class LTILaunchPage(INGIniousPage): - """ - Page called by the TC to start an LTI session on a given task - """ - endpoint = 'ltilaunchpage' - - def GET(self, courseid, taskid): - raise MethodNotAllowed() - - def POST(self, courseid, taskid): - (session_id, loggedin) = self._parse_lti_data(courseid, taskid) - if loggedin: - return redirect(self.app.get_path("lti", "task")) - else: - return redirect(self.app.get_path("lti", "login")) - - def _parse_lti_data(self, courseid, taskid): - """ Verify and parse the data for the LTI basic launch """ - post_input = flask.request.form - self.logger.debug('_parse_lti_data:' + str(post_input)) - - try: - course = self.course_factory.get_course(courseid) - except exceptions.CourseNotFoundException as ex: - raise NotFound(description=_(str(ex))) - - try: - test = LTIWebPyToolProvider.from_webpy_request() - validator = LTIValidator(self.database.nonce, course.lti_keys()) - verified = test.is_valid_request(validator) - except Exception as ex: - self.logger.error("Error while parsing the LTI request : {}".format(str(post_input))) - self.logger.error("The exception caught was : {}".format(str(ex))) - raise Forbidden(description=_("Error while parsing the LTI request")) - - if verified: - self.logger.debug('parse_lit_data for %s', str(post_input)) - user_id = post_input["user_id"] - roles = post_input.get("roles", "Student").split(",") - realname = self._find_realname(post_input) - email = post_input.get("lis_person_contact_email_primary", "") - lis_outcome_service_url = post_input.get("lis_outcome_service_url", None) - outcome_result_id = post_input.get("lis_result_sourcedid", None) - consumer_key = post_input["oauth_consumer_key"] - - if course.lti_send_back_grade(): - if lis_outcome_service_url is None or outcome_result_id is None: - self.logger.info('Error: lis_outcome_service_url is None but lti_send_back_grade is True') - raise Forbidden(description=_("In order to send grade back to the TC, INGInious needs the parameters lis_outcome_service_url and " - "lis_outcome_result_id in the LTI basic-launch-request. Please contact your administrator.")) - else: - lis_outcome_service_url = None - outcome_result_id = None - - tool_name = post_input.get('tool_consumer_instance_name', 'N/A') - tool_desc = post_input.get('tool_consumer_instance_description', 'N/A') - tool_url = post_input.get('tool_consumer_instance_url', 'N/A') - context_title = post_input.get('context_title', 'N/A') - context_label = post_input.get('context_label', 'N/A') - - session_id = self.user_manager.create_lti_session(user_id, roles, realname, email, courseid, taskid, consumer_key, - lis_outcome_service_url, outcome_result_id, tool_name, tool_desc, tool_url, - context_title, context_label) - loggedin = self.user_manager.attempt_lti_login() - - return session_id, loggedin - else: - self.logger.info("Couldn't validate LTI request") - raise Forbidden(description=_("Couldn't validate LTI request")) - - def _find_realname(self, post_input): - """ Returns the most appropriate name to identify the user """ - - # First, try the full name - if "lis_person_name_full" in post_input: - return post_input["lis_person_name_full"] - if "lis_person_name_given" in post_input and "lis_person_name_family" in post_input: - return post_input["lis_person_name_given"] + post_input["lis_person_name_family"] - - # Then the email - if "lis_person_contact_email_primary" in post_input: - return post_input["lis_person_contact_email_primary"] - - # Then only part of the full name - if "lis_person_name_family" in post_input: - return post_input["lis_person_name_family"] - if "lis_person_name_given" in post_input: - return post_input["lis_person_name_given"] - - return post_input["user_id"] diff --git a/inginious/frontend/pages/utils.py b/inginious/frontend/pages/utils.py index 06fd15720..fb2fb2e02 100644 --- a/inginious/frontend/pages/utils.py +++ b/inginious/frontend/pages/utils.py @@ -27,6 +27,7 @@ from inginious.frontend.taskset_factory import TasksetFactory from inginious.frontend.task_factory import TaskFactory +from inginious.frontend.course_factory import CourseFactory from inginious.frontend.lti_outcome_manager import LTIOutcomeManager @@ -84,7 +85,7 @@ def taskset_factory(self) -> TasksetFactory: return self.app.taskset_factory @property - def course_factory(self) -> TaskFactory: + def course_factory(self) -> CourseFactory: """ Returns the task factory singleton """ return self.app.course_factory diff --git a/inginious/frontend/templates/course_admin/settings.html b/inginious/frontend/templates/course_admin/settings.html index 690041c2d..ad41c5c26 100644 --- a/inginious/frontend/templates/course_admin/settings.html +++ b/inginious/frontend/templates/course_admin/settings.html @@ -346,10 +346,26 @@

{{_("Course settings")}}

- +
- + +
+
+
+ +
+
    + {% for iss, config in course.lti_config().items() %} + {% for client_config in config %} + {% set path = get_path('lti', 'jwks', course.get_id(), course.lti_keyset_hash(iss, client_config['client_id'])) %} +
    + {{ path }} + Issuer {{ iss }}, client id {{ client_config['client_id']}} +
    + {% endfor %} + {% endfor %} +
diff --git a/inginious/frontend/user_manager.py b/inginious/frontend/user_manager.py index ee0d711c4..65911ea67 100644 --- a/inginious/frontend/user_manager.py +++ b/inginious/frontend/user_manager.py @@ -155,7 +155,7 @@ def session_lti_info(self): "task": (course_id, task_id), "outcome_service_url": outcome_service_url, "outcome_result_id": outcome_result_id, - "consumer_key": consumer_key + "platform_instance_id": platform_instance_id } where all these data where provided by the LTI consumer, and MAY NOT be equivalent to the data @@ -227,7 +227,7 @@ def _destroy_session(self): self._session["lti"] = None self._session["tos_signed"] = None - def create_lti_session(self, user_id, roles, realname, email, course_id, task_id, consumer_key, outcome_service_url, + def create_lti_session(self, user_id, roles, realname, email, course_id, task_id, platform_instance_id, outcome_service_url, outcome_result_id, tool_name, tool_desc, tool_url, context_title, context_label): """ Creates an LTI cookieless session. Returns the new session id""" @@ -242,7 +242,7 @@ def create_lti_session(self, user_id, roles, realname, email, course_id, task_id "task": (course_id, task_id), "outcome_service_url": outcome_service_url, "outcome_result_id": outcome_result_id, - "consumer_key": consumer_key, + "platform_instance_id": platform_instance_id, "context_title": context_title, "context_label": context_label, "tool_description": tool_desc, @@ -254,7 +254,7 @@ def create_lti_session(self, user_id, roles, realname, email, course_id, task_id def attempt_lti_login(self): """ Given that the current session is an LTI one (session_lti_info does not return None), attempt to find an INGInious user - linked to this lti username/consumer_key. If such user exists, logs in using it. + linked to this lti username/platform_instance_id. If such user exists, logs in using it. Returns True (resp. False) if the login was successful """ diff --git a/setup.py b/setup.py index 367483f15..8ad382983 100644 --- a/setup.py +++ b/setup.py @@ -16,6 +16,7 @@ "itsdangerous==2.1.2", "Jinja2==3.1.3", "lti==0.9.5", + "PyLTI1p3==2.0.0", "msgpack==1.0.7", "natsort==8.4.0", "psutil==5.9.8",