Skip to content

Commit e2c314f

Browse files
authored
Merge pull request #1240 from ccnmtl/ERD-458-lti-1.3
LTI 1.3 configuration
2 parents 078c7e8 + df04325 commit e2c314f

File tree

5 files changed

+337
-3
lines changed

5 files changed

+337
-3
lines changed

metricsmentor/main/views.py

Lines changed: 274 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,15 @@
44
from courseaffils.columbia import CourseStringTemplate, CanvasTemplate
55
from courseaffils.models import Course
66
from courseaffils.views import get_courses_for_user
7-
from django.conf import settings
7+
from django.conf import settings, LazySettings
88
from django.contrib import messages
99
from django.contrib.auth.mixins import (
1010
LoginRequiredMixin
1111
)
1212
from django.contrib.auth.models import Group
1313
from django.shortcuts import render, get_object_or_404
1414
from django.http import (
15-
HttpResponseRedirect
15+
HttpResponseRedirect, HttpResponse
1616
)
1717
from django.urls.base import reverse
1818
from django.views.generic.base import TemplateView, View
@@ -31,6 +31,38 @@
3131
import statsmodels.api as sm
3232
import json
3333
import math
34+
from lti_tool.views import LtiLaunchBaseView, OIDCLoginInitView
35+
from lti_dynamic_registration.views import DynamicRegistrationBaseView
36+
from lti_dynamic_registration.constants import CanvasPrivacyLevel
37+
from lti_dynamic_registration.types import (
38+
CanvasLtiMessage,
39+
CanvasLtiRegistration,
40+
CanvasLtiToolConfiguration,
41+
)
42+
from urllib.parse import urljoin, urlparse
43+
from django.templatetags.static import static
44+
from django.views.decorators.clickjacking import xframe_options_exempt
45+
46+
47+
def ensure_https(url: str) -> str:
48+
if not url.startswith('http://') and not url.startswith('https://'):
49+
return 'https://' + url
50+
return url
51+
52+
53+
def is_static_file_remote(path: str) -> bool:
54+
url = static(path)
55+
parsed = urlparse(url)
56+
return bool(parsed.scheme and parsed.netloc)
57+
58+
59+
def get_icon_url(settings: LazySettings, host: str) -> str:
60+
icon_url = static(settings.LTI_TOOL_CONFIGURATION['embed_icon_url'])
61+
62+
if not is_static_file_remote(icon_url):
63+
icon_url = urljoin(f'https://{host}', icon_url)
64+
65+
return icon_url
3466

3567

3668
class CoursesView(LoginRequiredMixin, TemplateView):
@@ -130,6 +162,246 @@ def post(self, request, *args, **kwargs):
130162
)
131163

132164

165+
class JSONConfigView(View):
166+
"""
167+
JSON configuration endpoint for LTI 1.3.
168+
169+
In Canvas LMS, an LTI Developer Key can be created via Manual
170+
Entry, or by URL. This view provides the JSON necessary for URL
171+
configuration in Canvas.
172+
173+
https://canvas.instructure.com/doc/api/file.lti_dev_key_config.html
174+
"""
175+
def get(self, request, *args, **kwargs):
176+
domain = request.get_host()
177+
title = settings.LTI_TOOL_CONFIGURATION['title']
178+
icon_url = static(settings.LTI_TOOL_CONFIGURATION['embed_icon_url'])
179+
target_link_uri = urljoin(
180+
'https://{}'.format(domain), reverse('lti-launch'))
181+
182+
uuid_str = kwargs.get('registration_uuid')
183+
oidc_init_uri = urljoin(
184+
'https://{}'.format(domain),
185+
reverse('init', kwargs={'registration_uuid': uuid_str}))
186+
187+
lti_platform = 'columbiasce.test.instructure.com'
188+
if hasattr(settings, 'LTI_PLATFORM'):
189+
lti_platform = settings.LTI_PLATFORM
190+
191+
json_obj = {
192+
'title': title,
193+
'description': settings.LTI_TOOL_CONFIGURATION['description'],
194+
'oidc_initiation_url': oidc_init_uri,
195+
'target_link_uri': target_link_uri,
196+
'scopes': [
197+
'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem',
198+
'https://purl.imsglobal.org/spec/lti-ags/lti/claim/lis',
199+
200+
'https://purl.imsglobal.org/'
201+
'spec/lti-ags/scope/result.readonly',
202+
203+
'https://purl.imsglobal.org/'
204+
'spec/lti-nrps/scope/contextmembership.readonly',
205+
],
206+
'extensions': [
207+
{
208+
'domain': domain,
209+
'tool_id': 'metricsmentor',
210+
'platform': lti_platform,
211+
'privacy_level': 'public',
212+
'settings': {
213+
'text': 'Launch ' + title,
214+
'labels': {
215+
'en': 'Launch ' + title,
216+
},
217+
'icon_url': icon_url,
218+
'selection_height': 800,
219+
'selection_width': 800,
220+
'placements': [
221+
{
222+
'text': 'MetricsMentor',
223+
'icon_url': icon_url,
224+
'placement': 'course_navigation',
225+
'message_type': 'LtiResourceLinkRequest',
226+
'target_link_uri': target_link_uri,
227+
'selection_height': 500,
228+
'selection_width': 500
229+
}
230+
],
231+
}
232+
}
233+
],
234+
'public_jwk_url': urljoin(
235+
'https://{}'.format(domain), reverse('jwks'))
236+
}
237+
return JsonResponse(json_obj)
238+
239+
240+
@method_decorator(xframe_options_exempt, name='dispatch')
241+
class MyOIDCLoginInitView(OIDCLoginInitView):
242+
pass
243+
244+
245+
@method_decorator(xframe_options_exempt, name='dispatch')
246+
class LtiLaunchView(LtiLaunchBaseView, TemplateView):
247+
"""
248+
https://github.com/academic-innovation/django-lti/blob/main/README.md#handling-an-lti-launch
249+
"""
250+
template_name = 'lti_provider/landing_page.html'
251+
lti_tool_name = None
252+
course = None
253+
254+
@xframe_options_exempt
255+
def handle_resource_launch(self, request, lti_launch):
256+
if settings.DEBUG:
257+
print('All lti_launch data:', lti_launch.get_launch_data())
258+
print('User:', lti_launch.user.__dict__)
259+
print('NRPS claim:', lti_launch.nrps_claim)
260+
print('Roles claim:', lti_launch.roles_claim)
261+
262+
self.lti_tool_name = lti_launch.platform_instance_claim.get(
263+
'product_family_code')
264+
if self.lti_tool_name:
265+
self.lti_tool_name = self.lti_tool_name.capitalize()
266+
267+
self.deployment_id = lti_launch.deployment.deployment_id
268+
self.course_id = lti_launch.context_claim.get('id')
269+
self.course_name = lti_launch.context_claim.get('title')
270+
271+
# Search for course by context_id in EconPractice database
272+
try:
273+
self.course = Course.objects.get(
274+
context_id=self.course_id, deployment_id=self.deployment_id)
275+
except Course.DoesNotExist:
276+
pass
277+
278+
return self.get(request)
279+
280+
@xframe_options_exempt
281+
def get_context_data(self, **kwargs):
282+
domain = self.request.get_host()
283+
url = settings.LTI_TOOL_CONFIGURATION['landing_url'].format(
284+
self.request.scheme, domain, kwargs.get('context'))
285+
286+
lti_tool_name = 'LTI'
287+
if self.lti_tool_name:
288+
lti_tool_name = self.lti_tool_name
289+
290+
return {
291+
'DEBUG': settings.DEBUG,
292+
'landing_url': url,
293+
'title': settings.LTI_TOOL_CONFIGURATION['title'],
294+
'lti_tool_name': lti_tool_name,
295+
'course': self.course,
296+
'deployment_id': self.deployment_id,
297+
'course_id': self.course_id,
298+
'course_name': self.course_name,
299+
}
300+
301+
302+
class DynamicRegistrationView(DynamicRegistrationBaseView):
303+
tool_friendly_name = "MetricsMentor"
304+
lti_platform = 'columbiasce.test.instructure.com'
305+
306+
def dispatch(self, *args, **kwargs) -> HttpResponse:
307+
if hasattr(settings, 'LTI_PLATFORM'):
308+
self.lti_platform = settings.LTI_PLATFORM
309+
310+
return super().dispatch(*args, **kwargs)
311+
312+
def get(self, request, *args, **kwargs) -> HttpResponse:
313+
context = {
314+
# Pass in a default lti_platform
315+
'lti_platform': ensure_https(self.lti_platform),
316+
}
317+
return render(request, 'registration/lti_registration.html', context)
318+
319+
def post(self, request, *args, **kwargs) -> HttpResponse:
320+
# Perform the registration steps. Typically this would involve:
321+
# 1. Register the platform in the tool
322+
issuer = request.POST.get('issuer')
323+
if issuer:
324+
issuer = issuer.strip()
325+
issuer = ensure_https(issuer)
326+
327+
authorization_endpoint = urljoin(issuer, '/api/lti/authorize_redirect')
328+
329+
token_endpoint = urljoin(issuer, '/login/oauth2/token')
330+
jwks_uri = urljoin(issuer, '/api/lti/security/jwks')
331+
332+
reg = self.register_platform_in_tool(
333+
issuer, {
334+
'issuer': issuer,
335+
'authorization_endpoint': authorization_endpoint,
336+
'token_endpoint': token_endpoint,
337+
'jwks_uri': jwks_uri,
338+
}
339+
)
340+
341+
# 2. Register the tool in the platform
342+
openid_config = self.get_openid_config()
343+
privacy_level = CanvasPrivacyLevel('public')
344+
host = request.get_host()
345+
target_link_uri = f'https://{host}/lti/launch/'
346+
icon_url = get_icon_url(settings, host)
347+
oidc_init_uri = urljoin(
348+
'https://{}'.format(host),
349+
reverse('oidc_init', kwargs={'registration_uuid': reg.uuid}))
350+
351+
lti_tool_config = CanvasLtiToolConfiguration(
352+
domain=self.lti_platform,
353+
target_link_uri=target_link_uri,
354+
claims=[
355+
'sub',
356+
'iss',
357+
'name',
358+
'given_name',
359+
'family_name',
360+
'nickname',
361+
'picture',
362+
'email',
363+
'locale'
364+
],
365+
messages=[
366+
CanvasLtiMessage(
367+
label='MetricsMentor',
368+
icon_uri=icon_url,
369+
type='LtiResourceLinkRequest',
370+
placements=['course_navigation'],
371+
target_link_uri=target_link_uri,
372+
default_enabled=True
373+
)
374+
],
375+
description=settings.LTI_TOOL_CONFIGURATION['description'],
376+
privacy_level=privacy_level,
377+
tool_id='metricsmentor',
378+
)
379+
tool_platform_registration = CanvasLtiRegistration(
380+
client_name='MetricsMentor',
381+
target_link_uri=target_link_uri,
382+
initiate_login_uri=oidc_init_uri,
383+
jwks_uri=jwks_uri,
384+
scopes=[
385+
'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem',
386+
],
387+
lti_tool_configuration=lti_tool_config,
388+
)
389+
390+
if settings.DEBUG:
391+
print('registration', tool_platform_registration.to_dict())
392+
client_id = self.register_tool_in_platform(
393+
openid_config, tool_platform_registration)
394+
395+
# 3. Update the platform registration with the client ID returned in
396+
# step 2
397+
reg.client_id = client_id
398+
reg.save()
399+
400+
# Return a page containing javascript that calls a special
401+
# platform postMessage endpoint
402+
return self.success_response()
403+
404+
133405
class LTICourseCreate(LoginRequiredMixin, View):
134406

135407
def notify_staff(self, course):

metricsmentor/settings_shared.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,19 @@
2222
'metricsmentor.main',
2323
'courseaffils',
2424
'lti_provider',
25+
'lti_tool',
2526
'contactus',
2627
'debug_toolbar',
2728
]
2829

2930
MIDDLEWARE += [ # noqa
3031
'django.middleware.csrf.CsrfViewMiddleware',
3132
'debug_toolbar.middleware.DebugToolbarMiddleware',
33+
'django.contrib.sessions.middleware.SessionMiddleware',
34+
'lti_tool.middleware.LtiLaunchMiddleware',
35+
'django.contrib.auth.middleware.AuthenticationMiddleware',
36+
'django.middleware.clickjacking.XFrameOptionsMiddleware',
37+
'lti_authentication.middleware.LtiLaunchAuthenticationMiddleware',
3238
]
3339

3440
THUMBNAIL_SUBDIR = "thumbs"
@@ -42,6 +48,9 @@
4248
DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
4349

4450
AUTHENTICATION_BACKENDS += [ # noqa
51+
'django.contrib.auth.backends.ModelBackend',
52+
# django-lti-authentication (LTI 1.3)
53+
'lti_authentication.backends.LtiLaunchAuthenticationBackend',
4554
'lti_provider.auth.LTIBackend',
4655
]
4756

@@ -55,11 +64,21 @@
5564
EMAIL_HOST_USER = os.environ.get('SES_USERNAME')
5665
EMAIL_HOST_PASSWORD = os.environ.get('SES_PASSWORD')
5766

67+
LTI_AUTHENTICATION = {
68+
'use_person_sourcedid': True,
69+
}
70+
71+
SESSION_ENGINE = 'django.contrib.sessions.backends.signed_cookies'
72+
SESSION_COOKIE_SECURE = True
73+
SESSION_COOKIE_HTTPONLY = True
74+
SESSION_COOKIE_DOMAIN = None
75+
SESSION_COOKIE_SAMESITE = 'Lax'
76+
X_FRAME_OPTIONS = 'SAMEORIGIN'
5877

5978
LTI_TOOL_CONFIGURATION = {
6079
'title': 'Metrics Mentor',
6180
'description': 'Econometrics Simulations',
62-
'launch_url': 'lti/',
81+
'launch_url': 'launch/',
6382
'embed_url': '',
6483
'embed_icon_url': '',
6584
'embed_tool_id': '',
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<title>MetricsMentor: LTI 1.3 Dynamic Registration</title>
5+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-LN+7fdVzj6u52u30Kp6M/trliBMCMKTyK833zpbD+pXdCLuTusPj697FH4R/5mcr" crossorigin="anonymous">
6+
</head>
7+
<body class="container">
8+
9+
<h5>
10+
MetricsMentor: LTI 1.3 Dynamic Registration
11+
</h5>
12+
13+
<form method="post">
14+
{% csrf_token %}
15+
<div class="mb-3">
16+
<label for="issuerInput" class="form-label">Issuer</label>
17+
<input type="text" class="form-control" name="issuer" required
18+
id="issuerInput" aria-describedby="issuerHelp"
19+
value="{{ lti_platform }}">
20+
<div id="issuerHelp" class="form-text">e.g.: https://courseworks2.columbia.edu/</div>
21+
</div>
22+
<button type="submit" class="btn btn-primary">Submit</button>
23+
</form>
24+
25+
</body>
26+
</html>

0 commit comments

Comments
 (0)