Skip to content

Commit

Permalink
Implements basic LTI 1.3
Browse files Browse the repository at this point in the history
  • Loading branch information
mpiraux committed Oct 18, 2024
1 parent 29067a6 commit 9b1d6e5
Show file tree
Hide file tree
Showing 11 changed files with 227 additions and 206 deletions.
5 changes: 5 additions & 0 deletions inginious/frontend/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
33 changes: 27 additions & 6 deletions inginious/frontend/courses.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 """
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 """
Expand Down
9 changes: 6 additions & 3 deletions inginious/frontend/flask/mapping.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/<courseid>/<taskid>',
flask_app.add_url_rule('/lti/oidc_login/<courseid>',
view_func=LTIOIDCLoginPage.as_view('ltioidcloginpage'))
flask_app.add_url_rule('/lti/launch/<courseid>/<taskid>',
view_func=LTILaunchPage.as_view('ltilaunchpage'))
flask_app.add_url_rule('/lti/jwks/<courseid>/<keyset_hash>', 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/<path:asset_url>',
Expand Down
55 changes: 0 additions & 55 deletions inginious/frontend/lti_request_validator.py

This file was deleted.

26 changes: 0 additions & 26 deletions inginious/frontend/lti_tool_provider.py

This file was deleted.

37 changes: 33 additions & 4 deletions inginious/frontend/pages/course_admin/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 """
Expand Down Expand Up @@ -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"

Expand Down
Loading

0 comments on commit 9b1d6e5

Please sign in to comment.