diff --git a/.coveragerc b/.coveragerc index 3a33749e6757bbeab1059cf1bb9b35095f98f5b2..970aebfdd95a456c7f8f63564afa81f908011e25 100644 --- a/.coveragerc +++ b/.coveragerc @@ -2,3 +2,4 @@ [run] data_file = .coverage source = lti_consumer +omit = */urls.py diff --git a/lti_consumer/__init__.py b/lti_consumer/__init__.py index bdd1ff4c530841cdc7da9767f819899da181c0ca..8bc16deb250f2eef5aa7cb35a80084be3fc7c9ab 100644 --- a/lti_consumer/__init__.py +++ b/lti_consumer/__init__.py @@ -2,3 +2,4 @@ Runtime will load the XBlock class from here. """ from .lti_consumer import LtiConsumerXBlock +from .apps import LTIConsumerApp diff --git a/lti_consumer/apps.py b/lti_consumer/apps.py new file mode 100644 index 0000000000000000000000000000000000000000..f06d6d84b584eb07875c1d9f3ebfa0cd63389569 --- /dev/null +++ b/lti_consumer/apps.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +""" +lti_consumer Django application initialization. +""" +from __future__ import absolute_import, unicode_literals + +from django.apps import AppConfig + + +class LTIConsumerApp(AppConfig): + """ + Configuration for the lti_consumer Django application. + """ + + name = 'lti_consumer' + + # Set LMS urls for LTI endpoints + # Urls are under /api/lti_consumer/ + plugin_app = { + 'url_config': { + 'lms.djangoapp': { + 'namespace': 'lti_consumer', + 'regex': '^api/', + 'relative_path': 'plugin.urls', + } + } + } diff --git a/lti_consumer/lti_1p3/__init__.py b/lti_consumer/lti_1p3/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lti_consumer/lti_1p3/constants.py b/lti_consumer/lti_1p3/constants.py new file mode 100644 index 0000000000000000000000000000000000000000..c0d93a0644a987a389242e5ad44dfcbcfb4fff97 --- /dev/null +++ b/lti_consumer/lti_1p3/constants.py @@ -0,0 +1,35 @@ +""" +LTI 1.3 Constants definition file + +This includes the LTI Base message, OAuth2 scopes, and +lists of required and optional parameters required for +LTI message generation and validation. +""" + +LTI_BASE_MESSAGE = { + # Claim type: fixed key with value `LtiResourceLinkRequest` + # http://www.imsglobal.org/spec/lti/v1p3/#message-type-claim + "https://purl.imsglobal.org/spec/lti/claim/message_type": "LtiResourceLinkRequest", + + # LTI Claim version + # http://www.imsglobal.org/spec/lti/v1p3/#lti-version-claim + "https://purl.imsglobal.org/spec/lti/claim/version": "1.3.0", +} + +LTI_1P3_ROLE_MAP = { + 'staff': [ + 'http://purl.imsglobal.org/vocab/lis/v2/system/person#Administrator', + 'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Instructor', + 'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Student', + ], + 'instructor': [ + 'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Instructor', + 'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Student' + ], + 'student': [ + 'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Student' + ], + 'guest': [ + 'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Student' + ], +} diff --git a/lti_consumer/lti_1p3/consumer.py b/lti_consumer/lti_1p3/consumer.py new file mode 100644 index 0000000000000000000000000000000000000000..3ea978441ca205d5ca3ddfe9021df03992df33ae --- /dev/null +++ b/lti_consumer/lti_1p3/consumer.py @@ -0,0 +1,275 @@ +""" +LTI 1.3 Consumer implementation +""" +import json +import time + +# Quality checks failing due to know pylint bug +# pylint: disable=relative-import +from six.moves.urllib.parse import urlencode + +from Crypto.PublicKey import RSA +from jwkest.jwk import RSAKey +from jwkest.jws import JWS +from jwkest import jwk + +from .constants import LTI_1P3_ROLE_MAP, LTI_BASE_MESSAGE + + +class LtiConsumer1p3(object): + """ + LTI 1.3 Consumer Implementation + """ + def __init__( + self, + iss, + lti_oidc_url, + lti_launch_url, + client_id, + deployment_id, + rsa_key, + rsa_key_id + ): + """ + Initialize LTI 1.3 Consumer class + """ + self.iss = iss + self.oidc_url = lti_oidc_url + self.launch_url = lti_launch_url + self.client_id = client_id + self.deployment_id = deployment_id + + # Generate JWK from RSA key + self.jwk = RSAKey( + # Using the same key ID as client id + # This way we can easily serve multiple public + # keys on teh same endpoint and keep all + # LTI 1.3 blocks working + kid=rsa_key_id, + key=RSA.import_key(rsa_key) + ) + + # IMS LTI Claim data + self.lti_claim_user_data = None + self.lti_claim_launch_presentation = None + self.lti_claim_custom_parameters = None + + def _encode_and_sign(self, message): + """ + Encode and sign JSON with RSA key + """ + # The class instance that sets up the signing operation + # An RS 256 key is required for LTI 1.3 + _jws = JWS(message, alg="RS256", cty="JWT") + + # Encode and sign LTI message + return _jws.sign_compact([self.jwk]) + + @staticmethod + def _get_user_roles(role): + """ + Converts platform role into LTI compliant roles + + Used in roles claim: should return array of URI values + for roles that the user has within the message's context. + + Supported roles: + * Core - Administrator + * Institution - Instructor (non-core role) + * Institution - Student + + Reference: http://www.imsglobal.org/spec/lti/v1p3/#roles-claim + Role vocabularies: http://www.imsglobal.org/spec/lti/v1p3/#role-vocabularies + """ + lti_user_roles = set() + + if role: + # Raise value error if value doesn't exist in map + if role not in LTI_1P3_ROLE_MAP: + raise ValueError("Invalid role list provided.") + + # Add roles to list + lti_user_roles.update(LTI_1P3_ROLE_MAP[role]) + + return list(lti_user_roles) + + def prepare_preflight_url( + self, + callback_url, + hint="hint", + lti_hint="lti_hint" + ): + """ + Generates OIDC url with parameters + """ + oidc_url = self.oidc_url + "?" + parameters = { + "iss": self.iss, + "client_id": self.client_id, + "lti_deployment_id": self.deployment_id, + "target_link_uri": callback_url, + "login_hint": hint, + "lti_message_hint": lti_hint + } + + return { + "oidc_url": oidc_url + urlencode(parameters), + } + + def set_user_data( + self, + user_id, + role, + full_name=None, + email_address=None + ): + """ + Set user data/roles and convert to IMS Specification + + User Claim doc: http://www.imsglobal.org/spec/lti/v1p3/#user-identity-claims + Roles Claim doc: http://www.imsglobal.org/spec/lti/v1p3/#roles-claim + """ + self.lti_claim_user_data = { + # User identity claims + # sub: locally stable identifier for user that initiated the launch + "sub": user_id, + + # Roles claim + # Array of URI values for roles that the user has within the message's context + "https://purl.imsglobal.org/spec/lti/claim/roles": self._get_user_roles(role) + } + + # Additonal user identity claims + # Optional user data that can be sent to the tool, if the block is configured to do so + if full_name: + self.lti_claim_user_data.update({ + "name": full_name, + }) + + if email_address: + self.lti_claim_user_data.update({ + "email": email_address, + }) + + def set_launch_presentation_claim( + self, + document_target="iframe" + ): + """ + Optional: Set launch presentation claims + + http://www.imsglobal.org/spec/lti/v1p3/#launch-presentation-claim + """ + if document_target not in ['iframe', 'frame', 'window']: + raise ValueError("Invalid launch presentation format.") + + self.lti_claim_launch_presentation = { + # Launch presentation claim + "https://purl.imsglobal.org/spec/lti/claim/launch_presentation": { + # Can be one of: iframe, frame, window + "document_target": document_target, + # TODO: Add support for `return_url` handler to allow the tool + # to return error messages back to the lms. + # See the spec referenced above for more information. + }, + } + + def set_custom_parameters( + self, + custom_parameters + ): + """ + Stores custom parameters configured for LTI launch + """ + if not isinstance(custom_parameters, dict): + raise ValueError("Custom parameters must be a key/value dictionary.") + + self.lti_claim_custom_parameters = { + "https://purl.imsglobal.org/spec/lti/claim/custom": custom_parameters + } + + def generate_launch_request( + self, + preflight_response, + resource_link + ): + """ + Build LTI message from class parameters + + This will add all required parameters from the LTI 1.3 spec and any additional ones set in + the configuration and JTW encode the message using the provided key. + """ + # Start from base message + lti_message = LTI_BASE_MESSAGE.copy() + + # TODO: Validate preflight response + # Add base parameters + lti_message.update({ + # Issuer + "iss": self.iss, + + # Nonce from OIDC preflight launch request + "nonce": preflight_response.get("nonce"), + + # JWT aud and azp + "aud": [ + self.client_id + ], + "azp": self.client_id, + + # LTI Deployment ID Claim: + # String that identifies the platform-tool integration governing the message + # http://www.imsglobal.org/spec/lti/v1p3/#lti-deployment-id-claim + "https://purl.imsglobal.org/spec/lti/claim/deployment_id": self.deployment_id, + + # Target Link URI: actual endpoint for the LTI resource to display + # MUST be the same value as the target_link_uri passed by the platform in the OIDC login request + # http://www.imsglobal.org/spec/lti/v1p3/#target-link-uri + "https://purl.imsglobal.org/spec/lti/claim/target_link_uri": self.launch_url, + + # Resource link: stable and unique to each deployment_id + # This value MUST change if the link is copied or exported from one system or + # context and imported into another system or context + # http://www.imsglobal.org/spec/lti/v1p3/#resource-link-claim + "https://purl.imsglobal.org/spec/lti/claim/resource_link": { + "id": resource_link, + # Optional claims + # "title": "Introduction Assignment" + # "description": "Assignment to introduce who you are", + }, + }) + + # Check if user data is set, then append it to lti message + # Raise if isn't set, since some user data is required for the launch + if self.lti_claim_user_data: + lti_message.update(self.lti_claim_user_data) + else: + raise ValueError("Required user data isn't set.") + + # Set optional claims + # Launch presentation claim + if self.lti_claim_launch_presentation: + lti_message.update(self.lti_claim_launch_presentation) + + # Custom variables claim + if self.lti_claim_custom_parameters: + lti_message.update(self.lti_claim_custom_parameters) + + # Add `exp` and `iat` JWT attributes + lti_message.update({ + "iat": int(round(time.time())), + "exp": int(round(time.time()) + 3600) + }) + + return { + "state": preflight_response.get("state"), + "id_token": self._encode_and_sign(lti_message) + } + + def get_public_keyset(self): + """ + Export Public JWK + """ + public_keys = jwk.KEYS() + public_keys.append(self.jwk) + return json.loads(public_keys.dump_jwks()) diff --git a/lti_consumer/lti_consumer.py b/lti_consumer/lti_consumer.py index 9a1d9108199fef3a28a50f1a1c475aa404bea37b..02f2640e2e947790391543e63fa34b69a6766c79 100644 --- a/lti_consumer/lti_consumer.py +++ b/lti_consumer/lti_consumer.py @@ -54,6 +54,7 @@ from __future__ import absolute_import, unicode_literals import logging import re +import uuid from collections import namedtuple from importlib import import_module @@ -61,6 +62,7 @@ import six.moves.urllib.error import six.moves.urllib.parse import six import bleach +from Crypto.PublicKey import RSA from django.utils import timezone from webob import Response from xblock.core import List, Scope, String, XBlock @@ -72,9 +74,16 @@ from xblockutils.studio_editable import StudioEditableXBlockMixin from .exceptions import LtiError from .lti import LtiConsumer +from .lti_1p3.consumer import LtiConsumer1p3 from .oauth import log_authorization_header from .outcomes import OutcomeService -from .utils import _ +from .utils import ( + _, + get_lms_base, + get_lms_lti_keyset_link, + get_lms_lti_launch_link, +) + log = logging.getLogger(__name__) @@ -165,6 +174,7 @@ class LaunchTarget(object): # pylint: disable=bad-option-value, useless-object- @XBlock.needs('i18n') +@XBlock.wants('user') @XBlock.wants('settings') @XBlock.wants('lti-configuration') class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock): @@ -270,6 +280,48 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock): default="", scope=Scope.settings ) + + # LTI 1.3 fields + lti_version = String( + display_name=_("LTI Version"), + scope=Scope.settings, + values=[ + {"display_name": "LTI 1.1/1.2", "value": "lti_1p1"}, + {"display_name": "LTI 1.3", "value": "lti_1p3"}, + ], + default="lti_1p1", + ) + lti_1p3_launch_url = String( + display_name=_("LTI 1.3 Launch URL"), + default='', + scope=Scope.settings + ) + lti_1p3_oidc_url = String( + display_name=_("LTI 1.3 OIDC URL"), + default='', + scope=Scope.settings + ) + lti_1p3_tool_public_key = String( + display_name=_("LTI 1.3 Tool Public Key"), + default='', + scope=Scope.settings + ) + # Client ID and block key + lti_1p3_client_id = String( + display_name=_("LTI 1.3 Block Client ID"), + default=str(uuid.uuid4()), + scope=Scope.settings + ) + # This key isn't editable, and should be regenerated + # for every block created (and not be carried over) + # This isn't what happens right now though + lti_1p3_block_key = String( + display_name=_("LTI 1.3 Block Key"), + default=RSA.generate(2048).export_key('PEM'), + scope=Scope.settings + ) + + # LTI 1.1 fields lti_id = String( display_name=_("LTI ID"), help=_( @@ -297,6 +349,8 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock): default='', scope=Scope.settings ) + + # Misc custom_parameters = List( display_name=_("Custom Parameters"), help=_( @@ -435,12 +489,23 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock): # Possible editable fields editable_field_names = ( - 'display_name', 'description', 'lti_id', 'launch_url', 'custom_parameters', - 'launch_target', 'button_text', 'inline_height', 'modal_height', 'modal_width', - 'has_score', 'weight', 'hide_launch', 'accept_grades_past_due', 'ask_to_send_username', - 'ask_to_send_email', 'enable_processors', + 'display_name', 'description', + # LTI 1.3 variables + 'lti_version', 'lti_1p3_launch_url', 'lti_1p3_oidc_url', 'lti_1p3_tool_public_key', + # TODO: implement a proper default setter method on XBlock Fields API. + # This is just a workaround the issue. + 'lti_1p3_client_id', 'lti_1p3_block_key', + # LTI 1.1 variables + 'lti_id', 'launch_url', + # Other parameters + 'custom_parameters', 'launch_target', 'button_text', 'inline_height', 'modal_height', + 'modal_width', 'has_score', 'weight', 'hide_launch', 'accept_grades_past_due', + 'ask_to_send_username', 'ask_to_send_email', 'enable_processors', ) + # Author view + has_author_view = True + @staticmethod def workbench_scenarios(): """ @@ -723,6 +788,52 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock): close_date = due_date return close_date is not None and timezone.now() > close_date + def _get_lti1p3_consumer(self): + """ + Returns a preconfigured LTI 1.3 consumer. + + If the block is configured to use LTI 1.3, set up a + base LTI 1.3 consumer class with all block related + configuration services. + + This class does NOT store state between calls. + """ + return LtiConsumer1p3( + iss=get_lms_base(), + lti_oidc_url=self.lti_1p3_oidc_url, + lti_launch_url=self.lti_1p3_launch_url, + client_id=self.lti_1p3_client_id, + deployment_id="1", + rsa_key=self.lti_1p3_block_key, + rsa_key_id=self.lti_1p3_client_id + ) + + def author_view(self, context): + """ + XBlock author view of this component. + + If using LTI 1.1 it shows a launch preview of the XBlock. + If using LTI 1.3 it displays a fragment with parameters that + need to be set on the LTI Tool to make the integration work. + """ + if self.lti_version == "lti_1p1": + return self.student_view(context) + + fragment = Fragment() + loader = ResourceLoader(__name__) + context = { + "client": self.lti_1p3_client_id, + "deployment_id": "1", + "keyset_url": get_lms_lti_keyset_link(self.location), # pylint: disable=no-member + "oidc_callback": get_lms_lti_launch_link(), + "launch_url": self.lti_1p3_launch_url, + } + fragment.add_content(loader.render_mako_template('/templates/html/lti_1p3_studio.html', context)) + fragment.add_css(loader.load_unicode('static/css/student.css')) + fragment.add_javascript(loader.load_unicode('static/js/xblock_lti_consumer.js')) + fragment.initialize_js('LtiConsumerXBlock') + return fragment + def student_view(self, context): """ XBlock student view of this component. @@ -749,7 +860,7 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock): @XBlock.handler def lti_launch_handler(self, request, suffix=''): # pylint: disable=unused-argument """ - XBlock handler for launching the LTI provider. + XBlock handler for launching LTI 1.1 tools. Displays a form which is submitted via Javascript to send the LTI launch POST request to the LTI @@ -770,6 +881,91 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock): template = loader.render_mako_template('/templates/html/lti_launch.html', context) return Response(template, content_type='text/html') + @XBlock.handler + def lti_1p3_launch_handler(self, request, suffix=''): # pylint: disable=unused-argument + """ + XBlock handler for launching the LTI 1.3 tools. + + Displays a form with the OIDC preflight request and + submits it to the tool. + + Arguments: + request (xblock.django.request.DjangoWebobRequest): Request object for current HTTP request + + Returns: + webob.response: HTML LTI launch form + """ + lti_consumer = self._get_lti1p3_consumer() + context = lti_consumer.prepare_preflight_url( + callback_url=get_lms_lti_launch_link(), + hint=six.text_type(self.location), # pylint: disable=no-member + lti_hint=six.text_type(self.location) # pylint: disable=no-member + ) + + loader = ResourceLoader(__name__) + template = loader.render_mako_template('/templates/html/lti_1p3_oidc.html', context) + return Response(template, content_type='text/html') + + @XBlock.handler + def lti_1p3_launch_callback(self, request, suffix=''): # pylint: disable=unused-argument + """ + XBlock handler for launching the LTI 1.3 tool. + + This endpoint is only valid when a LTI 1.3 tool is being used. + + Returns: + webob.response: HTML LTI launch form or error page if misconfigured + """ + if self.lti_version != "lti_1p3": + return Response(status=404) + + loader = ResourceLoader(__name__) + context = {} + + lti_consumer = self._get_lti1p3_consumer() + + # Pass user data + lti_consumer.set_user_data( + user_id=self.runtime.user_id, + # Pass django user role to library + role=self.runtime.get_user_role() + ) + + # Set launch context + # Hardcoded for now, but we need to translate from + # self.launch_target to one of the LTI compliant names, + # either `iframe`, `frame` or `window` + # This is optional though + lti_consumer.set_launch_presentation_claim('iframe') + + context.update({ + "preflight_response": dict(request.GET), + "launch_request": lti_consumer.generate_launch_request( + resource_link=self.resource_link_id, + preflight_response=request.GET + ) + }) + + context.update({ + 'launch_url': self.lti_1p3_launch_url, + 'user': self.runtime.user_id + }) + template = loader.render_mako_template('/templates/html/lti_1p3_launch.html', context) + return Response(template, content_type='text/html') + + @XBlock.handler + def public_keyset_endpoint(self, request, suffix=''): # pylint: disable=unused-argument + """ + XBlock handler for launching the LTI provider. + """ + if self.lti_version == "lti_1p3": + return Response( + json_body=self._get_lti1p3_consumer().get_public_keyset(), + content_type='application/json', + content_disposition='attachment; filename=keyset.json' + ) + return Response(status=404) + @XBlock.handler def outcome_service_handler(self, request, suffix=''): # pylint: disable=unused-argument """ @@ -935,13 +1131,19 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock): allowed_attributes = dict(bleach.sanitizer.ALLOWED_ATTRIBUTES, **{'img': ['src', 'alt']}) sanitized_comment = bleach.clean(self.score_comment, tags=allowed_tags, attributes=allowed_attributes) + # Set launch handler depending on LTI version + lti_block_launch_handler = self.runtime.handler_url(self, 'lti_launch_handler').rstrip('/?') + if self.lti_version == 'lti_1p3': + lti_block_launch_handler = self.runtime.handler_url(self, 'lti_1p3_launch_handler').rstrip('/?') + return { 'launch_url': self.launch_url.strip(), + 'lti_1p3_launch_url': self.lti_1p3_launch_url.strip(), 'element_id': self.location.html_id(), # pylint: disable=no-member 'element_class': self.category, # pylint: disable=no-member 'launch_target': self.launch_target, 'display_name': self.display_name, - 'form_url': self.runtime.handler_url(self, 'lti_launch_handler').rstrip('/?'), + 'form_url': lti_block_launch_handler, 'hide_launch': self.hide_launch, 'has_score': self.has_score, 'weight': self.weight, diff --git a/lti_consumer/plugin/__init__.py b/lti_consumer/plugin/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lti_consumer/plugin/urls.py b/lti_consumer/plugin/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..8bfc8789e861e63d09aaec1d5ba4560ca5f972b4 --- /dev/null +++ b/lti_consumer/plugin/urls.py @@ -0,0 +1,27 @@ +""" +URL mappings for LTI Consumer plugin. +""" + +from __future__ import absolute_import + +from django.conf import settings +from django.conf.urls import url + +from .views import ( + public_keyset_endpoint, + launch_gate_endpoint, +) + + +urlpatterns = [ + url( + 'lti_consumer/v1/public_keysets/{}$'.format(settings.USAGE_ID_PATTERN), + public_keyset_endpoint, + name='lti_consumer.public_keyset_endpoint' + ), + url( + 'lti_consumer/v1/launch/(?:/(?P<suffix>.*))?$', + launch_gate_endpoint, + name='lti_consumer.launch_gate' + ) +] diff --git a/lti_consumer/plugin/views.py b/lti_consumer/plugin/views.py new file mode 100644 index 0000000000000000000000000000000000000000..14aa4c977cd1cd3d01087a880a28011c9fde1458 --- /dev/null +++ b/lti_consumer/plugin/views.py @@ -0,0 +1,56 @@ +""" +LTI consumer plugin passthrough views +""" + +from django.http import HttpResponse + +from opaque_keys.edx.keys import UsageKey # pylint: disable=import-error +from lms.djangoapps.courseware.module_render import ( # pylint: disable=import-error + handle_xblock_callback, + handle_xblock_callback_noauth, +) + + +def public_keyset_endpoint(request, usage_id=None): + """ + Gate endpoint to fetch public keysets from a problem + + This is basically a passthrough function that uses the + OIDC response parameter `login_hint` to locate the block + and run the proper handler. + """ + try: + usage_key = UsageKey.from_string(usage_id) + + return handle_xblock_callback_noauth( + request=request, + course_id=str(usage_key.course_key), + usage_id=str(usage_key), + handler='public_keyset_endpoint' + ) + except: # pylint: disable=bare-except + return HttpResponse(status=404) + + +def launch_gate_endpoint(request, suffix): + """ + Gate endpoint that triggers LTI launch endpoint XBlock handler + + This is basically a passthrough function that uses the + OIDC response parameter `login_hint` to locate the block + and run the proper handler. + """ + try: + usage_key = UsageKey.from_string( + request.GET.get('login_hint') + ) + + return handle_xblock_callback( + request=request, + course_id=str(usage_key.course_key), + usage_id=str(usage_key), + handler='lti_1p3_launch_callback', + suffix=suffix + ) + except: # pylint: disable=bare-except + return HttpResponse(status=404) diff --git a/lti_consumer/templates/html/lti_1p3_launch.html b/lti_consumer/templates/html/lti_1p3_launch.html new file mode 100644 index 0000000000000000000000000000000000000000..11356c1e9c121282931566c48b315824bad645c7 --- /dev/null +++ b/lti_consumer/templates/html/lti_1p3_launch.html @@ -0,0 +1,30 @@ +<!DOCTYPE HTML> +<html> + <head> + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> + <title>LTI</title> + </head> + <body> + <form + id="lti-launch" + action="${launch_url}" + method="post" + style="display: none;" + encType="application/x-www-form-urlencoded" + > + % for param_name, param_value in launch_request.items(): + <input name="${param_name}" value="${param_value}" /> + % endfor + + <input type="submit" value="Press to Launch" /> + </form> + <script type="text/javascript"> + (function (d) { + var element = d.getElementById("lti-launch"); + if (element) { + element.submit(); + } + }(document)); + </script> + </body> +</html> diff --git a/lti_consumer/templates/html/lti_1p3_oidc.html b/lti_consumer/templates/html/lti_1p3_oidc.html new file mode 100644 index 0000000000000000000000000000000000000000..861b4f587e5f72fb9c9a303b9c25207c8372c645 --- /dev/null +++ b/lti_consumer/templates/html/lti_1p3_oidc.html @@ -0,0 +1,20 @@ +<!DOCTYPE HTML> +<html> + <head> + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> + <title>LTI</title> + </head> + <body> + <a id="launch-link" href="${oidc_url}"> + Click here to launch activity. + </a> + <script type="text/javascript"> + (function (d) { + var element = d.getElementById("launch-link"); + if (element) { + element.click(); + } + }(document)); + </script> + </body> +</html> diff --git a/lti_consumer/templates/html/lti_1p3_studio.html b/lti_consumer/templates/html/lti_1p3_studio.html new file mode 100644 index 0000000000000000000000000000000000000000..e16c28ad63d6f19080d048282f4ba4d1a37a2729 --- /dev/null +++ b/lti_consumer/templates/html/lti_1p3_studio.html @@ -0,0 +1,41 @@ +<!DOCTYPE HTML> +<html> + <head> + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> + <title>LTI</title> + </head> + <body> + <p> + <b>LTI 1.3 Launches can only be performed from the LMS.</b> + </p> + + <p> + To set up the LTI integration, you need to register the LMS in the tool with the information provided below. + </p> + + <p> + <b>Client: </b> + ${client} + </p> + + <p> + <b>Deployment ID: </b> + ${deployment_id} + </p> + + <p> + <b>Keyset URL: </b> + ${keyset_url} + </p> + + <p> + <b>OAuth URL: </b> + N/A + </p> + + <p> + <b>OIDC Callback URL: </b> + ${oidc_callback} + </p> + </body> +</html> diff --git a/lti_consumer/templates/html/student.html b/lti_consumer/templates/html/student.html index 59f85a629c8b53a16665e3f3e9241c7c2bedb3e4..a49632b72e3c3bd484ae58bc233684fedd94f8ed 100644 --- a/lti_consumer/templates/html/student.html +++ b/lti_consumer/templates/html/student.html @@ -20,7 +20,7 @@ data-ask-to-send-email="${ask_to_send_email}" > -% if launch_url and not hide_launch: +% if (launch_url or lti_1p3_launch_url) and not hide_launch: % if launch_target in ['modal', 'new_window']: <section class="wrapper-lti-link"> % if description: diff --git a/lti_consumer/tests/unit/test_lti_1p3_consumer.py b/lti_consumer/tests/unit/test_lti_1p3_consumer.py new file mode 100644 index 0000000000000000000000000000000000000000..d0d89bb221481bce6787c1582ab18e3448e2ca98 --- /dev/null +++ b/lti_consumer/tests/unit/test_lti_1p3_consumer.py @@ -0,0 +1,298 @@ +""" +Unit tests for LTI 1.3 consumer implementation +""" +from __future__ import absolute_import, unicode_literals + +import json +import ddt + +from mock import Mock, patch +from django.test.testcases import TestCase +from six.moves.urllib.parse import urlparse, parse_qs + +from Crypto.PublicKey import RSA +from jwkest.jwk import load_jwks +from jwkest.jws import JWS + +from lti_consumer.lti_1p3.consumer import LtiConsumer1p3 + + +# Variables required for testing and verification +ISS = "http://test-platform.example/" +OIDC_URL = "http://test-platform/oidc" +LAUNCH_URL = "http://test-platform/launch" +CLIENT_ID = "1" +DEPLOYMENT_ID = "1" +# Consider storing a fixed key +RSA_KEY_ID = "1" +RSA_KEY = RSA.generate(2048).export_key('PEM') + + +# Test classes +@ddt.ddt +class TestLti1p3Consumer(TestCase): + """ + Unit tests for LtiConsumer1p3 + """ + def setUp(self): + super(TestLti1p3Consumer, self).setUp() + + # Set up consumer + self.lti_consumer = LtiConsumer1p3( + iss=ISS, + lti_oidc_url=OIDC_URL, + lti_launch_url=LAUNCH_URL, + client_id=CLIENT_ID, + deployment_id=DEPLOYMENT_ID, + rsa_key=RSA_KEY, + rsa_key_id=RSA_KEY_ID + ) + + def _setup_lti_user(self): + """ + Set up a minimal LTI message with only required parameters. + + Currently, the only required parameters are the user data, + but using a helper function to keep the usage consistent accross + all tests. + """ + self.lti_consumer.set_user_data( + user_id="1", + role="student", + ) + + def _get_lti_message( + self, + preflight_response=None, + resource_link="link" + ): + """ + Retrieves a base LTI message with fixed test parameters. + + This function has valid default values, so it can be used to test custom + parameters, but allows overriding them. + """ + if preflight_response is None: + preflight_response = {"nonce": "", "state": ""} + + return self.lti_consumer.generate_launch_request( + preflight_response, + resource_link + ) + + def _decode_token(self, token): + """ + Checks for a valid signarute and decodes JWT signed LTI message + + This also tests the public keyset function. + """ + public_keyset = self.lti_consumer.get_public_keyset() + key_set = load_jwks(json.dumps(public_keyset)) + + return JWS().verify_compact(token, keys=key_set) + + @ddt.data( + ( + 'student', + ['http://purl.imsglobal.org/vocab/lis/v2/institution/person#Student'] + ), + ( + 'staff', + [ + 'http://purl.imsglobal.org/vocab/lis/v2/system/person#Administrator', + 'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Instructor', + 'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Student', + ] + ) + ) + @ddt.unpack + def test_get_user_roles(self, role, expected_output): + """ + Check that user roles are correctly translated to LTI 1.3 compliant rolenames. + """ + roles = self.lti_consumer._get_user_roles(role) # pylint: disable=protected-access + self.assertItemsEqual(roles, expected_output) + + def test_get_user_roles_invalid(self): + """ + Check that invalid user roles are throw a ValueError. + """ + with self.assertRaises(ValueError): + self.lti_consumer._get_user_roles('invalid') # pylint: disable=protected-access + + def test_prepare_preflight_url(self): + """ + Check if preflight request is properly formed and has all required keys. + """ + preflight_request_data = self.lti_consumer.prepare_preflight_url( + callback_url=LAUNCH_URL, + hint="test-hint", + lti_hint="test-lti-hint" + ) + + # Extract and check parameters from OIDC launch request url + parameters = parse_qs(urlparse(preflight_request_data['oidc_url']).query) + self.assertItemsEqual( + parameters.keys(), + [ + 'iss', + 'login_hint', + 'lti_message_hint', + 'client_id', + 'target_link_uri', + 'lti_deployment_id' + ] + ) + self.assertEqual(parameters['iss'][0], ISS) + self.assertEqual(parameters['client_id'][0], CLIENT_ID) + self.assertEqual(parameters['login_hint'][0], "test-hint") + self.assertEqual(parameters['lti_message_hint'][0], "test-lti-hint") + self.assertEqual(parameters['lti_deployment_id'][0], DEPLOYMENT_ID) + self.assertEqual(parameters['target_link_uri'][0], LAUNCH_URL) + + @ddt.data( + # User with no roles + ( + {"user_id": "1", "role": ''}, + { + "sub": "1", + "https://purl.imsglobal.org/spec/lti/claim/roles": [] + } + ), + # Student user, no optional data + ( + {"user_id": "1", "role": 'student'}, + { + "sub": "1", + "https://purl.imsglobal.org/spec/lti/claim/roles": [ + "http://purl.imsglobal.org/vocab/lis/v2/institution/person#Student" + ] + } + ), + # User with extra data + ( + {"user_id": "1", "role": '', "full_name": "Jonh", "email_address": "jonh@example.com"}, + { + "sub": "1", + "https://purl.imsglobal.org/spec/lti/claim/roles": [], + "name": "Jonh", + "email": "jonh@example.com" + } + ), + + ) + @ddt.unpack + def test_set_user_data(self, data, expected_output): + """ + Check if setting user data works + """ + self.lti_consumer.set_user_data(**data) + self.assertEqual( + self.lti_consumer.lti_claim_user_data, + expected_output + ) + + @ddt.data( + "iframe", + "frame", + "window" + ) + def test_set_valid_presentation_claim(self, target): + """ + Check if setting presentation claim data works + """ + self._setup_lti_user() + self.lti_consumer.set_launch_presentation_claim(document_target=target) + self.assertEqual( + self.lti_consumer.lti_claim_launch_presentation, + { + "https://purl.imsglobal.org/spec/lti/claim/launch_presentation": { + "document_target": target + } + } + ) + + # Prepare LTI message + launch_request = self._get_lti_message() + + # Decode and verify message + decoded = self._decode_token(launch_request['id_token']) + self.assertIn( + "https://purl.imsglobal.org/spec/lti/claim/launch_presentation", + decoded.keys() + ) + self.assertEqual( + decoded["https://purl.imsglobal.org/spec/lti/claim/launch_presentation"], + { + "document_target": target + } + ) + + def test_set_invalid_presentation_claim(self): + """ + Check if setting invalid presentation claim data raises + """ + with self.assertRaises(ValueError): + self.lti_consumer.set_launch_presentation_claim(document_target="invalid") + + def test_check_no_user_data_error(self): + """ + Check if the launch request fails if no user data is set. + """ + with self.assertRaises(ValueError): + self.lti_consumer.generate_launch_request( + preflight_response=Mock(), + resource_link=Mock() + ) + + @patch('time.time', return_value=1000) + def test_launch_request(self, mock_time): + """ + Check if the launch request works if user data is set. + """ + self._setup_lti_user() + launch_request = self._get_lti_message( + preflight_response={ + "nonce": "test", + "state": "state" + }, + resource_link="link" + ) + + self.assertEqual(mock_time.call_count, 2) + + # Check launch request contents + self.assertItemsEqual(launch_request.keys(), ['state', 'id_token']) + self.assertEqual(launch_request['state'], 'state') + # TODO: Decode and check token + + def test_custom_parameters(self): + """ + Check if custom parameters are properly set. + """ + custom_parameters = { + "custom": "parameter", + } + + self._setup_lti_user() + self.lti_consumer.set_custom_parameters(custom_parameters) + + launch_request = self._get_lti_message() + + # Decode and verify message + decoded = self._decode_token(launch_request['id_token']) + self.assertIn( + 'https://purl.imsglobal.org/spec/lti/claim/custom', + decoded.keys() + ) + self.assertEqual( + decoded["https://purl.imsglobal.org/spec/lti/claim/custom"], + custom_parameters + ) + + def test_invalid_custom_parameters(self): + """ + Check if invalid custom parameters raise exceptions. + """ + with self.assertRaises(ValueError): + self.lti_consumer.set_custom_parameters("invalid") diff --git a/lti_consumer/tests/unit/test_lti_consumer.py b/lti_consumer/tests/unit/test_lti_consumer.py index 5767ee66a5d0bdb6215079d967c80790e0d64f44..a56613fe405891dccbe91956907a08fa38e8d4f5 100644 --- a/lti_consumer/tests/unit/test_lti_consumer.py +++ b/lti_consumer/tests/unit/test_lti_consumer.py @@ -812,14 +812,17 @@ class TestGetContext(TestLtiConsumerXBlock): Unit tests for LtiConsumerXBlock._get_context_for_template() """ - def test_context_keys(self): + @ddt.data('lti_1p1', 'lti_1p3') + def test_context_keys(self, lti_version): """ Test `_get_context_for_template` returns dict with correct keys """ + self.xblock.lti_version = lti_version context_keys = ( - 'launch_url', 'element_id', 'element_class', 'launch_target', 'display_name', 'form_url', 'hide_launch', - 'has_score', 'weight', 'module_score', 'comment', 'description', 'ask_to_send_username', - 'ask_to_send_email', 'button_text', 'modal_vertical_offset', 'modal_horizontal_offset', 'modal_width', + 'launch_url', 'lti_1p3_launch_url', 'element_id', 'element_class', 'launch_target', + 'display_name', 'form_url', 'hide_launch', 'has_score', 'weight', 'module_score', + 'comment', 'description', 'ask_to_send_username', 'ask_to_send_email', 'button_text', + 'modal_vertical_offset', 'modal_horizontal_offset', 'modal_width', 'accept_grades_past_due' ) context = self.xblock._get_context_for_template() # pylint: disable=protected-access @@ -912,3 +915,84 @@ class TestGetModalPositionOffset(TestLtiConsumerXBlock): # modal_height defaults to 80, so offset should equal 10 self.assertEqual(offset, 10) + + +class TestLtiConsumer1p3XBlock(TestCase): + """ + Unit tests for LtiConsumerXBlock when using an LTI 1.3 tool. + """ + def setUp(self): + super(TestLtiConsumer1p3XBlock, self).setUp() + + self.xblock_attributes = { + 'lti_version': 'lti_1p3', + 'lti_1p3_launch_url': 'http://tool.example/launch', + 'lti_1p3_oidc_url': 'http://tool.example/oidc', + 'lti_1p3_tool_public_key': '' + } + self.xblock = make_xblock('lti_consumer', LtiConsumerXBlock, self.xblock_attributes) + + # pylint: disable=unused-argument + @patch('lti_consumer.utils.get_lms_base', return_value="https://example.com") + @patch('lti_consumer.lti_consumer.get_lms_base', return_value="https://example.com") + def test_launch_request(self, mock_url, mock_url_2): + """ + Test LTI 1.3 launch request + """ + response = self.xblock.lti_1p3_launch_handler(make_request('', 'GET')) + self.assertEqual(response.status_code, 200) + + # Check if tool OIDC url is on page + self.assertIn(self.xblock_attributes['lti_1p3_oidc_url'], response.body) + + # pylint: disable=unused-argument + @patch('lti_consumer.utils.get_lms_base', return_value="https://example.com") + @patch('lti_consumer.lti_consumer.get_lms_base', return_value="https://example.com") + def test_launch_callback_endpoint(self, mock_url, mock_url_2): + """ + Test that the LTI 1.3 callback endpoind. + """ + self.xblock.runtime.get_user_role.return_value = 'student' + self.xblock.runtime.user_id = 2 + + # Craft request sent back by LTI tool + request = make_request('', 'GET') + request.query_string = "state=state_test_123&nonce=nonce&login_hint=oidchint<i_message_hint=ltihint" + + response = self.xblock.lti_1p3_launch_callback(request) + + # Check response and assert that state was inserted + self.assertEqual(response.status_code, 200) + self.assertIn("state", response.body) + self.assertIn("state_test_123", response.body) + + def test_launch_callback_endpoint_when_using_lti_1p1(self): + """ + Test that the LTI 1.3 callback endpoind is unavailable when using 1.1. + """ + self.xblock.lti_version = 'lti_1p1' + self.xblock.save() + response = self.xblock.lti_1p3_launch_callback(make_request('', 'GET')) + self.assertEqual(response.status_code, 404) + + # pylint: disable=unused-argument + @patch('lti_consumer.utils.get_lms_base', return_value="https://example.com") + @patch('lti_consumer.lti_consumer.get_lms_base', return_value="https://example.com") + def test_keyset_endpoint(self, mock_url, mock_url_2): + """ + Test that the LTI 1.3 keyset endpoind. + """ + response = self.xblock.public_keyset_endpoint(make_request('', 'GET')) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content_type, 'application/json') + self.assertEqual(response.content_disposition, 'attachment; filename=keyset.json') + + def test_keyset_endpoint_when_using_lti_1p1(self): + """ + Test that the LTI 1.3 keyset endpoind is unavailable when using 1.1. + """ + self.xblock.lti_version = 'lti_1p1' + self.xblock.save() + + response = self.xblock.public_keyset_endpoint(make_request('', 'GET')) + self.assertEqual(response.status_code, 404) diff --git a/lti_consumer/utils.py b/lti_consumer/utils.py index 75cc9cd2bc138a211158d2f593654ceb19a2fd04..9e61613e58d4a56efd67d238ab3e2f0f65878630 100644 --- a/lti_consumer/utils.py +++ b/lti_consumer/utils.py @@ -1,11 +1,48 @@ # -*- coding: utf-8 -*- """ -Make '_' a no-op so we can scrape strings +Utility functions for LTI Consumer block """ +from six import text_type +from django.conf import settings def _(text): """ - :return text + Make '_' a no-op so we can scrape strings """ return text + + +def get_lms_base(): + """ + Returns LMS base url to be used as issuer on OAuth2 flows + + TODO: This needs to be improved and account for Open edX sites and + organizations. + One possible improvement is to use `contentstore.get_lms_link_for_item` + and strip the base domain name. + """ + return settings.LMS_ROOT_URL + + +def get_lms_lti_keyset_link(location): + """ + Returns an LMS link to LTI public keyset endpoint + + :param location: the location of the block + """ + return u"{lms_base}/api/lti_consumer/v1/public_keysets/{location}".format( + lms_base=get_lms_base(), + location=text_type(location), + ) + + +def get_lms_lti_launch_link(): + """ + Returns an LMS link to LTI Launch endpoint + + :param location: the location of the block + """ + return u"{lms_base}/api/lti_consumer/v1/launch/".format( + lms_base=get_lms_base(), + ) diff --git a/setup.py b/setup.py index db3d15c90a126f6dd15963b6fb0c36e5b5f7b4ce..01a6a99ea412757885b8dced6325fd03e395e5e3 100644 --- a/setup.py +++ b/setup.py @@ -50,7 +50,7 @@ with open('README.rst') as _f: setup( name='lti-consumer-xblock', - version='1.4.2', + version='2.0.0', description='This XBlock implements the consumer side of the LTI specification.', long_description=long_description, long_description_content_type='text/markdown', @@ -64,6 +64,9 @@ setup( entry_points={ 'xblock.v1': [ 'lti_consumer = lti_consumer:LtiConsumerXBlock', + ], + 'lms.djangoapp': [ + "lti_consumer = lti_consumer:LTIConsumerApp", ] }, package_data=package_data("lti_consumer", ["static", "templates", "public", "translations"]),