Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implements LTI 1.3 #26

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 16 additions & 8 deletions configuration_default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,23 @@ courses:
same_origin_proxy: yes

# LTI-related config. If you want to use LTI, uncomment the following lines:

lti:
consumer_secret: my_super_key
consumer_key: my_super_consumer_key
tool_url: ~ # url of the syllabus using this INGInious course
tool_description: ~ # description the syllabus using this INGInious course to announce over LTI
tool_context_id: ~
tool_context_label: ~
tool_context_title: ~
platform: # Describes the syllabus
url: ~ #URL of the syllabus using this INGInious course
description: ~ # Description the syllabus using this INGInious course to announce over LTI
iss: ~ # Identifies the platform, e.g., using its domain name
client_id: ~ # Differentiates registrations among the iss
deployment_id: ~ # Identifies this integration
public_key: ~ # Platform pub key in PEM format (w/ comments headers)
private_key: ~ # Platform private key in PEM format (w/ comments headers)
launch_context: # Identifies the course in the syllabus part of this integration
id: ~ # Identifies the course in the syllabus
label: ~ # Shortname for the course, such as its course code
title: ~ # Course code and full course name
tool: # Describes the INGInious LTI instance
launch_url: ~ # INGInious LTI course endpoint
oidc_login_url: ~ # INGInious LTI OIDC login endpoint
key_set_url: ~ # INGInious LTI course keyset endpoint

pages:
# indicates the location where the pages directory is located. It has a lower priority than the SYLLABUS_PAGES_PATH
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
author_email='',
scripts= ['syllabus-webapp'],
install_requires=[
'pyyaml >= 3.12', 'werkzeug >= 0.11.11', 'pygments >= 2.1.3', 'flask >= 0.12', 'docutils >= 0.13.1', 'lti',
'pyyaml >= 3.12', 'werkzeug >= 0.11.11', 'pygments >= 2.1.3', 'flask >= 0.12', 'docutils >= 0.13.1', 'lti1p3platform >= 0.1.6',
'flask-sqlalchemy >= 2.3.2', 'sqlalchemy >= 2.0.0','python3-saml', 'GitPython', 'sphinx', 'sphinxcontrib-websupport'
],
include_package_data=True,
Expand Down
4 changes: 2 additions & 2 deletions syllabus/inginious_syllabus.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
from syllabus.models.params import Params
from syllabus.models.user import hash_password_func, User, UserAlreadyExists, verify_activation_mac, get_activation_mac
from syllabus.saml import prepare_request, init_saml_auth
from syllabus.utils.inginious_lti import get_lti_data, get_lti_submission
from syllabus.utils.inginious_lti import get_lti_data, get_lti_submission, bp as inginious_lti_bp
from syllabus.utils.mail import send_confirmation_mail, send_authenticated_confirmation_mail
from syllabus.utils.pages import seeother, get_content_data, permission_admin, update_last_visited, store_last_visited, render_content, default_rst_opts, get_cheat_sheet
from syllabus.utils.toc import Content, Chapter, TableOfContent, ContentNotFoundError, Page
Expand All @@ -61,6 +61,7 @@
directives.register_directive('teacher', syllabus.utils.directives.TeacherDirective)
directives.register_directive('framed', syllabus.utils.directives.FramedDirective)
directives.register_directive('print', syllabus.utils.directives.PrintOnlyDirective)
app.register_blueprint(inginious_lti_bp)


if "saml" in syllabus.get_config()['authentication_methods']:
Expand Down Expand Up @@ -641,7 +642,6 @@ def update_pages(secret, course):
syllabus.utils.pages.init_and_sync_repo(course, force_sync=True)
return "done"


def main():
update_database()
init_db()
Expand Down
13 changes: 13 additions & 0 deletions syllabus/templates/lti_deep_link_return.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>LTI Deep Link</title>
</head>

<body>
{% for c in content_items %}
{{ c|safe }}
{% endfor %}
</body>
</html>
22 changes: 22 additions & 0 deletions syllabus/templates/lti_tool_launch.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>LTI Tool Launch</title>
</head>

<body>
<form method="POST" action="{{ launch_url }}" id="form">
<input type="hidden" name="id_token" value="{{ id_token }}" />
<input type="hidden" name="state" value="{{ state }}" />
</form>

<script type="text/javascript">
document.getElementById("form").submit();
</script>

<noscript>
Please enable Javascript to access this content.
</noscript>
</body>
</html>
206 changes: 139 additions & 67 deletions syllabus/utils/inginious_lti.py
Original file line number Diff line number Diff line change
@@ -1,57 +1,150 @@
import json
import re
from json import JSONDecodeError
from urllib import request as urllib_request, parse
from urllib.error import URLError, HTTPError
from typing import Any, Dict
from urllib import parse

from lti import ToolConsumer
from lti1p3platform.ltiplatform import LTI1P3PlatformConfAbstract
from lti1p3platform.registration import Registration

import syllabus

lti_regex_match = re.compile('/@([0-9a-fA-F]+?)@/')


def get_lti_url(course, user_id, task_id):
config = syllabus.get_config()
inginious_config = config['courses'][course]['inginious']
consumer = ToolConsumer(
consumer_key=inginious_config['lti']['consumer_key'],
consumer_secret=inginious_config['lti']['consumer_secret'],
launch_url='%s/lti/%s/%s' % (inginious_config['url'], inginious_config['course_id'], task_id),
params={
'lti_message_type': 'basic-lti-launch-request',
'lti_version': "1.1",
'resource_link_id': "syllabus_{}_{}".format(course, task_id),
'user_id': user_id,
}
)
from lti1p3platform.oidc_login import OIDCLoginAbstract
from lti1p3platform.message_launch import LTIAdvantageMessageLaunchAbstract

d = consumer.generate_launch_data()
data = parse.urlencode(d).encode()
from flask import abort, redirect, render_template, jsonify, request, Blueprint, url_for

req = urllib_request.Request('%s/lti/%s/%s' % (inginious_config['url'], inginious_config['course_id'], task_id), data=data)
resp = urllib_request.urlopen(req)

task_url = resp.geturl()
import syllabus

lti_url_regex = re.compile("%s/@[0-9a-fA-F]+@/lti/task/?" % inginious_config['url'])
if not lti_url_regex.match(task_url):
pass
#raise Exception("INGInious returned the wrong url: %s vs %s" % (task_url, str(lti_url_regex)))
return task_url
bp = Blueprint('lti', __name__, url_prefix='/lti')
platforms: Dict[str, 'LTIPlatformConf'] = {}

class LTIPlatformConf(LTI1P3PlatformConfAbstract):
def init_platform_config(self, course):
"""
register platform configuration
"""
config = syllabus.get_config()
assert course in config['courses'], f"Unknown course: {course}"
lti_config = config['courses'][course]['inginious']['lti']
registration = Registration() \
.set_iss(lti_config['platform']['iss']) \
.set_client_id(lti_config['platform']['client_id']) \
.set_deployment_id(lti_config['platform']['deployment_id']) \
.set_launch_url(lti_config['tool']['launch_url']) \
.set_oidc_login_url(lti_config['tool']['oidc_login_url']) \
.set_tool_key_set_url(lti_config['tool']['key_set_url']) \
.set_platform_public_key(lti_config['platform']['public_key']) \
.set_platform_private_key(lti_config['platform']['private_key'])

self._registration = registration
self.course = course
platforms[course] = self

def get_registration_by_params(self, **kwargs) -> Registration:
return self._registration

@classmethod
def get_platform(cls, course):
if course not in syllabus.get_config()['courses']:
return abort(404)
if course not in platforms:
return LTIPlatformConf(course=course)
return platforms[course]


class OIDCLogin(OIDCLoginAbstract):
def set_lti_message_hint(self, **kwargs):
self.hint = json.dumps(kwargs)

def get_lti_message_hint(self):
return self.hint

def get_redirect(self, url):
return redirect(url)


class LTI1p3MessageLaunch(LTIAdvantageMessageLaunchAbstract):
deep_link = False

def get_preflight_response(self) -> Dict[str, Any]:
return request.form.copy() | request.args.copy()

def render_launch_form(self, launch_data, **kwargs):
return render_template("lti_tool_launch.html", **launch_data)

def prepare_launch(self, preflight_response, **kwargs):
message_hint = json.loads(parse.unquote_plus(preflight_response['lti_message_hint']))
course_config = syllabus.get_config()['courses'][message_hint['course']]
inginious_config = course_config['inginious']
lti_config = inginious_config['lti']

self.set_user_data(preflight_response['login_hint'], []) # TODO(mp): Roles, see https://www.imsglobal.org/spec/lti/v1p3#role-vocabularies
self.set_resource_link_claim("syllabus_{}_{}".format(message_hint['course'], message_hint['task_id']))
launch_context = lti_config.get('launch_context', {})
self.set_launch_context_claim(
context_id=launch_context.get('id', message_hint['course']),
context_label=launch_context.get('label', None),
context_title=launch_context.get('title', course_config['title']),
context_types=["http://purl.imsglobal.org/vocab/lis/v2/course#CourseOffering"]
)
launch_url = lti_config['tool']['launch_url']
if not launch_url.endswith('/'):
launch_url += '/'
launch_url += message_hint['task_id']
self.set_launch_url(launch_url)
self.set_extra_claims({
"https://purl.imsglobal.org/spec/lti/claim/tool_platform": {
"guid": course_config['title'],
"name": 'Interactive syllabus (%s)' % course_config['title'],
"description": lti_config.get('platform_description', None),
"url": lti_config.get('platform_url', None),
},
})

if self.deep_link:
self.set_dl(
deep_link_return_url=url_for('lti.deep_link_return', course=message_hint['course'], _external=True),
title='Interactive syllabus (%s - %s)' % (course_config['title'], message_hint['task_id']),
accept_types={'html', 'ltiResourceLink'},
)

def preflight_lti_1p3_launch(course, task_id, user_id):
platform = LTIPlatformConf.get_platform(course)
oidc_login = OIDCLogin(None, platform)
oidc_login.set_lti_message_hint(course=course, task_id=task_id)
launch_url = platform.get_registration().get_launch_url()
if not launch_url.endswith('/'):
launch_url += '/'
launch_url += task_id
oidc_login.set_launch_url(task_id)
return oidc_login.prepare_preflight_url(user_id)

@bp.route('/jwks/<string:course>')
def get_jwks(course):
return jsonify(LTIPlatformConf.get_platform(course).get_jwks())

@bp.route('/auth', methods=['GET', 'POST'])
def authorization():
message_hint = json.loads(parse.unquote_plus(request.form.get('lti_message_hint', request.args.get('lti_message_hint'))))
launch_request = LTI1p3MessageLaunch(None, LTIPlatformConf.get_platform(message_hint['course']))
return launch_request.lti_launch()

@bp.route('/deep_link/<string:course>', methods=['POST'])
def deep_link_return(course):
platform = LTIPlatformConf.get_platform(course)
message = platform.tool_validate_and_decode(request.form.get('JWT', request.args.get('JWT')))
return render_template('lti_deep_link_return.html', content_items=[c['html'] for c in message['https://purl.imsglobal.org/spec/lti-dl/claim/content_items']])


def get_lti_submission(course, user_id, task_id):
config = syllabus.get_config()
try:
lti_url = get_lti_url(course, user_id, task_id)
except HTTPError:
return None
match = lti_regex_match.findall(lti_url)
import json
from json import JSONDecodeError
from urllib import request as urllib_request
from urllib.error import HTTPError
print('get_lti_submission', course, user_id, task_id)
# TODO(mp): Find INGInious token/cookie from past launch attempts
match = []
if len(match) == 1:
cookie = match[0]
try:
response = json.loads(urllib_request.urlopen('%s/@%s@/lti/bestsubmission' % (config['courses'][course]['inginious']['url'], cookie), timeout=5).read().decode("utf-8"))
response = json.loads(urllib_request.urlopen('%s/lti/bestsubmission?session_id=%s' % (config['courses'][course]['inginious']['url'], cookie), timeout=5).read().decode("utf-8"))
except (JSONDecodeError, HTTPError):
response = {"status": "error"}
if response["status"] == "success" and response["submission"] is not None:
Expand All @@ -60,28 +153,7 @@ def get_lti_submission(course, user_id, task_id):


def get_lti_data(course, user_id, task_id):
course_config = syllabus.get_config()['courses'][course]
inginious_config = course_config['inginious']
lti_config = inginious_config['lti']
consumer = ToolConsumer(
consumer_key=inginious_config['lti']['consumer_key'],
consumer_secret=inginious_config['lti']['consumer_secret'],
launch_url='%s/lti/%s/%s' % (inginious_config['url'], inginious_config['course_id'], task_id),
params={
'lti_message_type': 'basic-lti-launch-request',
'lti_version': "1.1",
'resource_link_id': "syllabus_%s" % task_id,
'tool_consumer_instance_name': 'Interactive syllabus (%s)' % course_config['title'],
'tool_consumer_instance_url': lti_config.get('tool_url', None),
'tool_consumer_instance_description': lti_config.get('tool_description', None),
'context_id': lti_config.get('tool_context_id', None),
'context_label': lti_config.get('tool_context_label', None),
'context_title': lti_config.get('tool_context_title', None),
'user_id': user_id,
}
)

d = consumer.generate_launch_data()
return d, consumer.launch_url


parsed = parse.urlparse(preflight_lti_1p3_launch(course, task_id, user_id))
query = parse.parse_qs(parsed.query)
query['lti_message_hint'] = [parse.quote_plus(query['lti_message_hint'][0])]
return {k: v[0] for k, v in query.items()}, parsed._replace(query='').geturl()