From 5881d202d693b3050c34a91cbb97294712a9b5c6 Mon Sep 17 00:00:00 2001 From: michaelroytman Date: Mon, 15 Aug 2022 14:53:33 -0400 Subject: [PATCH 01/12] feat: modify get_lti_1p3_launch_start_url to remove deep_link_launch and dl_content parameters This commit modifies the get_lti_1p3_launch_start_url API method to remove the deep_link_launch and dl_content parameters. They are replaced with an lti_hint parameter. Callers of this function will need to supply the appropriate lti_hint. This commit also includes changes to callers of this method to include the appropriate lti_hint. This makes it easy for the proctoring preflight to generate the launch URL without adding another "proctoring" parameter to get_lti_1p3_launch_start_url. --- lti_consumer/api.py | 17 +++-------------- .../templatetags/get_dl_lti_launch_url.py | 2 +- lti_consumer/tests/unit/test_api.py | 2 +- 3 files changed, 5 insertions(+), 16 deletions(-) diff --git a/lti_consumer/api.py b/lti_consumer/api.py index d03be168..1ccad766 100644 --- a/lti_consumer/api.py +++ b/lti_consumer/api.py @@ -144,7 +144,7 @@ def get_lti_1p3_launch_info(config_id=None, block=None): } -def get_lti_1p3_launch_start_url(config_id=None, block=None, deep_link_launch=False, dl_content_id=None, hint=""): +def get_lti_1p3_launch_start_url(config_id=None, block=None, lti_hint="", hint=""): """ Computes and retrieves the LTI URL that starts the OIDC flow. """ @@ -152,17 +152,6 @@ def get_lti_1p3_launch_start_url(config_id=None, block=None, deep_link_launch=Fa lti_config = _get_lti_config(config_id, block) lti_consumer = lti_config.get_lti_consumer() - # Change LTI hint depending on LTI launch type - lti_hint = "" - # Case 1: Performs Deep Linking configuration flow. Triggered by staff users to - # configure tool options and select content to be presented. - if deep_link_launch: - lti_hint = "deep_linking_launch" - # Case 2: Perform a LTI Launch for `ltiResourceLink` content types, since they - # need to use the launch mechanism from the callback view. - elif dl_content_id: - lti_hint = f"deep_linking_content_launch:{dl_content_id}" - # Prepare and return OIDC flow start url return lti_consumer.prepare_preflight_url( hint=hint, @@ -189,7 +178,7 @@ def get_lti_1p3_content_url(config_id=None, block=None, hint=""): # If there's no content items, return normal LTI launch URL if not content_items.count(): - return get_lti_1p3_launch_start_url(config_id, block, hint=hint) + return get_lti_1p3_launch_start_url(config_id, block, lti_hint="deep_linking", hint=hint) # If there's a single `ltiResourceLink` content, return the launch # url for that specif deep link @@ -197,7 +186,7 @@ def get_lti_1p3_content_url(config_id=None, block=None, hint=""): return get_lti_1p3_launch_start_url( config_id, block, - dl_content_id=content_items.get().id, + lti_hint=f"deep_linking_content_launch:{content_items.get().id}", hint=hint, ) diff --git a/lti_consumer/templatetags/get_dl_lti_launch_url.py b/lti_consumer/templatetags/get_dl_lti_launch_url.py index 1dd97c9c..7abcaf29 100644 --- a/lti_consumer/templatetags/get_dl_lti_launch_url.py +++ b/lti_consumer/templatetags/get_dl_lti_launch_url.py @@ -21,6 +21,6 @@ def get_dl_lti_launch_url(content_item): """ return get_lti_1p3_launch_start_url( config_id=content_item.lti_configuration.id, - dl_content_id=content_item.id, + lti_hint=f"deep_linking_content_launch:{content_item.id}", hint=str(content_item.lti_configuration.location), ) diff --git a/lti_consumer/tests/unit/test_api.py b/lti_consumer/tests/unit/test_api.py index c034a49d..0729f125 100644 --- a/lti_consumer/tests/unit/test_api.py +++ b/lti_consumer/tests/unit/test_api.py @@ -360,7 +360,7 @@ def test_retrieve_url(self): self.assertIn('lti_message_hint=', launch_url) # Call API for deep link launch - launch_url = get_lti_1p3_launch_start_url(block=self.xblock, deep_link_launch=True) + launch_url = get_lti_1p3_launch_start_url(block=self.xblock, lti_hint="deep_linking") self.assertIn('lti_message_hint=deep_linking_launch', launch_url) From ae93bf713119e57d714368fc61cd54e3d57bf881 Mon Sep 17 00:00:00 2001 From: michaelroytman Date: Thu, 11 Aug 2022 14:49:08 -0400 Subject: [PATCH 02/12] feat: add proctoring enabled field to LTIConfiguration --- ...configuration_lti_1p3_proctoring_enabled.py | 18 ++++++++++++++++++ lti_consumer/models.py | 8 ++++++++ 2 files changed, 26 insertions(+) create mode 100644 lti_consumer/migrations/0016_lticonfiguration_lti_1p3_proctoring_enabled.py diff --git a/lti_consumer/migrations/0016_lticonfiguration_lti_1p3_proctoring_enabled.py b/lti_consumer/migrations/0016_lticonfiguration_lti_1p3_proctoring_enabled.py new file mode 100644 index 00000000..21198991 --- /dev/null +++ b/lti_consumer/migrations/0016_lticonfiguration_lti_1p3_proctoring_enabled.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.14 on 2022-08-11 18:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('lti_consumer', '0015_add_additional_1p3_fields'), + ] + + operations = [ + migrations.AddField( + model_name='lticonfiguration', + name='lti_1p3_proctoring_enabled', + field=models.BooleanField(default=False, help_text='Enable LTI Proctoring Services', verbose_name='Enable LTI Proctoring Services'), + ), + ] diff --git a/lti_consumer/models.py b/lti_consumer/models.py index e8736d2e..37d4a927 100644 --- a/lti_consumer/models.py +++ b/lti_consumer/models.py @@ -227,6 +227,14 @@ class LtiConfiguration(models.Model): 'grades.' ) + # LTI Proctoring Service Related Variables + # TODO: handle invalid config if not LTI 1.3 + lti_1p3_proctoring_enabled = models.BooleanField( + "Enable LTI Proctoring Services", + default=False, + help_text='Enable LTI Proctoring Services', + ) + # Empty variable that'll hold the block once it's retrieved # from the modulestore or preloaded _block = None From 0f9ccd9860c501d851801478cf9a82c987886c34 Mon Sep 17 00:00:00 2001 From: michaelroytman Date: Mon, 15 Aug 2022 15:21:46 -0400 Subject: [PATCH 03/12] fix: grammar in exception message --- lti_consumer/lti_1p3/exceptions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lti_consumer/lti_1p3/exceptions.py b/lti_consumer/lti_1p3/exceptions.py index bc9551ff..c8df71a7 100644 --- a/lti_consumer/lti_1p3/exceptions.py +++ b/lti_consumer/lti_1p3/exceptions.py @@ -67,7 +67,7 @@ class LtiAdvantageServiceNotSetUp(Lti1p3Exception): class LtiNrpsServiceNotSetUp(Lti1p3Exception): - message = "LTI Names and Role Provisioning Services is not set up." + message = "The LTI Names and Role Provisioning Service is not set up." class LtiDeepLinkingContentTypeNotSupported(Lti1p3Exception): From 20daae20ee47c21d5fcf0b408a8f22837de61923 Mon Sep 17 00:00:00 2001 From: michaelroytman Date: Thu, 11 Aug 2022 09:21:35 -0400 Subject: [PATCH 04/12] fix: add additional error handling for invalid signature --- lti_consumer/lti_1p3/exceptions.py | 4 ++++ lti_consumer/lti_1p3/key_handlers.py | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/lti_consumer/lti_1p3/exceptions.py b/lti_consumer/lti_1p3/exceptions.py index c8df71a7..8c6cd4c6 100644 --- a/lti_consumer/lti_1p3/exceptions.py +++ b/lti_consumer/lti_1p3/exceptions.py @@ -30,6 +30,10 @@ class NoSuitableKeys(Lti1p3Exception): message = "JWKS could not be loaded from the URL." +class BadJwtSignature(Lti1p3Exception): + message = "The JWT signature is invalid." + + class UnknownClientId(Lti1p3Exception): pass diff --git a/lti_consumer/lti_1p3/key_handlers.py b/lti_consumer/lti_1p3/key_handlers.py index 7f52b4a7..64048c5d 100644 --- a/lti_consumer/lti_1p3/key_handlers.py +++ b/lti_consumer/lti_1p3/key_handlers.py @@ -10,7 +10,7 @@ import json from Cryptodome.PublicKey import RSA -from jwkest import BadSyntax, WrongNumberOfParts, jwk +from jwkest import BadSignature, BadSyntax, WrongNumberOfParts, jwk from jwkest.jwk import RSAKey, load_jwks_from_url from jwkest.jws import JWS, NoSuitableSigningKeys from jwkest.jwt import JWT @@ -124,6 +124,8 @@ def validate_and_decode(self, token): raise exceptions.NoSuitableKeys() from err except (BadSyntax, WrongNumberOfParts) as err: raise exceptions.MalformedJwtToken() from err + except BadSignature as err: + raise exceptions.BadJwtSignature() from err class PlatformKeyHandler: From eeeb0609d9c4247e80268707ca92790ea183be1d Mon Sep 17 00:00:00 2001 From: michaelroytman Date: Mon, 15 Aug 2022 14:46:59 -0400 Subject: [PATCH 05/12] feat: add check_token_claim function for use by LtiProctoringConsumer --- lti_consumer/utils.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/lti_consumer/utils.py b/lti_consumer/utils.py index c29eb45f..2bbf4e00 100644 --- a/lti_consumer/utils.py +++ b/lti_consumer/utils.py @@ -3,9 +3,12 @@ """ import logging from importlib import import_module +from urllib.parse import urljoin from django.conf import settings +from django.urls import reverse +from lti_consumer.lti_1p3.exceptions import InvalidClaimValue, MissingRequiredClaim from lti_consumer.plugin.compat import get_external_config_waffle_flag, get_database_config_waffle_flag log = logging.getLogger(__name__) @@ -122,6 +125,26 @@ def get_lti_nrps_context_membership_url(lti_config_id): ) +def get_lti_proctoring_start_assessment_url(): + """ + Return the LTI Proctoring Services start assessment URL. + """ + urljoin(get_lms_base(), reverse('lti:start-assessment')) + + +def check_token_claim(token, claim_key, expected_value, invalid_claim_error_msg): + """ + Check that the claim in the token with the key claim_key matches the expected value. If not, + raise an InvalidClaimValue exception with the invalid_claim_error_msg. + """ + claim_value = token.get(claim_key) + + if claim_value is None: + raise MissingRequiredClaim(f"Token is missing required {claim_key} claim.") + if claim_value != expected_value: + raise InvalidClaimValue(invalid_claim_error_msg) + + def resolve_custom_parameter_template(xblock, template): """ Return the value processed according to the template processor. From 1d4743119bc3ad1c4c6eee426c0586b37906ec58 Mon Sep 17 00:00:00 2001 From: michaelroytman Date: Mon, 15 Aug 2022 14:53:04 -0400 Subject: [PATCH 06/12] feat: add LtiProctoringConsumer --- lti_consumer/lti_1p3/constants.py | 3 + lti_consumer/lti_1p3/consumer.py | 227 ++++++++++++++++++++++++++++++ 2 files changed, 230 insertions(+) diff --git a/lti_consumer/lti_1p3/constants.py b/lti_consumer/lti_1p3/constants.py index 5f85d297..1cdbaf92 100644 --- a/lti_consumer/lti_1p3/constants.py +++ b/lti_consumer/lti_1p3/constants.py @@ -83,3 +83,6 @@ class LTI_1P3_CONTEXT_TYPE(Enum): # pylint: disable=invalid-name course_offering = 'http://purl.imsglobal.org/vocab/lis/v2/course#CourseOffering' course_section = 'http://purl.imsglobal.org/vocab/lis/v2/course#CourseSection' course_template = 'http://purl.imsglobal.org/vocab/lis/v2/course#CourseTemplate' + + +LTI_PROCTORING_DATA_KEYS = ['attempt_number', 'resource_link', 'session_data', 'start_assessment_url'] diff --git a/lti_consumer/lti_1p3/consumer.py b/lti_consumer/lti_1p3/consumer.py index f2b2a4f6..05a6dadd 100644 --- a/lti_consumer/lti_1p3/consumer.py +++ b/lti_consumer/lti_1p3/consumer.py @@ -3,6 +3,7 @@ """ from urllib.parse import urlencode +from lti_consumer.utils import check_token_claim from . import constants, exceptions from .constants import ( LTI_1P3_ROLE_MAP, @@ -10,6 +11,7 @@ LTI_1P3_ACCESS_TOKEN_REQUIRED_CLAIMS, LTI_1P3_ACCESS_TOKEN_SCOPES, LTI_1P3_CONTEXT_TYPE, + LTI_PROCTORING_DATA_KEYS, ) from .key_handlers import ToolKeyHandler, PlatformKeyHandler from .ags import LtiAgs @@ -659,3 +661,228 @@ def enable_nrps(self, context_memberships_url): # Include LTI NRPS claim inside the LTI Launch message self.set_extra_claim(self.nrps.get_lti_nrps_launch_claim()) + + +class LtiProctoringConsumer(LtiConsumer1p3): + """ + This class is an LTI Proctoring Services LTI consumer implementation. + + It builds on top of the LtiConsumer1p3r and adds support for the LTI Proctoring Services specification. The + specification can be found here: http://www.imsglobal.org/spec/proctoring/v1p0. + + This consumer currently only supports the "Assessment Proctoring Messages" and the proctoring assessmen flow. + It does not currently support the Assessment Control Service. + + The LtiProctoringConsumer requires necessary context to work properly, including data like attempt_number, + resource_link, etc. This information is provided to the consumer through the set_proctoring_data method, which + is called from the consuming context to pass in necessary data. + """ + def __init__( + self, + iss, + lti_oidc_url, + lti_launch_url, + client_id, + deployment_id, + rsa_key, + rsa_key_id, + tool_key=None, + tool_keyset_url=None, + ): + """ + Initialize the LtiProctoringConsumer by delegating to LtiConsumer1p3's __init__ method. + """ + super().__init__( + iss, + lti_oidc_url, + lti_launch_url, + client_id, + deployment_id, + rsa_key, + rsa_key_id, + tool_key, + tool_keyset_url + ) + self.proctoring_data = {} + + def set_proctoring_data(self, **kwargs): + """ + Set the self.proctoring_data dictionary with the provided kwargs, so long as a given key is in + LTI_PROCTORING_DATA_KEYS. + """ + for key, value in kwargs.items(): + if key in LTI_PROCTORING_DATA_KEYS: + self.proctoring_data[key] = value + + def _get_base_claims(self): + """ + Return claims common to all LTI Proctoring Services LTI launch messages, to be used when creating LTI launch + messages. + """ + proctoring_claims = { + "https://purl.imsglobal.org/spec/lti-ap/claim/attempt_number": self.proctoring_data.get("attempt_number"), + "https://purl.imsglobal.org/spec/lti-ap/claim/session_data": self.proctoring_data.get("session_data"), + } + + return proctoring_claims + + def get_start_proctoring_claims(self): + """ + Return claims specific to LTI Proctoring Services LtiStartProctoring LTI launch message, + to be injected into the LTI launch message. + """ + proctoring_claims = self._get_base_claims() + proctoring_claims.update({ + "https://purl.imsglobal.org/spec/lti/claim/message_type": "LtiStartProctoring", + "https://purl.imsglobal.org/spec/lti-ap/claim/start_assessment_url": + self.proctoring_data.get("start_assessment_url"), + }) + + return proctoring_claims + + def get_end_assessment_claims(self): + """ + Return claims specific to LTI Proctoring Services LtiEndAssessment LTI launch message, + to be injected into the LTI launch message. + """ + proctoring_claims = self._get_base_claims() + proctoring_claims.update({ + "https://purl.imsglobal.org/spec/lti/claim/message_type": "LtiEndAssessment", + }) + + return proctoring_claims + + def generate_launch_request( + self, + preflight_response, + resource_link + ): + """ + Build and return LTI launch message for proctoring. + + This method overrides LtiConsumer1p3's method to include proctoring specific launch claims. It leverages + the set_extra_claim method to include these additional claims in the LTI launch message. + """ + lti_message_hint = preflight_response.get("lti_message_hint") + proctoring_claims = None + if lti_message_hint == "LtiStartProctoring": + proctoring_claims = self.get_start_proctoring_claims() + elif lti_message_hint == "LtiEndAssessment": + proctoring_claims = self.get_end_assessment_claims() + else: + raise ValueError('lti_message_hint must be one of [LtiStartProctoring, LtiStartAssessment].') + + self.set_extra_claim(proctoring_claims) + + return super().generate_launch_request(preflight_response, resource_link) + + def check_and_decode_token(self, token): + """ + Once the Proctoring Tool is satisfied that the user has completed the necessary proctoring set up and that the + assessment will be proctored securely, it redirects the user to the Assessment Platform, directing the browser + to make a POST request containing a JWT and the session_data. This is the "Start Assessment" message. + + This method validates the JWT signature and decodes the JWT. It also validates the claims in the JWT according + to the Proctoring Services specification. + + It either returns a dictionary containing information required by the start_assessment, or it raises an + exception if any claims is missing or invalid. + """ + # Decode token and check expiration. + proctoring_response = self.tool_jwt.validate_and_decode(token) + + # TODO: We MUST perform other forms of validation here. + # TODO: We MUST validate the verified_user claim if it is provided, although it is optional to provide it. + # An Assessment Platform MAY reject the Start Assessment message if a required identity claim is missing + # (indicating that it has not been verified by the Proctoring Tool). Which identity claims a + # Proctoring Tool will verify is subject to agreement outside of the scope of this specification but, at a + # minimum, it is recommended that Proctoring Tools performing identity verification are able to verify the + # given_name, family_name and name claims. + # See 3.3 Transferring the Candidate Back to the Assessment Platform. + + # ------------------------- + # Check Required LTI Claims + # ------------------------- + + # Check that the response message_type claim is "LtiStartAssessment". + claim_key = "https://purl.imsglobal.org/spec/lti/claim/message_type" + check_token_claim( + proctoring_response, + claim_key, + "LtiStartAssessment", + f"Token's {claim_key} claim should be LtiStartAssessment." + ) + + # # Check that the response version claim is "1.3.0". + claim_key = "https://purl.imsglobal.org/spec/lti/claim/version" + check_token_claim( + proctoring_response, + claim_key, + "1.3.0", + f"Token's {claim_key} claim should be 1.3.0." + ) + + # Check that the response session_data claim is the correct anti-CSRF token. + claim_key = "https://purl.imsglobal.org/spec/lti-ap/claim/session_data" + check_token_claim( + proctoring_response, + claim_key, + self.proctoring_data.get("session_data"), + f"Token's {claim_key} claim is not correct." + ) + + # TODO: Right now, the library doesn't support additional claims within the resource_link claim. + # Once it does, we should check the entire claim instead of just the id. + claim_key = "https://purl.imsglobal.org/spec/lti/claim/resource_link" + check_token_claim( + proctoring_response, + claim_key, + {"id": self.proctoring_data.get("resource_link")}, + f"Token's {claim_key} claim is not correct." + ) + + claim_key = "https://purl.imsglobal.org/spec/lti-ap/claim/attempt_number" + check_token_claim( + proctoring_response, + claim_key, + self.proctoring_data.get("attempt_number"), + f"Token's {claim_key} claim is not correct." + ) + + # ------------------------- + # Check Optional LTI Claims + # ------------------------- + + verified_user = proctoring_response.get("https://purl.imsglobal.org/spec/lti-ap/claim/verified_user", {}) + # See 4.3.2.1 Verified user claim. + # The iss and sub attributes SHOULD NOT be included as they are opaque to the Proctoring Tool and cannot be + # independently verified. + iss = verified_user.get('iss') + if iss is not None: + raise exceptions.InvalidClaimValue('Token verified_user claim should not contain the iss claim.') + sub = verified_user.get('sub') + if sub is not None: + raise exceptions.InvalidClaimValue('Token verified_user claim should not contain the sub claim.') + + # See 4.3.2.1 Verified user claim. + # If the picture attribute is provided it MUST point to a picture taken by the Proctoring Tool. + # It MUST NOT be the same picture provided by the Assessment Platform in the Start Proctoring message. + picture = verified_user.get('picture') + if picture and picture == self.lti_claim_user_data.get('picture'): + raise exceptions.InvalidClaimValue( + 'If the verified_claim is provided and contains the picture claim,' + ' the picture claim should not be the same picture provided by the Assessment Platform to the Tool.' + ) + # TODO: We can leverage these verified user claims. For example, we could use + # these claims for Name Affirmation. + + end_assessment_return = proctoring_response.get( + "https://purl.imsglobal.org/spec/lti-ap/claim/end_assessment_return" + ) + + response = { + 'end_assessment_return': end_assessment_return, + 'verified_user': verified_user, + } + + return response From 452eb7e923fe1012156a29e09ce4607c73efbb5a Mon Sep 17 00:00:00 2001 From: michaelroytman Date: Mon, 15 Aug 2022 17:41:05 -0400 Subject: [PATCH 07/12] feat: instantiate the LtiProctoringConsumer from the LtiConfiguration --- lti_consumer/models.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/lti_consumer/models.py b/lti_consumer/models.py index 37d4a927..11dee976 100644 --- a/lti_consumer/models.py +++ b/lti_consumer/models.py @@ -20,7 +20,7 @@ # LTI 1.1 from lti_consumer.lti_1p1.consumer import LtiConsumer1p1 # LTI 1.3 -from lti_consumer.lti_1p3.consumer import LtiAdvantageConsumer +from lti_consumer.lti_1p3.consumer import LtiAdvantageConsumer, LtiProctoringConsumer from lti_consumer.lti_1p3.key_handlers import PlatformKeyHandler from lti_consumer.plugin import compat from lti_consumer.plugin.compat import request_cached @@ -495,8 +495,13 @@ def _get_lti_1p3_consumer(self): Uses the `config_store` variable to determine where to look for the configuration and instance the class. """ + consumer_class = LtiAdvantageConsumer + # LTI Proctoring Services is not currently supported for CONFIG_ON_XBLOCK or CONFIG_EXTERNAL. + if self.lti_1p3_proctoring_enabled and self.config_store == self.CONFIG_ON_DB: + consumer_class = LtiProctoringConsumer + if self.config_store == self.CONFIG_ON_XBLOCK: - consumer = LtiAdvantageConsumer( + consumer = consumer_class( iss=get_lms_base(), lti_oidc_url=self.block.lti_1p3_oidc_url, lti_launch_url=self.block.lti_1p3_launch_url, @@ -512,7 +517,7 @@ def _get_lti_1p3_consumer(self): tool_keyset_url=self.block.lti_1p3_tool_keyset_url, ) elif self.config_store == self.CONFIG_ON_DB: - consumer = LtiAdvantageConsumer( + consumer = consumer_class( iss=get_lms_base(), lti_oidc_url=self.lti_1p3_oidc_url, lti_launch_url=self.lti_1p3_launch_url, From 5393bdda87d24c6b8c75670640c418d62ec14ecb Mon Sep 17 00:00:00 2001 From: michaelroytman Date: Mon, 15 Aug 2022 17:27:14 -0400 Subject: [PATCH 08/12] feat: raise exception if LtiConfiguration config_store doesn't support proctoring --- lti_consumer/models.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lti_consumer/models.py b/lti_consumer/models.py index 11dee976..aab6d407 100644 --- a/lti_consumer/models.py +++ b/lti_consumer/models.py @@ -228,7 +228,6 @@ class LtiConfiguration(models.Model): ) # LTI Proctoring Service Related Variables - # TODO: handle invalid config if not LTI 1.3 lti_1p3_proctoring_enabled = models.BooleanField( "Enable LTI Proctoring Services", default=False, @@ -256,6 +255,11 @@ def clean(self): "lti_1p3_tool_public_key or lti_1p3_tool_keyset_url." ), }) + if self.version == self.LTI_1P3 and self.config_store in [self.CONFIG_ON_XBLOCK, self.CONFIG_EXTERNAL]: + raise ValidationError({ + "config_store": _("CONFIG_ON_XBLOCK and CONFIG_EXTERNAL are not supported for " + "LTI 1.3 Proctoring Services."), + }) try: consumer = self.get_lti_consumer() except NotImplementedError: From 2e6715214e428e9096a69d03eff6706953893ef7 Mon Sep 17 00:00:00 2001 From: michaelroytman Date: Mon, 15 Aug 2022 17:28:06 -0400 Subject: [PATCH 09/12] fix: bug prevent creation of LtiConfiguration if location is null --- lti_consumer/models.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lti_consumer/models.py b/lti_consumer/models.py index aab6d407..7dc9aa9f 100644 --- a/lti_consumer/models.py +++ b/lti_consumer/models.py @@ -243,8 +243,10 @@ def clean(self): raise ValidationError({ "config_store": _("LTI Configuration stores on XBlock needs a block location set."), }) + # If we're in an XBlock context, then check whether the database_config_enabled flag is enabled. Otherwise, + # don't restrict saving LtiConfiguration. if self.version == self.LTI_1P3 and self.config_store == self.CONFIG_ON_DB: - if not database_config_enabled(self.block.location.course_key): + if self.location and not database_config_enabled(self.block.location.course_key): raise ValidationError({ "config_store": _("LTI Configuration stores on database is not enabled."), }) From 1174f91586dfdc0d89c24e584e16cf34243d0e3d Mon Sep 17 00:00:00 2001 From: michaelroytman Date: Mon, 15 Aug 2022 17:41:56 -0400 Subject: [PATCH 10/12] feat: add views to plugin for Proctoring Services implementation --- lti_consumer/plugin/urls.py | 37 ++- lti_consumer/plugin/views.py | 436 ++++++++++++++++++++++++++++++++++- 2 files changed, 466 insertions(+), 7 deletions(-) diff --git a/lti_consumer/plugin/urls.py b/lti_consumer/plugin/urls.py index 39496e8b..3c5428d4 100644 --- a/lti_consumer/plugin/urls.py +++ b/lti_consumer/plugin/urls.py @@ -7,10 +7,19 @@ from django.urls import include, re_path, path from rest_framework import routers -from lti_consumer.plugin.views import (LtiAgsLineItemViewset, # LTI Advantage URLs; LTI NRPS URLs - LtiNrpsContextMembershipViewSet, access_token_endpoint, - deep_linking_content_endpoint, deep_linking_response_endpoint, - launch_gate_endpoint, public_keyset_endpoint) +from lti_consumer.plugin.views import ( + LtiAgsLineItemViewset, + LtiNrpsContextMembershipViewSet, + access_token_endpoint, + end_assessment, + deep_linking_content_endpoint, + deep_linking_response_endpoint, + launch_gate_endpoint, + launch_gate_endpoint_proctoring, + start_assessment, + start_proctoring, + public_keyset_endpoint +) # LTI 1.3 APIs router router = routers.SimpleRouter(trailing_slash=False) @@ -59,4 +68,24 @@ r'lti_consumer/v1/lti/(?P[-\w]+)/', include(router.urls) ), + path( + 'lti_consumer/v1/start_proctoring/', + start_proctoring, + name='lti_consumer.start_proctoring', + ), + path( + 'lti_consumer/v1/end_assessment/', + end_assessment, + name='lti_consumer.end_assessment', + ), + path( + 'lti_consumer/v1/start_assessment/', + start_assessment, + name='lti_consumer.start_assessment', + ), + re_path( + 'lti_consumer/v1/proctoring_launch/(?:/(?P.*))?$', + launch_gate_endpoint_proctoring, + name='lti_consumer.proctoring_launch_gate' + ), ] diff --git a/lti_consumer/plugin/views.py b/lti_consumer/plugin/views.py index d2683b9c..cd58f590 100644 --- a/lti_consumer/plugin/views.py +++ b/lti_consumer/plugin/views.py @@ -11,8 +11,11 @@ from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods from django.views.decorators.clickjacking import xframe_options_sameorigin -from django.shortcuts import render +from django.shortcuts import redirect, render +from django.utils.crypto import get_random_string from django_filters.rest_framework import DjangoFilterBackend +from edx_django_utils.cache import get_cache_key, TieredCache +from jwkest.jwt import JWT, BadSyntax from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import UsageKey from rest_framework import viewsets, status @@ -20,7 +23,7 @@ from rest_framework.response import Response from rest_framework.status import HTTP_400_BAD_REQUEST, HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND -from lti_consumer.api import get_lti_pii_sharing_state_for_course +from lti_consumer.api import get_lti_pii_sharing_state_for_course, get_lti_1p3_launch_start_url from lti_consumer.exceptions import LtiError from lti_consumer.models import ( LtiConfiguration, @@ -30,6 +33,7 @@ from lti_consumer.lti_1p3.consumer import LTI_1P3_CONTEXT_TYPE from lti_consumer.lti_1p3.exceptions import ( + BadJwtSignature, Lti1p3Exception, LtiDeepLinkingContentTypeNotSupported, UnsupportedGrantType, @@ -338,7 +342,6 @@ def access_token_endpoint(request, lti_config_id=None, usage_id=None): except UnsupportedGrantType: return JsonResponse({"error": "unsupported_grant_type"}, status=HTTP_400_BAD_REQUEST) - # Post from external tool that doesn't # have access to CSRF tokens @csrf_exempt @@ -673,3 +676,430 @@ def list(self, *args, **kwargs): "error": "above_response_limit", "explanation": "The number of retrieved users is bigger than the maximum allowed in the configuration.", }, status=HTTP_403_FORBIDDEN) + + +def proctoring_preflight(request, lti_config_id): + """ + This view represents a "Platform-Originating Message"; the Assessment Platform is directing the browser to send a + message to the Proctoring Tool. Because the Assessment Platform acts as the identity provider + (IdP), it must follow the "OpenID Connect Launch Flow". The first step is the third-party initiated login; it is a + "third-party" initiated login to protect against login CSRF attacks. This is also known as a preflight request. + + "In 3rd party initiated login, the login flow is initiated by an OpenID Provider or another party, rather than the + Relying Party. In this case, the initiator redirects to the Relying Party at its login initiation endpoint, which + requests that the Relying Party send an Authentication Request to a specified OpenID Provider." + https://www.imsglobal.org/spec/security/v1p0/#openid_connect_launch_flow + + This view redirects the user's browser to the Proctoring Tool's initial "OIDC login initiation URL", which acts + as the first step of third-party initiated login. The Proctoring Tool should redirect the user's browser to the + Assessment Platform's "OIDC Authorization end-point", which starts the OpenID Connect authentication flow, + implemented by the launch_gate_endpoint_proctoring view. + + The Assessment Platform needs to know the Proctoring Tool's OIDC login initiation URL. The Proctoring Tool needs to + know the Assessment Platform's OIDC authorization URL. This information is exchanged out-of-band during the + registration phase. + """ + # DECISION: It doesn't appear that the OIDC login initiation/preflight view has been pulled out of the XBlock yet, + # so I cannot leverage an existing view. I think uncoupling the view from the XBlock is beyond the scope + # of this ticket, so I've written a proctoring specific view here for now. + + try: + lti_config = LtiConfiguration.objects.get(config_id=lti_config_id) + except LtiConfiguration.DoesNotExist as exc: + log.error("The config_id %s is invalid for the LTI 1.3 proctoring launch preflight request.", lti_config_id) + raise Http404 from exc + + if not lti_config.lti_1p3_proctoring_enabled: + log.info("Proctoring Services for LTIConfiguration with config_id %s are not enabled", lti_config_id) + return render(request, 'html/lti_1p3_launch_error.html', status=HTTP_400_BAD_REQUEST) + + # NOTE: The lti_hint could easily be a parameter to this view, which would eliminate the need for the query + # parameter. I'd like some feedback about whether this function should be a utility view that is only ever + # called by start_proctoring or end_assessment or whether it should be exposed by the library for use by + # consumers of the library. + lti_hint = request.GET.get("lti_hint") + if not lti_hint: + log.info("The `lti_hint` query param in the request is missing or empty.") + return render(request, 'html/lti_1p3_launch_error.html', status=HTTP_400_BAD_REQUEST) + elif lti_hint not in ["LtiStartProctoring", "LtiEndAssessment"]: + log.info("The `lti_hint` query param in the request is invalid.") + return render(request, 'html/lti_1p3_launch_error.html', status=HTTP_400_BAD_REQUEST) + + preflight_url = get_lti_1p3_launch_start_url( + config_id=lti_config.id, + lti_hint=lti_hint, + hint=lti_config_id, + ) + + return redirect(preflight_url) + + +@require_http_methods(['GET']) +def start_proctoring(request, lti_config_id): + """ + This view represents a "Platform-Originating Message"; the Assessment Platform is directing the browser to send a + "start proctoring" message to the Proctoring Tool. Because the Assessment Platform acts as the identity provider + (IdP), it must follow the "OpenID Connect Launch Flow". The first step is the third-party initiated login; it is a + "third-party" initiated login to protect against login CSRF attacks. This is also known as a preflight request. + """ + # Set the lti_hint query parameter appropriately. QueryDicts are immutable, so we must copy the QueryDict + # and set the query parameter. + get_params = request.GET.copy() + get_params['lti_hint'] = 'LtiStartProctoring' + request.GET = get_params + + return proctoring_preflight(request, lti_config_id) + + +@require_http_methods(['GET']) +def end_assessment(request, lti_config_id): + """ + This view represents a "Platform-Originating Message"; the Assessment Platform is directing the browser to send a + "end assessment" message to the Proctoring Tool. Because the Assessment Platform acts as the identity provider + (IdP), it must follow the "OpenID Connect Launch Flow". The first step is the third-party initiated login; it is a + "third-party" initiated login to protect against login CSRF attacks. This is also known as a preflight request. + + The End Assessment message covers the last part of the overall assessment submission workflow. This message + MUST be sent by the Assessment Platform upon attempt completion IF the end_assessment_return claim is set to True + by the Proctoring Tool as part of the Start Assessment launch. + """ + end_assessment_return_key = get_cache_key(app="lti", key="end_assessment_return", user_id=request.user.id) + cached_end_assessment_return = TieredCache.get_cached_response(end_assessment_return_key) + + # If end_assessment_return was provided by the Proctoring Tool, and end_assessment was True, then the Assessment + # Platform MUST send an End Assessment message to the Proctoring Tool. Otherwise, the Assessment Platform can + # complete its normal post-assessment flow. + if cached_end_assessment_return.is_found and cached_end_assessment_return: + # Clear the cached end_assessment_return value. + TieredCache.delete_all_tiers(end_assessment_return_key) + + # Set the lti_message_hint query parameter appropriately. QueryDicts are immutable, so we must copy the + # QueryDict and set the query parameter. + get_params = request.GET.copy() + get_params['lti_hint'] = 'LtiEndAssessment' + request.GET = get_params + + return proctoring_preflight(request, lti_config_id) + else: + return JsonResponse(data={}) + + +# We do not want Django's CSRF protection enabled for POSTs made by external services to this endpoint. +# Please see the comment for the launch_gate_endpoint_proctoring view for a more detailed justification. +@csrf_exempt +# Per the Proctoring Services specification, the Proctoring Tool can direct the user's browser to make only a POST +# request to this endpoint. +@require_http_methods(['POST']) +def start_assessment(request): + """ + This view handles the Proctoring Tool's message to start the assessment, which is a "Tool-Originating Message". + + Once the Proctoring Tool determines the user is ready to start the proctored assessment (e.g. their environment + has been secured and they have completed user identity verification), it sends the Assessment Platform an LTI + message. Because it is a "Tool-Originating Message" and no user identity is shared, the message is a signed JWT, not + an ID Token. + + The Proctoring Tool needs to know the location of this endpoint on the Assessment Platform; this endpoint is + referred to as the "start assessment URL". This information is sent to the Proctoring Tool in the Assessment + Platform's response to the Tool's request to the login endpoint (launch_gate_endpoint_proctoring). It is included as + the required claim "start_assessment_url" in the ID Token. + """ + # DECISION: Instead of relying on a config_id URL parameter, let's use the iss claim in signed JWT sent by the + # Proctoring Tool to identify the LtiConfiguration; iss should match the client_id of the + # LtiConfiguration. Although there is a risk that the JWT is not authentic, we will validate the + # authenticity of the JWT and raise an exception if the signature is invalid later in this function. + token = request.POST.get('JWT') + + # TODO: This needs better error handling. + try: + jwt = JWT().unpack(token) + except BadSyntax: + return JsonResponse( + {}, + status=HTTP_400_BAD_REQUEST, + ) + + client_id = jwt.payload().get('iss') + + try: + lti_config = LtiConfiguration.objects.get(lti_1p3_client_id=client_id) + except LtiConfiguration.DoesNotExist as exc: + log.error("The iss claim %s is not valid for the LTI 1.3 proctoring start_assessment request.", client_id) + raise Http404 from exc + + if not lti_config.lti_1p3_proctoring_enabled: + log.info("Proctoring Services for LTIConfiguration with config_id %s are not enabled", lti_config.config_id) + return JsonResponse( + {'error': + f'Proctoring Services for LTIConfiguration with config_id {lti_config.config_id} are not enabled'}, + status=HTTP_400_BAD_REQUEST, + ) + + lti_consumer = lti_config.get_lti_consumer() + + # Let's grab the session_data stored in the user's session. This will need to be compared + # against the session_data claim in the JWT token included by the Proctoring Tool in the request. This protects + # against CSRF attacks. + session_data_key = get_cache_key(app="lti", key="session_data", user_id=request.user.id) + cached_session_data = TieredCache.get_cached_response(session_data_key) + if cached_session_data.is_found: + session_data = cached_session_data.value + else: + return JsonResponse( + {'error': 'The provided session_data claim does not match the anti-CSRF token.'}, + status=HTTP_400_BAD_REQUEST, + ) + + # TODO: The resource link should uniquely represent the assessment in the Assessment Platform. + # We SHOULD provide a value for the title attribute. + # It's RECOMMENDED to provide a value for the description attribute. + # The xblock-lti-consumer library does not currently support setting these attributes. + resource_link = request.POST.get('resource_link') + attempt_number = request.POST.get('attempt_number') + + lti_consumer.set_proctoring_data( + attempt_number=attempt_number, + session_data=session_data, + resource_link=resource_link, + ) + + # TODO: I hardcoded this to None for right now. We'll need to figure out how to supply user_roles from outside the + # platform. + # TODO: LTI Proctoring Services expects that the user role is empty or includes + # "http://purl.imsglobal.org/vocab/lis/v2/membership#Learner", which is a context role. + # lti_consumer.set_user_data uses the regular roles. So, for now, I'm leaving it empty. + # Required user claim data + lti_consumer.set_user_data( + user_id=request.user.id, + role=None, + ) + + # These claims are optional. They are necessary to set in order to properly verify the verified_user claim, + # if the Proctoring Tool includes it in the JWT. + # TODO: This will need to have additional consideration for PII. + # optional_user_identity_claims = get_optional_user_identity_claims() + # lti_consumer.set_proctoring_user_data( + # **optional_user_identity_claims + # ) + + try: + lti_response = lti_consumer.check_and_decode_token(token) + # TODO: This needs better error handling. + except (BadJwtSignature, MalformedJwtToken, NoSuitableKeys): + return JsonResponse( + {}, + status=HTTP_400_BAD_REQUEST, + ) + + # If the Proctoring Tool specifies the end_assessment_return claim in its LTI launch request, + # the Assessment Platform MUST send an End Assessment Message at the end of the user's + # proctored exam. + end_assessment_return = lti_response.get('end_assessment_return') + if end_assessment_return: + end_assessment_return_key = get_cache_key(app="lti", key="end_assessment_return", user_id=request.user.id) + # We convert the boolean to an int because memcached will return an int even if a boolean is stored. This + # ensures a consistent return value. + end_assessment_return_value = int(end_assessment_return) + TieredCache.set_all_tiers(end_assessment_return_key, end_assessment_return_value) + + return JsonResponse(data={}) + + +# We do not want Django's CSRF protection enabled for POSTs made by external services to this endpoint. This is because +# Django uses the double-submit cookie method of CSRF protection, but the Proctoring Specification lends itself better +# to the synchronizer token method of CSRF protection. Django's method requires an anti-CSRF token to be included in +# both a cookie and a hidden from value in the request to CSRF procted endpoints. In the Proctoring Specification, there +# are a number of issues supporting the double-submit cookie method. +# +# 1. Django requires that a cookie containing the anti-CSRF token is sent with the request from the Proctoring Tool to +# the Assessment Platform . When the user's browser makes a request to the launch_gate_endpoint view, an anti-CSRF +# token is set in the cookie. The default SameSite attribute for cookies is "Lax" (stored in the Django setting +# CSRF_COOKIE_SAMESITE), meaning that when the Proctoring Tool redirects the user's browser back to the Assessment +# Platform, the browser will not include the previously set cookie in its request to the Assessment Platform. +# CSRF_COOKIE_SAMESITE can be set to "None", but this means that all third-party cookies will be included by the +# browser, which may compromise CSRF protection for other endpoints. Note that settings CSRF_COOKIE_SAMESITE to +# "None" requires that CSRF_COOKIE_SECURE is set to True. +# +# 2. Django validates a request by comparing the above anti-CSRF token in the cookie to the anti-CSRF token in the POST +# request parameters. Django expects the anti-CSRF token to be in the POST request parameters with the key name +# "csrfmiddlewaretoken". However, the Proctoring Specification requires that the anti-CSRF token be included in the +# JWT token with the name "session_data". The Proctoring Tool will not direct the browser to send this anti-CSRF +# token back with the key name "csrfmiddlewaretoken", nor will it include it as a form parameter, as it's not part of +# the Proctoring Services Specification. +@csrf_exempt +# Authorization Servers MUST support the use of the HTTP GET and POST methods defined in RFC 2616 [RFC2616] +# at the Authorization Endpoint. +# See 3.1.2.1.Authentication Request of the OIDC specification. +# https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest +@require_http_methods(["GET", "POST"]) +def launch_gate_endpoint_proctoring(request, suffix=None): # pylint: disable=unused-argument + """ + This is the Assessment Platform's OIDC login authentication/authorization endpoint. + + This uses the "client_id" query parameter or form data to identify the LtiConfiguration and its consumer to generate + the LTI 1.3 Launch Form. + """ + preflight_response = request.GET.dict() if request.method == 'GET' else request.POST.dict() + + # DECISION: We need access to the correct LtiConfiguration instance. We could use "location", but this is tied + # to the XBlock. We could use config_id, but that would need to be passed to this view via the URL or a + # query parameter by the consuming library (see launch_gate_endpoint). Instead, let's use the client_id, + # which is a query parameter required by the LTI 1.3 specification. This should uniquely identify the + # LtiConfiguration instance. + client_id = preflight_response.get('client_id') + if not client_id: + log.error('The preflight response is not valid. The required parameter client_id is missing.') + return render(request, 'html/lti_1p3_launch_error.html', status=HTTP_400_BAD_REQUEST) + + try: + lti_config = LtiConfiguration.objects.get( + lti_1p3_client_id=client_id, + ) + except LtiConfiguration.DoesNotExist as exc: + log.error("Invalid client_id '%s' for LTI 1.3 proctoring launch.", client_id) + raise Http404 from exc + + if lti_config.version != LtiConfiguration.LTI_1P3: + log.error("The LTI Version of configuration %s is not LTI 1.3", lti_config) + return render(request, 'html/lti_1p3_launch_error.html', status=HTTP_404_NOT_FOUND) + + if not lti_config.lti_1p3_proctoring_enabled: + log.error("Proctoring Services for LTIConfiguration with config_id %s are not enabled", lti_config.config_id) + return render(request, 'html/lti_1p3_launch_error.html', status=HTTP_400_BAD_REQUEST) + + context = {} + + # TODO: The below calls are an issue, because they call to the LMS. + # course_key = usage_key.course_key + # course = compat.get_course_by_id(course_key) + # user_role = compat.get_user_role(request.user, course_key) + # external_user_id = compat.get_external_id_for_user(request.user) + + # TODO: I hardcoded this to None for right now. We'll need to figure out how to supply user_roles from outside the + # platform. + # TODO: LTI Proctoring Services expects that the user role is empty or includes + # "http://purl.imsglobal.org/vocab/lis/v2/membership#Learner", which is a context role. + # lti_consumer.set_user_data uses the regular roles. So, for now, I'm leaving it empty. + user_role = None + + course_key = "dummy_course_key" + + class DummyCourse: + display_name_with_default = "dummy_course_name" + display_org_with_default = "dummy_course_org" + course = DummyCourse() + + lti_consumer = lti_config.get_lti_consumer() + + try: + # Pass user data + # Pass django user role to library + + # TODO: Do we need an external_id for a user within proctoring? Should edx-exams define this value? + lti_consumer.set_user_data(user_id="dummy_user_id", role=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') + + # TODO: The below calls are an issue, because they call to the LMS. + # # Set context claim + # # This is optional + context_title = " - ".join([ + course.display_name_with_default, + course.display_org_with_default + ]) + # Course ID is the context ID for the LTI for now. This can be changed to be + # more specific in the future for supporting other tools like discussions, etc. + lti_consumer.set_context_claim( + str(course_key), + context_types=[LTI_1P3_CONTEXT_TYPE.course_offering], + context_title=context_title, + context_label=str(course_key) + ) + + # Set LTI Launch URL + # NOTE: According to the specification, we have to post to the redirect_uri specified in the + # preflight response, so I have changed this. + redirect_uri = preflight_response.get('redirect_uri') + if not redirect_uri: + raise PreflightRequestValidationFailure('The preflight response is not valid.' + 'The required parameter redirect_uri is missing.') + context.update({'launch_url': redirect_uri}) + + lti_message_hint = preflight_response.get('lti_message_hint') + if not lti_message_hint: + raise PreflightRequestValidationFailure('The preflight response is not valid.' + 'The required parameter lti_message_hint is invalid.') + + if lti_message_hint in ['LtiStartProctoring', 'LtiEndAssessment']: + # "The Assessment Platform MUST also include some session-specific data (session_data) that is + # opaque to the Proctoring Tool in the Start Proctoring message. + # This will be returned in the Start Assessment message and acts as an anti-CSRF token, + # the Assessment Tool MUST verify that this data matches the expected browser session + # before actually starting the assessment." + # See 3.3 Transferring the Candidate Back to the Assessment Platform. + # In the synchronizer token method of CSRF protection, the anti-CSRF token must be stored on the server. + session_data_key = get_cache_key(app="lti", key="session_data", user_id=request.user.id) + + cached_session_data = TieredCache.get_cached_response(session_data_key) + if cached_session_data.is_found: + session_data = cached_session_data.value + else: + session_data = get_random_string(32) + TieredCache.set_all_tiers(session_data_key, session_data) + + # These claims are optional. + # TODO: This will need to have additional consideration for PII. + # TODO: Add the appropriate PII to the claims depending on CourseAllowPIISharingInLTIFlag; + # see docs/decisions/0005-lti-pii-sharing-flag.rst. + # optional_user_identity_claims = get_optional_user_identity_claims() + # lti_consumer.set_proctoring_user_data( + # **optional_user_identity_claims + # ) + + # TODO: The resource link should uniquely represent the assessment in the Assessment Platform. + # We SHOULD provide a value for the title attribute. + # It's RECOMMENDED to provide a value for the description attribute. + # The xblock-lti-consumer library does not currently support setting these attributes. + resource_link = preflight_response.get('resource_link') + start_assessment_url = preflight_response.get('start_assessment_url') + attempt_number = preflight_response.get('attempt_number') + + lti_consumer.set_proctoring_data( + attempt_number=attempt_number, + session_data=session_data, + start_assessment_url=start_assessment_url + ) + else: + raise PreflightRequestValidationFailure('The preflight response is not valid.' + 'The required parameter lti_message_hint is invalid.') + + # Update context with LTI launch parameters + context.update({ + "preflight_response": preflight_response, + "launch_request": lti_consumer.generate_launch_request( + preflight_response=preflight_response, + resource_link=resource_link, + ) + }) + event = { + 'lti_version': lti_config.version, + 'user_roles': user_role, + 'launch_url': context['launch_url'] + } + # TODO: What should this be? It shouldn't be scoped to the XBlock. + track_event('xblock.launch_request', event) + + return render(request, 'html/lti_1p3_launch.html', context) + except Lti1p3Exception as exc: + log.warning( + "Error preparing LTI 1.3 launch for client_id %s: %s", + client_id, + exc, + exc_info=True + ) + return render(request, 'html/lti_1p3_launch_error.html', context, status=HTTP_400_BAD_REQUEST) From 1bbe9114c06d5aa963ebea1442cb41601a5e289c Mon Sep 17 00:00:00 2001 From: michaelroytman Date: Mon, 15 Aug 2022 17:46:34 -0400 Subject: [PATCH 11/12] feat: delete unused get_lti_proctoring_start_assessment_url --- lti_consumer/utils.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/lti_consumer/utils.py b/lti_consumer/utils.py index 2bbf4e00..ab9b879b 100644 --- a/lti_consumer/utils.py +++ b/lti_consumer/utils.py @@ -125,13 +125,6 @@ def get_lti_nrps_context_membership_url(lti_config_id): ) -def get_lti_proctoring_start_assessment_url(): - """ - Return the LTI Proctoring Services start assessment URL. - """ - urljoin(get_lms_base(), reverse('lti:start-assessment')) - - def check_token_claim(token, claim_key, expected_value, invalid_claim_error_msg): """ Check that the claim in the token with the key claim_key matches the expected value. If not, From 979ca8fef79ea5e0ce55bb58f97aab053b999ded Mon Sep 17 00:00:00 2001 From: michaelroytman Date: Tue, 16 Aug 2022 13:25:08 -0400 Subject: [PATCH 12/12] fix: remove unused imports from utils.py --- lti_consumer/utils.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/lti_consumer/utils.py b/lti_consumer/utils.py index ab9b879b..20e455b7 100644 --- a/lti_consumer/utils.py +++ b/lti_consumer/utils.py @@ -3,10 +3,8 @@ """ import logging from importlib import import_module -from urllib.parse import urljoin from django.conf import settings -from django.urls import reverse from lti_consumer.lti_1p3.exceptions import InvalidClaimValue, MissingRequiredClaim from lti_consumer.plugin.compat import get_external_config_waffle_flag, get_database_config_waffle_flag