diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 9a22b279addb7800b33fcffccae3ab4f3701d612..34832279037e0e872873df2158fa7ee4b9eb989d 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,14 +15,28 @@ Please See the [releases tab](https://github.com/edx/xblock-lti-consumer/release Unreleased ~~~~~~~~~~ + +6.2.0 - 2022-11-16 +------------------ +* Adds support for LTI 1.3 Proctoring Service specification in-browser proctoring launch. + + * Adds an Lti1p3ProctoringLaunchData data class. It should be included as an attribute of the Lti1p3LaunchData + data class to provide necessary proctoring data for a proctoring launch. + * Adds an LtiProctoringConsumer class. This class is used to generate LTI proctoring launch requests and to decode + and validate the JWT send back by the Tool with the LtiStartAssessment message. + * Adds an lti_1p3_proctoring_enabled BooleanField to the LtiConfiguration model. This field controls whether + proctoring is enabled for a particular LTI integration. + * Modifies the launch_gate_endpoint to support LtiStartProctoring and LtiEndAssessment LTI launch messages. + * Adds an start_proctoring_assessment_endpoint to support LtiStartAssessment messages from the Tool. + * Adds an LTI_1P3_PROCTORING_ASSESSMENT_STARTED signal. This signal is emitted when the LtiStartAssessment message is + sent from the Tool to inform users of the library that the LtiStartAssessment message has been received. + 6.1.0 - 2022-11-08 ------------------ * 6.0.0 broke studio functionality because it leaned more heavily on the xblock load which only worked in the LMS. * Fix by greatly limiting when we attempt a full xblock load and bind - - 6.0.0 - 2022-10-24 ------------------ BREAKING CHANGE: diff --git a/docs/proctoring.rst b/docs/proctoring.rst new file mode 100644 index 0000000000000000000000000000000000000000..20e2793a952b2314b1f0dd2ca24a346539823512 --- /dev/null +++ b/docs/proctoring.rst @@ -0,0 +1,95 @@ +####################### +LTI Proctoring Features +####################### + +Using LTI Proctoring Features +***************************** + +Currently, this library supports a subset of the functionality in the `1EdTech Proctoring Services Specification +<http://www.imsglobal.org/spec/proctoring/v1p0>`_. It supports the Proctoring Assessment Messages (i.e. the in-browser +LTI proctoring launches), but it does not support the Assessment Control Service (i.e. the proctoring service calls). +These proctoring features are currently only supported for LTI integrations using the ``CONFIG_ON_DB`` ``config_store`` +option. + +To enable LTI Proctoring features, you need to set the **Enable LTI Proctoring Services** field of the +``LtiConfiguration`` model to ``True``. + +To start an LTI 1.3 launch with the ``LtiStartProctoring`` or ``LtiEndAssessment`` LTI message, you need to call +the ``get_lti_1p3_launch_start_url`` Python API function with the appropriate arguments. You will need to make a request against the URL +returned by this function. The ``launch_data`` argument will contain all data necessary for the LTI 1.3 launch. The +``launch_data`` argument must be an instance of the ``Lti1p3LaunchData`` data class. In order to support the proctoring +features, you must also supply a value for the ``proctoring_launch_data`` field of the ``Lti1p3LaunchData`` class. The +``proctoring_launch_data`` argument must be an instance of the ``Lti1p3ProctoringLaunchData`` class. + +LTI Start Proctoring Launch +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Below is an example instantiation of the ``Lti1p3LaunchData`` class for an ``LtiStartProctoring`` LTI message. Please +see the docstring of both data classes for more detailed documentation. + +This library implements the view that handles the ``LtiStartAssessment`` LTI message that is sent by the tool. The name +of the URL for this view is ``lti_consumer.start_proctoring_assessment_endpoint``. You should use this URL as the +``start_assessment_url``. + +.. code:: python + + proctoring_start_assessment_url = urljoin( + <URL_ROOT>, + reverse('lti_consumer:lti_consumer.start_proctoring_assessment_endpoint') + ) + + proctoring_launch_data = Lti1p3ProctoringLaunchData( + attempt_number=<attempt_number>, + start_assessment_url=proctoring_start_assessment_url, + ) + + launch_data = Lti1p3LaunchData( + user_id=<user_id>, + user_role=<user_role>, + config_id=<config_id>, + resource_link_id=<resource_link_id>, + message_type="LtiStartProctoring", + proctoring_launch_data=proctoring_launch_data, + ) + + return redirect(get_lti_1p3_launch_start_url(launch_data)) + + +Note that for an ``LtiStartProctoring`` LTI launch message, the ``message_type`` field of the ``Lti1p3LaunchData`` +instance must be ``LtiStartProctoring`` and the ``start_assessment_url`` field of the ``Lti1p3ProctoringLaunchData`` +instance must be supplied. + +LTI End Assessment Launch +^^^^^^^^^^^^^^^^^^^^^^^^^ + +Below is an example instantiation of the ``Lti1p3LaunchData`` class for an ``LtiStartProctoring`` LTI message. Please +see the docstring of both data classes for more detailed documentation. + +In order to determine whether the platform should send an ``LtiEndAssessment`` LTI message to the tool, you should use +the ``get_end_assessment_return`` Python API function. This will return a boolean representing whether the tool +requested that the platform send an ``LtiEndAssessment`` LTI message at the end of the proctored assessment. + +.. code:: python + + end_assessment_return = get_end_assessment_return(<user_id>, <resource_link_id>) + + if end_assessment_return: + proctoring_launch_data = Lti1p3ProctoringLaunchData( + attempt_number=<attempt_number>, + ) + + launch_data = Lti1p3LaunchData( + user_id=<user_id>, + user_role=<user_role>, + config_id=<config_id>, + resource_link_id=<resource_link_id>, + message_type="LtiEndAssessment", + proctoring_launch_data=proctoring_launch_data, + ) + + return redirect(get_lti_1p3_launch_start_url(launch_data)) + + +Note that for an ``LtiEndAssessment`` LTI launch message, the ``message_type`` field of the ``Lti1p3LaunchData`` +instance must be ``LtiEndAssessment``. Unlike the ``LtiStartProctoring`` message, the ``start_assessment_url`` field of +the ``Lti1p3ProctoringLaunchData`` instance should not be supplied. diff --git a/lti_consumer/api.py b/lti_consumer/api.py index f0f3dd7059dc72d8a392f07c68c0a96a26d19cfa..34290d574fac720c22bfd9c30fb0b8ac919dd9f4 100644 --- a/lti_consumer/api.py +++ b/lti_consumer/api.py @@ -12,6 +12,8 @@ from opaque_keys.edx.keys import CourseKey from lti_consumer.lti_1p3.constants import LTI_1P3_ROLE_MAP from .models import CourseAllowPIISharingInLTIFlag, LtiConfiguration, LtiDlContentItem from .utils import ( + get_cache_key, + get_data_from_cache, get_lti_1p3_context_types_claim, get_lti_deeplinking_content_url, get_lms_lti_keyset_link, @@ -261,7 +263,7 @@ def validate_lti_1p3_launch_data(launch_data): "The context_id attribute is required in the launch data if any optional context properties are provided." ) - if launch_data.user_role not in LTI_1P3_ROLE_MAP: + if launch_data.user_role not in LTI_1P3_ROLE_MAP and launch_data.user_role is not None: validation_messages.append(f"The user_role attribute {launch_data.user_role} is not a valid user_role.") context_type = launch_data.context_type @@ -273,7 +275,42 @@ def validate_lti_1p3_launch_data(launch_data): f"The context_type attribute {context_type} in the launch data is not a valid context_type." ) + proctoring_launch_data = launch_data.proctoring_launch_data + if (launch_data.message_type in ["LtiStartProctoring", "LtiEndAssessment"] and not + proctoring_launch_data): + validation_messages.append( + "The proctoring_launch_data attribute is required if the message_type attribute is \"LtiStartProctoring\" " + "or \"LtiEndAssessment\"." + ) + + if (proctoring_launch_data and launch_data.message_type == "LtiStartProctoring" and not + proctoring_launch_data.start_assessment_url): + validation_messages.append( + "The proctoring_start_assessment_url attribute is required if the message_type attribute is " + "\"LtiStartProctoring\"." + ) + if validation_messages: return False, validation_messages else: return True, [] + + +def get_end_assessment_return(user_id, resource_link_id): + """ + Returns the end_assessment_return value stored in the cache. This can be used by applications to determine whether + to invoke an LtiEndAssessment LTI launch. + + Arguments: + * user_id: the database of the requesting User model instance + * resource_link_id: the resource_link_id of the LTI link for the assessment + """ + end_assessment_return_key = get_cache_key( + app="lti", + key="end_assessment_return", + user_id=user_id, + resource_link_id=resource_link_id, + ) + cached_end_assessment_return = get_data_from_cache(end_assessment_return_key) + + return cached_end_assessment_return diff --git a/lti_consumer/apps.py b/lti_consumer/apps.py index e50a43198a6e757e6f544f9becc0bb0633d396f9..f3f599687bc2bbff3df6db8473e762ecfdc4dc28 100644 --- a/lti_consumer/apps.py +++ b/lti_consumer/apps.py @@ -26,4 +26,4 @@ class LTIConsumerApp(AppConfig): def ready(self): # pylint: disable=unused-import,import-outside-toplevel - import lti_consumer.signals + from lti_consumer.signals import signals diff --git a/lti_consumer/data.py b/lti_consumer/data.py index 7d2bfcfe7c1a7fa42d5323d0660042ba3d8d0000..b450115db5d3becfaed4a1cb8bc41357ed5e8c43 100644 --- a/lti_consumer/data.py +++ b/lti_consumer/data.py @@ -3,7 +3,30 @@ This modules provides public data structures to represent LTI 1.3 launch data th by users of this library. """ -from attrs import define, field +from attrs import define, field, validators + + +@define +class Lti1p3ProctoringLaunchData: + """ + The Lti1p3ProctoringLaunchData class contains data necessary and related to an LTI 1.3 proctoring launch. + It is a mechanism to share launch data between apps. Applications using this library should use the + Lti1p3ProctoringLaunchData class to supply contextually defined or stored launch data to the LTI 1.3 proctoring + launch. + + Lti1p3ProctoringLaunchData is intended to be initialized and included as an attribute of an instance of the + Lti1p3LaunchData class. + + * attempt_number (required): The number of the user's attempt in the assessment. The attempt number + should be an integer that starts with 1 and that is incremented by 1 for each of user's subsequent attempts at + the assessment. + * start_assessment_url (conditionally required): The Platform URL that the Tool will send the start + assessment message to after it has completed the proctoring setup and verification. This attribute is required + if the message_type attribute of the Lti1p3LaunchData instance is "LtiStartProctoring". It is optional and + unused otherwise. + """ + attempt_number = field() + start_assessment_url = field(default=None) @define @@ -13,34 +36,53 @@ class Lti1p3LaunchData: launch data between apps. Applications using this library should use the Lti1p3LaunchData class to supply contextually defined or stored launch data to the generic LTI 1.3 launch. - * user_id (required): the user's unique identifier - * user_role (required): the user's role as one of the keys in LTI_1P3_ROLE_MAP: staff, instructor, student, or - guest - * config_id (required): the config_id field of an LtiConfiguration to use for the launch - * resource_link_id (required): a unique identifier that is guaranteed to be unique for each placement of the LTI - link - * launch_presentation_document_target (optional): the document_target property of the launch_presentation claim; it - describes the kind of browser window or frame from which the user launched inside the message sender's system; - it is one of frame, iframe, or window; it defaults to iframe - * message_type (optional): the message type of the eventual LTI launch; defaults to LtiResourceLinkRequest - * context_id (conditionally required): the id property of the context claim; the stable, unique identifier for the - context; if any of the context properties are provided, context_id is required - * context_type (optional): the type property of the context claim; a list of some combination of the following valid - context_types: group, course_offering, course_section, or course_template - * context_title (optional): the title proerty of the context claim; a short, descriptive name for the context - * context_label (optional): the label property of the context claim; a full, descriptive name for the context - * deep_linking_context_item_id (optional): the database id of the LtiDlContentItem that should be used for the LTI - 1.3 Deep Linking launch; this is used when the LTI 1.3 Deep Linking launch is a regular LTI resource link - request using a content item that was configured via a previous LTI 1.3 Deep Linking request + * user_id (required): A unique identifier for the user that is requesting the LTI 1.3 launch. If the optional + attribute external_user_id is provided, user_id will only be used internally and will not be shared externally. + If external_user_id is not provided, user_id will be shared externally, and then it must be stable to the + issuer. + * user_role (required): The user's role as one of the keys in LTI_1P3_ROLE_MAP: staff, instructor, student, or + guest. It can also be None if the empty list should be sent in the LTI launch message. + * config_id (required): The config_id field of an LtiConfiguration to use for the launch. + * resource_link_id (required): A unique identifier that is guaranteed to be unique for each placement of the LTI + link. + * external_user_id (optional): A unique identifier for the user that is requesting the LTI 1.3 launch that can be + shared externally. The identifier must be stable to the issuer. This value will be sent to the the Tool in the + form of both the login_hint in the login initiation request and the sub claim in the ID token of the LTI 1.3 + launch message. Use this attribute to specify what value to send as this claim. Otherwise, the value of the + required user_id attribute will be used. + * launch_presentation_document_target (optional): The document_target property of the launch_presentation claim. It + describes the kind of browser window or frame from which the user launched inside the message sender's system. + It is one of frame, iframe, or window; it defaults to iframe. + * launch_presentation_return_url (optional): A URL where the Tool can redirect the learner after the learner + completes the activity or is unable to start the activity. + * message_type (optional): The message type of the eventual LTI launch. It defaults to LtiResourceLinkRequest. + * context_id (conditionally required): The id property of the context claim. It is the stable, unique identifier for + the context. It is required if any of the context properties are provided. + * context_type (optional): The type property of the context claim. It is a list of some combination of the following + valid context_types: group, course_offering, course_section, or course_template. + * context_title (optional): The title proerty of the context claim. It is a short, descriptive name for the context. + * context_label (optional): The label property of the context claim. It is a full, descriptive name for the context. + * deep_linking_context_item_id (optional): The database id of the LtiDlContentItem that should be used for the LTI + 1.3 Deep Linking launch. It is used when the LTI 1.3 Deep Linking launch is a regular LTI resource link + request using a content item that was configured via a previous LTI 1.3 Deep Linking request. + * proctoring_launch_data (conditionally required): An instance of the Lti1p3ProctoringLaunchData that contains + data necessary and related to an LTI 1.3 proctoring launch. It is required if the message_type attribute is + "LtiStartProctoring" or "LtiEndAssessment". """ user_id = field() user_role = field() config_id = field() resource_link_id = field() - launch_presentation_document_target = field(default="iframe") + external_user_id = field(default=None) + launch_presentation_document_target = field(default=None) + launch_presentation_return_url = field(default=None) message_type = field(default="LtiResourceLinkRequest") context_id = field(default=None) context_type = field(default=None) context_title = field(default=None) context_label = field(default=None) deep_linking_content_item_id = field(default=None) + proctoring_launch_data = field( + default=None, + validator=validators.optional((validators.instance_of(Lti1p3ProctoringLaunchData))), + ) diff --git a/lti_consumer/lti_1p3/constants.py b/lti_consumer/lti_1p3/constants.py index 5f85d2974820e8fb112208477555d40b423a1a9c..d00e3c203cdb29f5256b6f40da555b02a01ca6c1 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_id', 'session_data', 'start_assessment_url'] diff --git a/lti_consumer/lti_1p3/consumer.py b/lti_consumer/lti_1p3/consumer.py index c45a608384a58e0a758727776f807193e8557f03..6360227de84f07c2f2340fcf784ef9cb9a606ef5 100644 --- a/lti_consumer/lti_1p3/consumer.py +++ b/lti_consumer/lti_1p3/consumer.py @@ -4,7 +4,8 @@ LTI 1.3 Consumer implementation import logging from urllib.parse import urlencode -from lti_consumer.utils import cache_lti_1p3_launch_data, get_data_from_cache +from lti_consumer.lti_1p3.exceptions import InvalidClaimValue +from lti_consumer.utils import cache_lti_1p3_launch_data, check_token_claim, get_data_from_cache from . import constants, exceptions from .constants import ( @@ -13,6 +14,7 @@ from .constants import ( 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 @@ -101,7 +103,7 @@ class LtiConsumer1p3: """ Generates OIDC url with parameters """ - user_id = launch_data.user_id + user_id = launch_data.external_user_id if launch_data.external_user_id else launch_data.user_id # Set the launch_data in the cache. An LTI 1.3 launch involves two "legs" - the third party initiated # login request (the preflight request) and the actual launch -, and this information must be shared between @@ -193,25 +195,27 @@ class LtiConsumer1p3: def set_launch_presentation_claim( self, - document_target="iframe" + document_target=None, + return_url=None, ): """ Optional: Set launch presentation claims http://www.imsglobal.org/spec/lti/v1p3/#launch-presentation-claim """ - if document_target not in ['iframe', 'frame', 'window']: + if document_target is not None and document_target not in ['iframe', 'frame', 'window']: raise ValueError("Invalid launch presentation format.") + lti_claim_launch_presentation = {} + + if document_target: + lti_claim_launch_presentation.update({"document_target": document_target}) + + if return_url: + lti_claim_launch_presentation.update({"return_url": return_url}) + 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. - }, + "https://purl.imsglobal.org/spec/lti/claim/launch_presentation": lti_claim_launch_presentation, } def set_context_claim( @@ -704,3 +708,193 @@ class LtiAdvantageConsumer(LtiConsumer1p3): # 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 LtiConsumer1p3 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 assessment 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): + """ + Sets 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): + """ + Returns 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): + """ + Returns 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): + """ + Returns 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, + ): + """ + Builds and return LTI launch message for proctoring. + + Overrides LtiConsumer1p3's method to include proctoring specific launch claims. 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') + launch_data = get_data_from_cache(lti_message_hint) + + if launch_data.message_type == "LtiStartProctoring": + proctoring_claims = self.get_start_proctoring_claims() + elif launch_data.message_type == "LtiEndAssessment": + proctoring_claims = self.get_end_assessment_claims() + else: + raise ValueError('lti_message_hint must \"LtiStartProctoring\" or \"LtiEndAssessment\".') + + self.set_extra_claim(proctoring_claims) + + return super().generate_launch_request(preflight_response) + + def check_and_decode_token(self, token): + """ + Decodes a Tool JWT token and validates OAuth and LTI Proctoring Services specificatin related claims. Returns a + dictionary representation of key proctoring claims in the Tool JWT token. + + Arguments: + * token (string): a JWT + """ + # Decode token and check expiration. + proctoring_response = self.tool_jwt.validate_and_decode(token) + + # ------------------------- + # 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: This is a special case. 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. For now, check + # that the resource_link claim is supplied and that the id attribute is correct. + claim_key = "https://purl.imsglobal.org/spec/lti/claim/resource_link" + resource_link = proctoring_response.get(claim_key) + check_token_claim( + proctoring_response, + claim_key, + ) + + resource_link_id = resource_link.get("id") + if self.proctoring_data.get("resource_link_id") != resource_link_id: + raise InvalidClaimValue(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." + ) + + response = { + 'end_assessment_return': proctoring_response.get( + "https://purl.imsglobal.org/spec/lti-ap/claim/end_assessment_return", + ), + 'verified_user': proctoring_response.get("https://purl.imsglobal.org/spec/lti-ap/claim/verified_user", {}), + 'resource_link': proctoring_response["https://purl.imsglobal.org/spec/lti/claim/resource_link"], + 'session_data': proctoring_response["https://purl.imsglobal.org/spec/lti-ap/claim/session_data"], + 'attempt_number': proctoring_response["https://purl.imsglobal.org/spec/lti-ap/claim/attempt_number"], + } + + return response diff --git a/lti_consumer/lti_1p3/exceptions.py b/lti_consumer/lti_1p3/exceptions.py index 7ef835b906b4346c79e10257f447ac1e9867d9d9..0b2c67c62987217ead6f9710d8e2dbe20ca70b92 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 7f52b4a70d0d43d1ce0d3e8bb924853fe662a757..64048c5df05910f1a60f2ed24dba8f66a66e669c 100644 --- a/lti_consumer/lti_1p3/key_handlers.py +++ b/lti_consumer/lti_1p3/key_handlers.py @@ -10,7 +10,7 @@ import time 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 @@ class ToolKeyHandler: 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: diff --git a/lti_consumer/lti_1p3/tests/test_consumer.py b/lti_consumer/lti_1p3/tests/test_consumer.py index 423c414e667b55cd84f57be733688ae84b686f66..9528e344c2a927cf43d5924b98b205de25c526aa 100644 --- a/lti_consumer/lti_1p3/tests/test_consumer.py +++ b/lti_consumer/lti_1p3/tests/test_consumer.py @@ -18,8 +18,10 @@ from lti_consumer.lti_1p3 import exceptions from lti_consumer.lti_1p3.ags import LtiAgs from lti_consumer.lti_1p3.deep_linking import LtiDeepLinking from lti_consumer.lti_1p3.nprs import LtiNrps -from lti_consumer.lti_1p3.constants import LTI_1P3_CONTEXT_TYPE -from lti_consumer.lti_1p3.consumer import LtiAdvantageConsumer, LtiConsumer1p3 +from lti_consumer.lti_1p3.constants import LTI_1P3_CONTEXT_TYPE, LTI_PROCTORING_DATA_KEYS +from lti_consumer.lti_1p3.consumer import LtiAdvantageConsumer, LtiConsumer1p3, LtiProctoringConsumer +from lti_consumer.lti_1p3.exceptions import InvalidClaimValue, MissingRequiredClaim + # Variables required for testing and verification ISS = "http://test-platform.example/" @@ -247,12 +249,18 @@ class TestLti1p3Consumer(TestCase): Check if setting presentation claim data works """ self._setup_lti_launch_data() - self.lti_consumer.set_launch_presentation_claim(document_target=target) + + return_url = "return_url" + self.lti_consumer.set_launch_presentation_claim( + document_target=target, + return_url=return_url, + ) self.assertEqual( self.lti_consumer.lti_claim_launch_presentation, { "https://purl.imsglobal.org/spec/lti/claim/launch_presentation": { - "document_target": target + "document_target": target, + "return_url": return_url, } } ) @@ -269,7 +277,8 @@ class TestLti1p3Consumer(TestCase): self.assertEqual( decoded["https://purl.imsglobal.org/spec/lti/claim/launch_presentation"], { - "document_target": target + "document_target": target, + "return_url": return_url, } ) @@ -846,3 +855,260 @@ class TestLtiAdvantageConsumer(TestCase): } } ) + + +@ddt.ddt +class TestLtiProctoringConsumer(TestCase): + """ + Unit tests for LtiProctoringConsumer + """ + def setUp(self): + super().setUp() + + # Set up consumer + self.lti_consumer = LtiProctoringConsumer( + 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, + # Use the same key for testing purposes + tool_key=RSA_KEY + ) + + self.preflight_response = {} + + def _setup_proctoring(self): + """ + Sets up data necessary for a proctoring LTI launch. + """ + # Set LTI Consumer parameters + self.preflight_response = { + "client_id": CLIENT_ID, + "redirect_uri": LAUNCH_URL, + "nonce": NONCE, + "state": STATE, + "lti_message_hint": "lti_message_hint", + } + self.lti_consumer.set_user_data("1", "student") + self.lti_consumer.set_resource_link_claim("resource_link_id") + + def get_launch_data(self, **kwargs): + """ + Returns a sample instance of Lti1p3LaunchData. + """ + launch_data_kwargs = { + "user_id": "user_id", + "user_role": "student", + "config_id": "1", + "resource_link_id": "resource_link_id", + } + + launch_data_kwargs.update(kwargs) + + return Lti1p3LaunchData(**launch_data_kwargs) + + @ddt.data(*LTI_PROCTORING_DATA_KEYS) + def test_valid_set_proctoring_data(self, key): + """ + Ensures that valid proctoring data can be set on an instance of LtiProctoringConsumer. + """ + value = "test_value" + self.lti_consumer.set_proctoring_data(**{key: value}) + + actual_value = self.lti_consumer.proctoring_data[key] + + self.assertEqual(value, actual_value) + + def test_invalid_set_proctoring_data(self): + """ + Ensures that an attempt to set invalid proctoring data on an instance of LtiProctoringConsumer does not update + the consumer's proctoring_data. + """ + self.lti_consumer.set_proctoring_data(test_key="test_value") + + self.assertEqual({}, self.lti_consumer.proctoring_data) + + def test_get_start_proctoring_claims(self): + """ + Ensures that the correct claims are returned for a LtiStartProctoring LTI launch message. + """ + self.lti_consumer.set_proctoring_data( + attempt_number="attempt_number", + session_data="session_data", + start_assessment_url="start_assessment_url", + ) + + actual_start_proctoring_claims = self.lti_consumer.get_start_proctoring_claims() + + expected_start_proctoring_claims = { + "https://purl.imsglobal.org/spec/lti-ap/claim/attempt_number": "attempt_number", + "https://purl.imsglobal.org/spec/lti-ap/claim/session_data": "session_data", + "https://purl.imsglobal.org/spec/lti/claim/message_type": "LtiStartProctoring", + "https://purl.imsglobal.org/spec/lti-ap/claim/start_assessment_url": "start_assessment_url", + } + + self.assertEqual(expected_start_proctoring_claims, actual_start_proctoring_claims) + + def test_get_end_assessment_claims(self): + """ + Ensures that the correct claims are returned for a LtiEndAssessment LTI launch message. + """ + self.lti_consumer.set_proctoring_data( + attempt_number="attempt_number", + session_data="session_data", + ) + + actual_get_end_assessment_claims = self.lti_consumer.get_end_assessment_claims() + + expected_get_end_assessment_claims = { + "https://purl.imsglobal.org/spec/lti-ap/claim/attempt_number": "attempt_number", + "https://purl.imsglobal.org/spec/lti-ap/claim/session_data": "session_data", + "https://purl.imsglobal.org/spec/lti/claim/message_type": "LtiEndAssessment", + } + + self.assertEqual(expected_get_end_assessment_claims, actual_get_end_assessment_claims) + + @ddt.data("LtiStartProctoring", "LtiEndAssessment") + @patch('lti_consumer.lti_1p3.consumer.get_data_from_cache') + def test_generate_launch_request(self, message_type, mock_get_data_from_cache): + """ + Ensures that the correct claims are included in LTI launch messages for the LtiStartProctoring and + LtiEndAssessment launch message types. + """ + + mock_launch_data = self.get_launch_data(message_type=message_type) + mock_get_data_from_cache.return_value = mock_launch_data + + self._setup_proctoring() + + self.lti_consumer.set_proctoring_data( + attempt_number="attempt_number", + session_data="session_data", + start_assessment_url="start_assessment_url", + ) + + token = self.lti_consumer.generate_launch_request( + self.preflight_response, + )['id_token'] + + decoded_token = self.lti_consumer.key_handler.validate_and_decode(token) + + expected_claims = {} + + if message_type == "LtiStartProctoring": + expected_claims = self.lti_consumer.get_start_proctoring_claims() + else: + expected_claims = self.lti_consumer.get_end_assessment_claims() + + decoded_token_claims = decoded_token.items() + for claim in expected_claims.items(): + self.assertIn(claim, decoded_token_claims) + + @patch('lti_consumer.lti_1p3.consumer.get_data_from_cache') + def test_generate_launch_request_invalid_message(self, mock_get_data_from_cache): + """ + Ensures that a ValueError is raised if the launch_data.message_type is not LtiStartProctoring or + LtiEndAssessment. + """ + + mock_launch_data = self.get_launch_data(message_type="LtiResourceLinkRequest") + mock_get_data_from_cache.return_value = mock_launch_data + + self._setup_proctoring() + + self.lti_consumer.set_proctoring_data( + attempt_number="attempt_number", + session_data="session_data", + start_assessment_url="start_assessment_url", + ) + + with self.assertRaises(ValueError): + _ = self.lti_consumer.generate_launch_request( + self.preflight_response, + )['id_token'] + + @ddt.data( + "https://purl.imsglobal.org/spec/lti/claim/message_type", + "https://purl.imsglobal.org/spec/lti/claim/version", + "https://purl.imsglobal.org/spec/lti-ap/claim/session_data", + "https://purl.imsglobal.org/spec/lti/claim/resource_link", + "https://purl.imsglobal.org/spec/lti-ap/claim/attempt_number", + ) + def test_invalid_check_and_decode_token(self, claim_key): + """ + Ensures that LtiStartAssessment JWTs are correctly validated; ensures that missing or incorrect claims cause an + InvalidClaimValue or MissingRequiredClaim exception to be raised. + """ + self.lti_consumer.set_proctoring_data( + attempt_number="attempt_number", + session_data="session_data", + start_assessment_url="start_assessment_url", + resource_link_id="resource_link_id", + ) + + start_assessment_response = { + "https://purl.imsglobal.org/spec/lti/claim/message_type": "LtiStartAssessment", + "https://purl.imsglobal.org/spec/lti/claim/version": "1.3.0", + "https://purl.imsglobal.org/spec/lti-ap/claim/session_data": "session_data", + "https://purl.imsglobal.org/spec/lti/claim/resource_link": {"id": "resource_link_id"}, + "https://purl.imsglobal.org/spec/lti-ap/claim/attempt_number": "attempt_number", + } + + # Check invalid claim values. + start_assessment_response[claim_key] = {} + encoded_token = self.lti_consumer.key_handler.encode_and_sign( + message=start_assessment_response, + expiration=3600 + ) + + with self.assertRaises(InvalidClaimValue): + self.lti_consumer.check_and_decode_token(encoded_token) + + # Check missing claims. + del start_assessment_response[claim_key] + encoded_token = self.lti_consumer.key_handler.encode_and_sign( + message=start_assessment_response, + expiration=3600 + ) + + with self.assertRaises(MissingRequiredClaim): + self.lti_consumer.check_and_decode_token(encoded_token) + + def test_valid_check_and_decode_token(self): + """ + Ensures that a valid LtiStartAssessment JWT is validated successfully. + """ + self.lti_consumer.set_proctoring_data( + attempt_number="attempt_number", + session_data="session_data", + start_assessment_url="start_assessment_url", + resource_link_id="resource_link_id", + ) + + start_assessment_response = { + "https://purl.imsglobal.org/spec/lti/claim/message_type": "LtiStartAssessment", + "https://purl.imsglobal.org/spec/lti/claim/version": "1.3.0", + "https://purl.imsglobal.org/spec/lti-ap/claim/session_data": "session_data", + "https://purl.imsglobal.org/spec/lti/claim/resource_link": {"id": "resource_link_id"}, + "https://purl.imsglobal.org/spec/lti-ap/claim/attempt_number": "attempt_number", + "https://purl.imsglobal.org/spec/lti-ap/claim/verified_user": {"name": "Bob"}, + "https://purl.imsglobal.org/spec/lti-ap/claim/end_assessment_return": "end_assessment_return", + } + + encoded_token = self.lti_consumer.key_handler.encode_and_sign( + message=start_assessment_response, + expiration=3600 + ) + + response = self.lti_consumer.check_and_decode_token(encoded_token) + expected_response = { + "end_assessment_return": "end_assessment_return", + "verified_user": {"name": "Bob"}, + "resource_link": {"id": "resource_link_id"}, + "session_data": "session_data", + "attempt_number": "attempt_number" + } + self.assertEqual(expected_response, response) diff --git a/lti_consumer/lti_1p3/tests/test_key_handlers.py b/lti_consumer/lti_1p3/tests/test_key_handlers.py index ab7a7734e299f57bd9b170a5a76ad87c6525938a..e087ad8da4ce59a1949a5cce33f68e71df7cddf7 100644 --- a/lti_consumer/lti_1p3/tests/test_key_handlers.py +++ b/lti_consumer/lti_1p3/tests/test_key_handlers.py @@ -8,6 +8,7 @@ from unittest.mock import patch import ddt from Cryptodome.PublicKey import RSA from django.test.testcases import TestCase +from jwkest import BadSignature from jwkest.jwk import RSAKey, load_jwks from jwkest.jws import JWS @@ -300,3 +301,20 @@ class TestToolKeyHandler(TestCase): # Decode and check results with self.assertRaises(exceptions.NoSuitableKeys): key_handler.validate_and_decode(signed) + + @patch("lti_consumer.lti_1p3.key_handlers.JWS.verify_compact") + def test_validate_and_decode_bad_signature(self, mock_verify_compact): + mock_verify_compact.side_effect = BadSignature() + + key_handler = ToolKeyHandler() + + message = { + "test": "test_message", + "iat": 1000, + "exp": 1200, + } + signed = create_jwt(self.key, message) + + # Decode and check results + with self.assertRaises(exceptions.BadJwtSignature): + key_handler.validate_and_decode(signed) diff --git a/lti_consumer/lti_xblock.py b/lti_consumer/lti_xblock.py index 9973e4395917bec2af8bcea551442e282b3c4557..311f9fed743b51d88b816f21ff9b312e9a275fd2 100644 --- a/lti_consumer/lti_xblock.py +++ b/lti_consumer/lti_xblock.py @@ -785,7 +785,19 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock): return '', '' @property - def user_id(self): + def lms_user_id(self): + """ + Returns the edx-platform database user id for the current user. + """ + user_id = self.runtime.service(self, 'user').get_current_user().opt_attrs.get( + 'edx-platform.user_id', None) + + if user_id is None: + raise LtiError(self.ugettext("Could not get user id for current request")) + return user_id + + @property + def anonymous_user_id(self): """ Returns the opaque anonymous_student_id for the current user. This defaults to 'student' when testing in studio. @@ -863,7 +875,7 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock): return "{context}:{resource_link}:{user_id}".format( context=urllib.parse.quote(self.context_id), resource_link=self.resource_link_id, - user_id=self.user_id + user_id=self.anonymous_user_id ) @property @@ -1100,7 +1112,7 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock): # return a 400 response with an appropriate error template. try: real_user_data = self.extract_real_user_data() - user_id = self.user_id + user_id = self.anonymous_user_id role = self.role # Convert the LMS role into an LTI 1.1 role. @@ -1429,10 +1441,11 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock): course_key = str(location.course_key) launch_data = Lti1p3LaunchData( - user_id=self.external_user_id, + user_id=self.lms_user_id, user_role=self.role, config_id=config_id, resource_link_id=str(location), + external_user_id=self.external_user_id, launch_presentation_document_target="iframe", context_id=course_key, context_type=["course_offering"], 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 0000000000000000000000000000000000000000..ea218b5bce4e3fd4f318eafee295d3e73f739722 --- /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-10-13 20:26 + +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 0254f92ce6eb3fc00ed8fb5d3c14a8baf8597e0e..2eb9ae013f3fae1af34bd57e4e982b775b0e2f28 100644 --- a/lti_consumer/models.py +++ b/lti_consumer/models.py @@ -20,7 +20,7 @@ from lti_consumer.filters import get_external_config_from_filter # 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 @@ -227,6 +227,13 @@ class LtiConfiguration(models.Model): 'grades.' ) + # LTI Proctoring Service Related Variables + lti_1p3_proctoring_enabled = models.BooleanField( + "Enable LTI Proctoring Services", + default=False, + help_text='Enable LTI Proctoring Services', + ) + def clean(self): if self.config_store == self.CONFIG_ON_XBLOCK and self.location is None: raise ValidationError({ @@ -245,6 +252,12 @@ class LtiConfiguration(models.Model): "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] and + self.lti_1p3_proctoring_enabled): + 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: @@ -470,9 +483,18 @@ class LtiConfiguration(models.Model): 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. + # NOTE: This currently prevents an LTI Consumer from supporting both the LTI 1.3 proctoring feature and the LTI + # Advantage services. We plan to address this. Follow this issue: + # https://github.com/openedx/xblock-lti-consumer/issues/303. + 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: block = compat.load_enough_xblock(self.location) - consumer = LtiAdvantageConsumer( + + consumer = consumer_class( iss=get_lms_base(), lti_oidc_url=block.lti_1p3_oidc_url, lti_launch_url=block.lti_1p3_launch_url, @@ -488,7 +510,7 @@ class LtiConfiguration(models.Model): tool_keyset_url=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, @@ -508,9 +530,11 @@ class LtiConfiguration(models.Model): # or CONFIG_ON_DB. raise NotImplementedError - self._setup_lti_1p3_ags(consumer) - self._setup_lti_1p3_deep_linking(consumer) - self._setup_lti_1p3_nrps(consumer) + if isinstance(consumer, LtiAdvantageConsumer): + self._setup_lti_1p3_ags(consumer) + self._setup_lti_1p3_deep_linking(consumer) + self._setup_lti_1p3_nrps(consumer) + return consumer def get_lti_consumer(self): diff --git a/lti_consumer/plugin/urls.py b/lti_consumer/plugin/urls.py index 39496e8b94283bec37918f2ae51b9c46db4a42b9..69780718c8e77e34a59c6e4737624b8a2b5f5c5a 100644 --- a/lti_consumer/plugin/urls.py +++ b/lti_consumer/plugin/urls.py @@ -10,7 +10,8 @@ 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) + launch_gate_endpoint, public_keyset_endpoint, + start_proctoring_assessment_endpoint) # LTI 1.3 APIs router router = routers.SimpleRouter(trailing_slash=False) @@ -59,4 +60,9 @@ urlpatterns = [ r'lti_consumer/v1/lti/(?P<lti_config_id>[-\w]+)/', include(router.urls) ), + path( + 'lti_consumer/v1/start_proctoring_assessment', + start_proctoring_assessment_endpoint, + name='lti_consumer.start_proctoring_assessment_endpoint' + ), ] diff --git a/lti_consumer/plugin/views.py b/lti_consumer/plugin/views.py index bc896a46d8f362301ca21edcaed4434788a96240..d1f971dc3ce286431927dabc4b0ac48812251fa8 100644 --- a/lti_consumer/plugin/views.py +++ b/lti_consumer/plugin/views.py @@ -6,67 +6,48 @@ import urllib from django.contrib.auth import get_user_model from django.core.exceptions import ObjectDoesNotExist, PermissionDenied, ValidationError -from django.http import JsonResponse, Http404 from django.db import transaction +from django.http import Http404, JsonResponse +from django.shortcuts import render +from django.utils.crypto import get_random_string +from django.views.decorators.clickjacking import xframe_options_exempt, xframe_options_sameorigin 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_exempt, xframe_options_sameorigin -from django.shortcuts import render from django_filters.rest_framework import DjangoFilterBackend +from edx_django_utils.cache import TieredCache, get_cache_key +from jwkest.jwt import JWT, BadSyntax from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import UsageKey -from rest_framework import viewsets, status +from rest_framework import status, viewsets from rest_framework.decorators import action 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, validate_lti_1p3_launch_data from lti_consumer.exceptions import LtiError -from lti_consumer.models import ( - LtiConfiguration, - LtiAgsLineItem, - LtiDlContentItem, -) - -from lti_consumer.lti_1p3.exceptions import ( - Lti1p3Exception, - LtiDeepLinkingContentTypeNotSupported, - UnsupportedGrantType, - MalformedJwtToken, - MissingRequiredClaim, - NoSuitableKeys, - TokenSignatureExpired, - UnknownClientId, -) -from lti_consumer.lti_1p3.extensions.rest_framework.constants import LTI_DL_CONTENT_TYPE_SERIALIZER_MAP -from lti_consumer.lti_1p3.extensions.rest_framework.serializers import ( - LtiAgsLineItemSerializer, - LtiAgsScoreSerializer, - LtiAgsResultSerializer, - LtiNrpsContextMembershipBasicSerializer, - LtiNrpsContextMembershipPIISerializer, -) -from lti_consumer.lti_1p3.extensions.rest_framework.permissions import ( - LtiAgsPermissions, - LtiNrpsContextMembershipsPermissions, -) +from lti_consumer.lti_1p3.consumer import LtiProctoringConsumer +from lti_consumer.lti_1p3.exceptions import (BadJwtSignature, InvalidClaimValue, Lti1p3Exception, + LtiDeepLinkingContentTypeNotSupported, MalformedJwtToken, + MissingRequiredClaim, NoSuitableKeys, TokenSignatureExpired, + UnknownClientId, UnsupportedGrantType) from lti_consumer.lti_1p3.extensions.rest_framework.authentication import Lti1p3ApiAuthentication -from lti_consumer.lti_1p3.extensions.rest_framework.renderers import ( - LineItemsRenderer, - LineItemRenderer, - LineItemScoreRenderer, - LineItemResultsRenderer, - MembershipResultRenderer, -) -from lti_consumer.lti_1p3.extensions.rest_framework.parsers import ( - LineItemParser, - LineItemScoreParser, -) +from lti_consumer.lti_1p3.extensions.rest_framework.constants import LTI_DL_CONTENT_TYPE_SERIALIZER_MAP +from lti_consumer.lti_1p3.extensions.rest_framework.parsers import LineItemParser, LineItemScoreParser +from lti_consumer.lti_1p3.extensions.rest_framework.permissions import (LtiAgsPermissions, + LtiNrpsContextMembershipsPermissions) +from lti_consumer.lti_1p3.extensions.rest_framework.renderers import (LineItemRenderer, LineItemResultsRenderer, + LineItemScoreRenderer, LineItemsRenderer, + MembershipResultRenderer) +from lti_consumer.lti_1p3.extensions.rest_framework.serializers import (LtiAgsLineItemSerializer, + LtiAgsResultSerializer, LtiAgsScoreSerializer, + LtiNrpsContextMembershipBasicSerializer, + LtiNrpsContextMembershipPIISerializer) from lti_consumer.lti_1p3.extensions.rest_framework.utils import IgnoreContentNegotiation +from lti_consumer.models import LtiAgsLineItem, LtiConfiguration, LtiDlContentItem from lti_consumer.plugin import compat -from lti_consumer.utils import _, get_lti_1p3_context_types_claim, get_data_from_cache +from lti_consumer.signals.signals import LTI_1P3_PROCTORING_ASSESSMENT_STARTED from lti_consumer.track import track_event - +from lti_consumer.utils import _, get_data_from_cache, get_lti_1p3_context_types_claim log = logging.getLogger(__name__) @@ -139,13 +120,18 @@ def public_keyset_endpoint(request, usage_id=None, lti_config_id=None): @require_http_methods(["GET", "POST"]) @xframe_options_exempt +@csrf_exempt def launch_gate_endpoint(request, suffix=None): # pylint: disable=unused-argument """ - Gate endpoint that triggers LTI launch endpoint XBlock handler + Receives an LTI 1.3 authentication request from an LTI tool and returns an LTI 1.3 authentication response. + The authentication request and the authentication response are the second and third steps of the OpenID Connect + Launch Flow, respectively. The authentication response contains the LTI message and is the LTI launch. + + Returns a response containing an auto-submitting form that directs the browser to make a POST to the Tool. - This uses the config_id key of the "lti_message_hint" query parameter - to identify the LtiConfiguration and its consumer to generate the - LTI 1.3 Launch Form. + Query Parameters: + * lti_message_hint (REQUIRED): a value used as a cache key to retrieved a cached instance of Lti1p3LaunchData + * login_hint (REQUIRED): an identifier for the user that initiated the launch; it is stable and unique to the issuer """ # pylint: disable=too-many-statements request_params = request.GET if request.method == 'GET' else request.POST @@ -162,7 +148,10 @@ def launch_gate_endpoint(request, suffix=None): # pylint: disable=unused-argume launch_data = get_data_from_cache(lti_message_hint) if not launch_data: - log.warning(f'There was a cache miss during an LTI 1.3 launch when using the cache_key {lti_message_hint}.') + log.warning( + f'There was a cache miss trying to fetch the launch data during an LTI 1.3 launch when using the cache' + f' key {lti_message_hint}. The login hint is {login_hint}.' + ) return render(request, 'html/lti_launch_error.html', status=HTTP_400_BAD_REQUEST) # Validate the Lti1p3LaunchData. @@ -193,7 +182,7 @@ def launch_gate_endpoint(request, suffix=None): # pylint: disable=unused-argume lti_consumer = lti_config.get_lti_consumer() # Set sub and roles claims. - user_id = launch_data.user_id + user_id = launch_data.external_user_id if launch_data.external_user_id else launch_data.user_id user_role = launch_data.user_role lti_consumer.set_user_data( user_id=user_id, @@ -204,9 +193,10 @@ def launch_gate_endpoint(request, suffix=None): # pylint: disable=unused-argume lti_consumer.set_resource_link_claim(launch_data.resource_link_id) # Set launch_presentation claim. - launch_presentation_target = launch_data.launch_presentation_document_target - if launch_presentation_target: - lti_consumer.set_launch_presentation_claim(launch_presentation_target) + lti_consumer.set_launch_presentation_claim( + document_target=launch_data.launch_presentation_document_target, + return_url=launch_data.launch_presentation_return_url + ) # Set optional context claim, if supplied. context_type = launch_data.context_type @@ -240,7 +230,7 @@ def launch_gate_endpoint(request, suffix=None): # pylint: disable=unused-argume # course creators to set up content. deep_linking_content_item_id = launch_data.deep_linking_content_item_id - if lti_consumer.dl and launch_data.message_type == 'LtiDeepLinkingRequest': + if launch_data.message_type == 'LtiDeepLinkingRequest' and lti_consumer.dl: # Check if the user is staff before LTI doing deep linking launch. # If not, raise exception and display error page if user_role not in ['instructor', 'staff']: @@ -251,7 +241,7 @@ def launch_gate_endpoint(request, suffix=None): # pylint: disable=unused-argume # Deep Linking ltiResourceLink content presentation # When content type is `ltiResourceLink`, the tool will be launched with # different parameters, set by instructors when running the DL configuration flow. - elif lti_consumer.dl and deep_linking_content_item_id: + elif deep_linking_content_item_id and lti_consumer.dl: # Retrieve Deep Linking parameters using the parameter. content_item = lti_config.ltidlcontentitem_set.get(pk=deep_linking_content_item_id) # Only filter DL content item from content item set in the same LTI configuration. @@ -265,6 +255,30 @@ def launch_gate_endpoint(request, suffix=None): # pylint: disable=unused-argume custom=dl_params.get('custom') ) + if launch_data.message_type == 'LtiStartProctoring': + # 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=launch_data.user_id, + resource_link_id=launch_data.resource_link_id + ) + + session_data = get_data_from_cache(session_data_key) + if not session_data: + session_data = get_random_string(32) + TieredCache.set_all_tiers(session_data_key, session_data) + + lti_consumer.set_proctoring_data( + attempt_number=launch_data.proctoring_launch_data.attempt_number, + session_data=session_data, + start_assessment_url=launch_data.proctoring_launch_data.start_assessment_url + ) + elif launch_data.message_type == 'LtiEndAssessment': + lti_consumer.set_proctoring_data( + attempt_number=launch_data.proctoring_launch_data.attempt_number, + ) + # Update context with LTI launch parameters context.update({ "preflight_response": preflight_response, @@ -302,6 +316,7 @@ def launch_gate_endpoint(request, suffix=None): # pylint: disable=unused-argume @csrf_exempt +@xframe_options_sameorigin @require_http_methods(["POST"]) def access_token_endpoint(request, lti_config_id=None, usage_id=None): """ @@ -471,7 +486,6 @@ def deep_linking_content_endpoint(request, lti_config_id): if not launch_data: log.warning(f'There was a cache miss during an LTI 1.3 launch when using the cache_key {launch_data_key}.') return render(request, 'html/lti_launch_error.html', status=HTTP_400_BAD_REQUEST) - try: # Get LTI Configuration lti_config = LtiConfiguration.objects.get(id=lti_config_id) @@ -705,3 +719,113 @@ class LtiNrpsContextMembershipViewSet(viewsets.ReadOnlyModelViewSet): "error": "above_response_limit", "explanation": "The number of retrieved users is bigger than the maximum allowed in the configuration.", }, status=HTTP_403_FORBIDDEN) + + +@csrf_exempt +@require_http_methods(['POST']) +def start_proctoring_assessment_endpoint(request): + """ + Receives the Proctoring Tool's message to start the assessment. Emits a signal informing interested parties that + the assessment should be started. + + Form Parameters: + * JWT (REQUIRED): a signed JWT containing the LTI message + """ + # In order to get the cached data (session_data and launch_data) from the cache, we need data from the JWT + # before it has been decoded and validated using the ToolKeyHandler. Grab the data we need and validate the JWT + # after. + token = request.POST.get('JWT') + + try: + jwt = JWT().unpack(token) + except BadSyntax: + return render(request, 'html/lti_proctoring_start_error.html', status=HTTP_400_BAD_REQUEST) + + jwt_payload = jwt.payload() + iss = jwt_payload.get('iss') + resource_link_id = jwt_payload.get('https://purl.imsglobal.org/spec/lti/claim/resource_link', {}).get('id') + + try: + lti_config = LtiConfiguration.objects.get(lti_1p3_client_id=iss) + except LtiConfiguration.DoesNotExist: + log.error("Invalid iss claim '%s' for LTI 1.3 Proctoring Services start_proctoring_assessment_endpoint" + " callback", iss) + return render(request, 'html/lti_proctoring_start_error.html', status=HTTP_404_NOT_FOUND) + + lti_consumer = lti_config.get_lti_consumer() + + if not isinstance(lti_consumer, LtiProctoringConsumer): + log.info("Proctoring Services for LTIConfiguration with config_id %s are not enabled", lti_config.config_id) + return render(request, 'html/lti_proctoring_start_error.html', status=HTTP_400_BAD_REQUEST) + + # Grab the data we need from the cache: launch_data and session_data. + common_cache_key_arguments = { + "app": "lti", + "user_id": request.user.id, + "resource_link_id": resource_link_id, + } + + launch_data_key = get_cache_key(**common_cache_key_arguments, key="launch_data") + launch_data = get_data_from_cache(launch_data_key) + if not launch_data: + log.warning( + f'There was a cache miss trying to fetch the launch data during an LTI 1.3 proctoring StartAssessment ' + f'launch when using the cache key {launch_data_key}. The LtiConfiguration config_id is ' + f'{lti_config.config_id}.' + ) + return render(request, 'html/lti_proctoring_start_error.html', status=HTTP_400_BAD_REQUEST) + + session_data_key = get_cache_key(**common_cache_key_arguments, key="session_data") + session_data = get_data_from_cache(session_data_key) + + lti_consumer.set_proctoring_data( + attempt_number=launch_data.proctoring_launch_data.attempt_number, + session_data=session_data, + resource_link_id=launch_data.resource_link_id, + ) + + try: + proctoring_response = lti_consumer.check_and_decode_token(token) + + except (BadJwtSignature, InvalidClaimValue, MalformedJwtToken, + MissingRequiredClaim, NoSuitableKeys, TokenSignatureExpired): + return render(request, 'html/lti_proctoring_start_error.html', 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 = proctoring_response.get('end_assessment_return') + if end_assessment_return: + end_assessment_return_key = get_cache_key(**common_cache_key_arguments, key="end_assessment_return") + # 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. This assumes end_assessment_return is a boolean or can otherwise be case to + # an integer. + try: + end_assessment_return_value = int(end_assessment_return) + except ValueError: + # If the end_assessment_return is not a boolean and cannot be cast to an integer, then assume that the value + # is False. We do not want to return a 404 at the end of a proctored session on account of an invalid value + # for this optional claim. + log.error( + "An error occurred during the handling of an LtiStartAssessment LTI lauch message for LTIConfiguration " + f"with config_id {lti_config.config_id} and resource_link_id {resource_link_id}. The " + "end_assessment_return Tool JWT claim is not a boolean value. An LtiEndAssessment LTI launch message " + "will not be sent as part of the end assessment workflow." + ) + else: + # Set a long enough timeout to ensure learners can complete their assessments without a cache timeout. + timeout = 60 * 60 * 12 + TieredCache.set_all_tiers( + end_assessment_return_key, + end_assessment_return_value, + django_cache_timeout=timeout + ) + + LTI_1P3_PROCTORING_ASSESSMENT_STARTED.send( + sender=None, + attempt_number=proctoring_response["attempt_number"], + resource_link=proctoring_response["resource_link"], + user_id=request.user.id, + ) + + return JsonResponse(data={}) diff --git a/lti_consumer/signals/__init__.py b/lti_consumer/signals/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lti_consumer/signals.py b/lti_consumer/signals/signals.py similarity index 96% rename from lti_consumer/signals.py rename to lti_consumer/signals/signals.py index e9d928e9faea722b96f05febe3f48d4cb1cd6082..ccf1194fed9e31aa2cb6487bdb132f3e309c911e 100644 --- a/lti_consumer/signals.py +++ b/lti_consumer/signals/signals.py @@ -4,7 +4,7 @@ LTI Consumer related Signal handlers import logging from django.db.models.signals import post_save -from django.dispatch import receiver +from django.dispatch import receiver, Signal from lti_consumer.models import LtiAgsScore from lti_consumer.plugin import compat @@ -62,3 +62,6 @@ def publish_grade_on_score_update(sender, instance, **kwargs): # pylint: disabl exc, ) raise exc + + +LTI_1P3_PROCTORING_ASSESSMENT_STARTED = Signal() diff --git a/lti_consumer/templates/html/lti_proctoring_start_error.html b/lti_consumer/templates/html/lti_proctoring_start_error.html new file mode 100644 index 0000000000000000000000000000000000000000..21d9eeaad9d9b785c61cea33694664cbd76e1664 --- /dev/null +++ b/lti_consumer/templates/html/lti_proctoring_start_error.html @@ -0,0 +1,16 @@ +{% load i18n %} +<!DOCTYPE HTML> +<html> + <head> + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> + <title>LTI</title> + </head> + <body> + <p> + <b>{% trans "There was an error while starting your LTI proctored assessment." %}</b> + </p> + <p> + {% trans "If you're seeing this on a live course, please contact the course staff." %} + </p> + </body> +</html> diff --git a/lti_consumer/tests/unit/plugin/test_proctoring.py b/lti_consumer/tests/unit/plugin/test_proctoring.py new file mode 100644 index 0000000000000000000000000000000000000000..25a62456ba3e0f6463d1650c8db146396758110d --- /dev/null +++ b/lti_consumer/tests/unit/plugin/test_proctoring.py @@ -0,0 +1,282 @@ +"""" +Tests for LTI 1.3 proctoring endpoint views. +""" +import uuid +from unittest.mock import call, patch + +import ddt +from Cryptodome.PublicKey import RSA +from django.contrib.auth import get_user_model +from django.test.testcases import TestCase +from edx_django_utils.cache import TieredCache, get_cache_key +from jwkest.jwk import RSAKey +from jwkest.jwt import BadSyntax + +from lti_consumer.data import Lti1p3LaunchData, Lti1p3ProctoringLaunchData +from lti_consumer.lti_1p3.exceptions import (BadJwtSignature, InvalidClaimValue, MalformedJwtToken, + MissingRequiredClaim, NoSuitableKeys) +from lti_consumer.lti_1p3.key_handlers import PlatformKeyHandler +from lti_consumer.models import LtiConfiguration +from lti_consumer.utils import get_data_from_cache + + +@ddt.ddt +class TestLti1p3ProctoringStartProctoringAssessmentEndpoint(TestCase): + """Tests for the start_proctoring_assessment_endpoint endpoint.""" + + def setUp(self): + super().setUp() + + self.url = "/lti_consumer/v1/start_proctoring_assessment" + + # Set up user. + self._setup_user() + + # Set up an LtiConfiguration instance for the integration. + self.lti_config = LtiConfiguration.objects.create( + version=LtiConfiguration.LTI_1P3, + lti_1p3_proctoring_enabled=True, + config_store=LtiConfiguration.CONFIG_ON_DB, + ) + + # Set up cached data necessary for this endpoint: launch_data and session_data. + self._setup_cached_data() + + # Set up a public key - private key pair that allows encoding and decoding a Tool JWT. + self.rsa_key_id = str(uuid.uuid4()) + self.private_key = RSA.generate(2048) + self.key = RSAKey( + key=self.private_key, + kid=self.rsa_key_id + ) + self.public_key = self.private_key.publickey().export_key().decode() + + self.lti_config.lti_1p3_tool_public_key = self.public_key + self.lti_config.save() + + def _setup_user(self): + """Sets up the requesting user instance.""" + self.user = get_user_model().objects.create_user( + username="user", + password="password" + ) + self.client.login(username="user", password="password") + + def _setup_cached_data(self): + """Sets up data in the cache necessary for the view: launch_data and session_data.""" + self.common_cache_key_arguments = { + "app": "lti", + "user_id": self.user.id, + "resource_link_id": "resource_link_id", + } + + # Cache session_data. + self.session_data_key = get_cache_key( + **self.common_cache_key_arguments, + key="session_data" + ) + TieredCache.set_all_tiers(self.session_data_key, "session_data") + + # Cache launch_data. + proctoring_launch_data = Lti1p3ProctoringLaunchData(attempt_number=2) + launch_data = Lti1p3LaunchData( + user_id="1", + user_role=None, + config_id=self.lti_config.config_id, + resource_link_id="resource_link_id", + proctoring_launch_data=proctoring_launch_data, + ) + + self.launch_data_key = get_cache_key( + **self.common_cache_key_arguments, + key="launch_data" + ) + TieredCache.set_all_tiers(self.launch_data_key, launch_data) + + def create_tool_jwt_token(self, **kwargs): + """ + Creates and returns a signed JWT token to act as a Tool JWT. + + Arguments: + * kwargs: Keyword arguments representing key, value pairs to include in the JWT token. This allows callers + to override default claims in the JWT token. + """ + lti_consumer = self.lti_config.get_lti_consumer() + + token = { + "iss": lti_consumer.client_id, + "https://purl.imsglobal.org/spec/lti/claim/message_type": "LtiStartAssessment", + "https://purl.imsglobal.org/spec/lti/claim/version": "1.3.0", + "https://purl.imsglobal.org/spec/lti-ap/claim/session_data": "session_data", + "https://purl.imsglobal.org/spec/lti/claim/resource_link": {"id": "resource_link_id"}, + "https://purl.imsglobal.org/spec/lti-ap/claim/attempt_number": 2, + } + + token.update(**kwargs) + + # Encode and sign the Tool JWT using the private key. The PlatformKeyHandler class is the only key handler that + # currently has code to encode and sign a JWT, so we use that class. The ToolKeyHandler will be used in the view + # to decode this JWT using the corresponding public key. + platform_key_handler = PlatformKeyHandler(self.private_key.export_key(), self.rsa_key_id) + signed_token = platform_key_handler.encode_and_sign(token) + + return signed_token + + def test_valid_token(self): + """Tests the happy path of the start_proctoring_assessment_endpoint.""" + response = self.client.post( + self.url, + { + "JWT": self.create_tool_jwt_token() + }, + ) + self.assertEqual(response.status_code, 200) + + def test_unparsable_token(self): + """Tests that a call to the start_assessment_endpoint with an unparsable token results in a 400 response.""" + with patch("lti_consumer.plugin.views.JWT.unpack") as mock_jwt_unpack_method: + mock_jwt_unpack_method.side_effect = BadSyntax(value="", msg="") + + response = self.client.post( + self.url, + { + "JWT": self.create_tool_jwt_token() + }, + ) + self.assertEqual(response.status_code, 400) + + def test_lti_configuration_does_not_exist(self): + """ + Tests that a call to the start_assessment_endpoint with an "iss" Tool JWT token claim that does not correspond + to an LtiConfiguration instance results in a 404 response. + """ + tool_jwt_token_overrides = {"iss": "iss"} + tool_jwt_token = self.create_tool_jwt_token(**tool_jwt_token_overrides) + + response = self.client.post( + self.url, + { + "JWT": tool_jwt_token + }, + ) + self.assertEqual(response.status_code, 404) + + def test_not_proctoring_consumer(self): + """ + Tests that a call to the start_assessment_endpoint with an "iss" Tool JWT token claim that corresponds to + an LtiConfiguration instance that does not have proctoring enabled results in a 400 response. + """ + # Disable LTI Assessment and Grades Services so we don't set it up unnecessarily. Otherwise, an exception is + # raised because there is no location field set on the LtiConfiguration instance. + with patch("lti_consumer.models.LtiConfiguration.get_lti_advantage_ags_mode") as get_lti_ags_mode_mock: + get_lti_ags_mode_mock.return_value = self.lti_config.LTI_ADVANTAGE_AGS_DISABLED + self.lti_config.lti_1p3_proctoring_enabled = False + self.lti_config.save() + + response = self.client.post( + self.url, + { + "JWT": self.create_tool_jwt_token() + }, + ) + self.assertEqual(response.status_code, 400) + + def test_cache_miss_launch_data(self): + """Tests that a call to the start_assessment_endpoint with no cached launch_data results in a 400 response.""" + TieredCache.set_all_tiers(self.launch_data_key, None) + + response = self.client.post( + self.url, + { + "JWT": self.create_tool_jwt_token() + }, + ) + self.assertEqual(response.status_code, 400) + + @ddt.data(BadJwtSignature, InvalidClaimValue, MalformedJwtToken, MissingRequiredClaim, NoSuitableKeys) + def test_check_and_decode_token_exception_handling(self, exception): + """Tests that a call to the start_assessment_endpoint with an invalid token results in a 400 response.""" + with patch("lti_consumer.lti_1p3.consumer.LtiProctoringConsumer.check_and_decode_token") as mock_method: + mock_method.side_effect = exception() + + response = self.client.post( + self.url, + { + "JWT": self.create_tool_jwt_token() + }, + ) + self.assertEqual(response.status_code, 400) + + def test_cached_end_assessment_return_valid(self): + """ + Tests that a call to the start_assessment_endpoint with a valid end_assessment_return Tool JWT token claim + results in a 200 response and correct data in the cache. + """ + end_assessment_return = True + + tool_jwt_token_overrides = { + "https://purl.imsglobal.org/spec/lti-ap/claim/end_assessment_return": end_assessment_return, + } + tool_jwt_token = self.create_tool_jwt_token(**tool_jwt_token_overrides) + + response = self.client.post( + self.url, + { + "JWT": tool_jwt_token + }, + ) + + self.assertEqual(response.status_code, 200) + + end_assessment_return_cache_key = get_cache_key(**self.common_cache_key_arguments, key="end_assessment_return") + end_assessment_return = get_data_from_cache(end_assessment_return_cache_key) + self.assertEqual(end_assessment_return, int(end_assessment_return)) + + def test_cached_end_assessment_return_invalid(self): + """ + Tests that a call to the start_assessment_endpoint with an invalid end_assessment_return Tool JWT token claim + results in a 200 response and correct data in the cache. + """ + end_assessment_return = "end_assessment_return" + + tool_jwt_token_overrides = { + "https://purl.imsglobal.org/spec/lti-ap/claim/end_assessment_return": end_assessment_return, + } + tool_jwt_token = self.create_tool_jwt_token(**tool_jwt_token_overrides) + + response = self.client.post( + self.url, + { + "JWT": tool_jwt_token + }, + ) + + self.assertEqual(response.status_code, 200) + + end_assessment_return_cache_key = get_cache_key(**self.common_cache_key_arguments, key="end_assessment_return") + end_assessment_return = get_data_from_cache(end_assessment_return_cache_key) + self.assertEqual(end_assessment_return, None) + + @patch("lti_consumer.plugin.views.LTI_1P3_PROCTORING_ASSESSMENT_STARTED.send") + def test_lti_1p3_proctoring_assessment_started_signal(self, mock_assessment_started_signal): + """ + Tests that a successful call to the start_assessment_endpoint emits the LTI_1P3_PROCTORING_ASSESSMENT_STARTED + Django signal. + """ + self.client.post( + self.url, + { + "JWT": self.create_tool_jwt_token() + }, + ) + + self.assertTrue(mock_assessment_started_signal.called) + self.assertEqual(mock_assessment_started_signal.call_count, 1) + + expected_call_args = call( + sender=None, + attempt_number=2, + resource_link={'id': 'resource_link_id'}, + user_id=self.user.id, + ) + self.assertEqual(mock_assessment_started_signal.call_args, expected_call_args) diff --git a/lti_consumer/tests/unit/plugin/test_views.py b/lti_consumer/tests/unit/plugin/test_views.py index e1bce12dfd4c6de283d42f487a68301ec7200e0c..7c6740327ddd0c4433d331f949cd1356a40d607e 100644 --- a/lti_consumer/tests/unit/plugin/test_views.py +++ b/lti_consumer/tests/unit/plugin/test_views.py @@ -8,11 +8,12 @@ import ddt from django.test.testcases import TestCase from django.urls import reverse +from edx_django_utils.cache import TieredCache, get_cache_key from Cryptodome.PublicKey import RSA from jwkest.jwk import RSAKey from opaque_keys.edx.keys import UsageKey -from lti_consumer.data import Lti1p3LaunchData +from lti_consumer.data import Lti1p3LaunchData, Lti1p3ProctoringLaunchData from lti_consumer.models import LtiConfiguration, LtiDlContentItem from lti_consumer.lti_1p3.exceptions import ( MissingRequiredClaim, @@ -456,6 +457,72 @@ class TestLti1p3LaunchGateEndpoint(TestCase): # Check response self.assertEqual(response.status_code, 200) + def test_launch_callback_endpoint_start_proctoring(self): + """ + Ensures that the launch_callback_endpoint works correctly for LtiStartProctoring LTI launch messages. + """ + self.config.lti_1p3_proctoring_enabled = True + self.config.save() + + self.launch_data.message_type = "LtiStartProctoring" + + proctoring_launch_data = Lti1p3ProctoringLaunchData( + attempt_number=1, + start_assessment_url="start_assessment_url", + ) + + self.launch_data.proctoring_launch_data = proctoring_launch_data + + session_data_key = get_cache_key( + app="lti", + key="session_data", + user_id=self.launch_data.user_id, + resource_link_id=self.launch_data.resource_link_id + ) + + TieredCache.set_all_tiers(session_data_key, "session_data") + + params = { + "client_id": self.config.lti_1p3_client_id, + "redirect_uri": "http://tool.example/launch", + "state": "state_test_123", + "nonce": "nonce", + "login_hint": self.launch_data.user_id, + "lti_message_hint": self.launch_data_key, + } + response = self.client.get(self.url, params) + + # Check response + self.assertEqual(response.status_code, 200) + + def test_launch_callback_endpoint_end_assessment(self): + """ + Ensures that the launch_callback_endpoint works correctly for LtiEndAssessment LTI launch messages. + """ + self.config.lti_1p3_proctoring_enabled = True + self.config.save() + + self.launch_data.message_type = "LtiEndAssessment" + + proctoring_launch_data = Lti1p3ProctoringLaunchData( + attempt_number=1, + ) + + self.launch_data.proctoring_launch_data = proctoring_launch_data + + params = { + "client_id": self.config.lti_1p3_client_id, + "redirect_uri": "http://tool.example/launch", + "state": "state_test_123", + "nonce": "nonce", + "login_hint": self.launch_data.user_id, + "lti_message_hint": self.launch_data_key, + } + response = self.client.get(self.url, params) + + # Check response + self.assertEqual(response.status_code, 200) + class TestLti1p3AccessTokenEndpoint(TestCase): """ diff --git a/lti_consumer/tests/unit/plugin/test_views_lti_ags.py b/lti_consumer/tests/unit/plugin/test_views_lti_ags.py index a3dd32845daa67c2d345f1889b96758cbefa42a7..a80a142fb9bb365454396a44973a5f5681312824 100644 --- a/lti_consumer/tests/unit/plugin/test_views_lti_ags.py +++ b/lti_consumer/tests/unit/plugin/test_views_lti_ags.py @@ -64,7 +64,7 @@ class LtiAgsLineItemViewSetTestCase(APITransactionTestCase): self._load_block_patch.return_value = self.xblock self._mock_user = Mock() - compat_mock = patch("lti_consumer.signals.compat") + compat_mock = patch("lti_consumer.signals.signals.compat") self.addCleanup(compat_mock.stop) self._compat_mock = compat_mock.start() self._compat_mock.get_user_from_external_user_id.return_value = self._mock_user diff --git a/lti_consumer/tests/unit/plugin/test_views_lti_deep_linking.py b/lti_consumer/tests/unit/plugin/test_views_lti_deep_linking.py index 6f1c5eeb956f8efad7f979c86293ce75e8ee564d..5d29d12dd0529d5ffdc4865657d71dcf2e07d109 100644 --- a/lti_consumer/tests/unit/plugin/test_views_lti_deep_linking.py +++ b/lti_consumer/tests/unit/plugin/test_views_lti_deep_linking.py @@ -96,7 +96,7 @@ class LtiDeepLinkingResponseEndpointTestCase(LtiDeepLinkingTestCase): super().setUp() # Patch method that calls platform core to ask for user permissions - compat_mock = patch("lti_consumer.signals.compat") + compat_mock = patch("lti_consumer.signals.signals.compat") self.addCleanup(compat_mock.stop) self._compat_mock = compat_mock.start() self._compat_mock.user_has_studio_write_access.return_value = True diff --git a/lti_consumer/tests/unit/test_api.py b/lti_consumer/tests/unit/test_api.py index 446e07743ec9b09728a9dacfdc27f033662b71d8..19ff9f2f7292e48b9438cde263c77486e2195675 100644 --- a/lti_consumer/tests/unit/test_api.py +++ b/lti_consumer/tests/unit/test_api.py @@ -13,13 +13,14 @@ from lti_consumer.api import ( _get_config_by_config_id, _get_or_create_local_lti_config, config_id_for_block, + get_end_assessment_return, get_lti_1p3_content_url, get_deep_linking_data, get_lti_1p3_launch_info, get_lti_1p3_launch_start_url, validate_lti_1p3_launch_data, ) -from lti_consumer.data import Lti1p3LaunchData +from lti_consumer.data import Lti1p3LaunchData, Lti1p3ProctoringLaunchData from lti_consumer.lti_xblock import LtiConsumerXBlock from lti_consumer.models import LtiConfiguration, LtiDlContentItem from lti_consumer.tests.test_utils import make_xblock @@ -93,6 +94,8 @@ class TestConfigIdForBlock(TestCase): creation forks on store type. """ def setUp(self): + super().setUp() + xblock_attributes = { 'lti_version': LtiConfiguration.LTI_1P1, } @@ -236,6 +239,7 @@ class TestGetOrCreateLocalLtiConfiguration(TestCase): self.assertEqual(lti_config.external_id, None) +@ddt.ddt class TestValidateLti1p3LaunchData(TestCase): """ Unit tests for validate_lti_1p3_launch_data API method. @@ -306,11 +310,11 @@ class TestValidateLti1p3LaunchData(TestCase): self.assertEqual(is_valid, False) self._assert_required_context_id_message(validation_messages) - def test_invalid_user_role(self): + @ddt.data("cat", "") + def test_invalid_user_role(self, user_role): """ Ensure that instances of Lti1p3LaunchData are instantiated with a user_role that is in the LTI_1P3_ROLE_MAP. """ - user_role = "cat" launch_data = Lti1p3LaunchData( user_id="1", user_role=user_role, @@ -326,6 +330,22 @@ class TestValidateLti1p3LaunchData(TestCase): [f"The user_role attribute {user_role} is not a valid user_role."] ) + def test_none_user_role(self): + """ + Ensure that instances of Lti1p3LaunchData can be instantiated with a value of None for user_role. + """ + launch_data = Lti1p3LaunchData( + user_id="1", + user_role=None, + config_id=_test_config_id, + resource_link_id="resource_link_id", + ) + + is_valid, validation_messages = validate_lti_1p3_launch_data(launch_data) + + self.assertEqual(is_valid, True) + self.assertEqual(validation_messages, []) + def test_invalid_context_type(self): """ Ensure that instances of Lti1p3LaunchData are instantiated with a context_type that is one of group, @@ -350,6 +370,51 @@ class TestValidateLti1p3LaunchData(TestCase): [f"The context_type attribute {context_type} in the launch data is not a valid context_type."] ) + @ddt.data("LtiStartProctoring", "LtiEndAssessment") + def test_required_proctoring_launch_data_for_proctoring_message_type(self, message_type): + launch_data = Lti1p3LaunchData( + user_id="1", + user_role="student", + config_id=_test_config_id, + resource_link_id="resource_link_id", + message_type=message_type + ) + + is_valid, validation_messages = validate_lti_1p3_launch_data(launch_data) + + self.assertEqual(is_valid, False) + self.assertEqual( + validation_messages, + [ + "The proctoring_launch_data attribute is required if the message_type attribute is " + "\"LtiStartProctoring\" or \"LtiEndAssessment\"." + ] + ) + + @ddt.data(None, "") + def test_required_start_assessment_url_for_start_proctoring_message_type(self, start_assessment_url): + proctoring_launch_data = Lti1p3ProctoringLaunchData(attempt_number=1, start_assessment_url=start_assessment_url) + + launch_data = Lti1p3LaunchData( + user_id="1", + user_role="student", + config_id=_test_config_id, + resource_link_id="resource_link_id", + message_type="LtiStartProctoring", + proctoring_launch_data=proctoring_launch_data, + ) + + is_valid, validation_messages = validate_lti_1p3_launch_data(launch_data) + + self.assertEqual(is_valid, False) + self.assertEqual( + validation_messages, + [ + "The proctoring_start_assessment_url attribute is required if the message_type attribute is" + " \"LtiStartProctoring\"." + ] + ) + class TestGetLti1p3LaunchInfo(TestCase): """ @@ -648,3 +713,26 @@ class TestGetLtiDlContentItemData(TestCase): with self.assertRaises(Exception): get_deep_linking_data(content_item.id, self.lti_config.config_id) + + +class TestGetEndAssessmentReturn(TestCase): + """ + Unit tests for get_end_assessment_return API method. + """ + def setUp(self): + # Patch internal method to avoid calls to modulestore + super().setUp() + patcher = patch( + 'lti_consumer.models.LtiConfiguration.get_lti_consumer', + ) + self.addCleanup(patcher.stop) + + @patch('lti_consumer.api.get_data_from_cache') + def test_get_end_assessment_return(self, mock_get_data_from_cache): + """Ensures get_end_assessment_return returns whatever is in the cache.""" + + get_data_from_cache_return_value = "end_assessment_return" + + mock_get_data_from_cache.return_value = get_data_from_cache_return_value + + self.assertEqual(get_end_assessment_return("user_id", "resource_link_id"), get_data_from_cache_return_value) diff --git a/lti_consumer/tests/unit/test_lti_xblock.py b/lti_consumer/tests/unit/test_lti_xblock.py index adbf3d2e533caa539760682fc942e094e8599e42..4824c8b29cb8541a3ad1dc05dd716d385c90f52e 100644 --- a/lti_consumer/tests/unit/test_lti_xblock.py +++ b/lti_consumer/tests/unit/test_lti_xblock.py @@ -257,11 +257,11 @@ class TestProperties(TestLtiConsumerXBlock): """ fake_user = Mock() fake_user.opt_attrs = { - 'edx-platform.anonymous_user_id': FAKE_USER_ID + 'edx-platform.user_id': FAKE_USER_ID } self.xblock.runtime.service(self, 'user').get_current_user = Mock(return_value=fake_user) - self.assertEqual(self.xblock.user_id, FAKE_USER_ID) + self.assertEqual(self.xblock.lms_user_id, FAKE_USER_ID) def test_user_id_none(self): """ @@ -275,7 +275,7 @@ class TestProperties(TestLtiConsumerXBlock): self.xblock.runtime.service(self, 'user').get_current_user = Mock(return_value=fake_user) with self.assertRaises(LtiError): - __ = self.xblock.user_id + __ = self.xblock.lms_user_id def test_external_user_id(self): """ @@ -304,7 +304,7 @@ class TestProperties(TestLtiConsumerXBlock): @patch('lti_consumer.lti_xblock.LtiConsumerXBlock.context_id') @patch('lti_consumer.lti_xblock.LtiConsumerXBlock.resource_link_id') - @patch('lti_consumer.lti_xblock.LtiConsumerXBlock.user_id', PropertyMock(return_value=FAKE_USER_ID)) + @patch('lti_consumer.lti_xblock.LtiConsumerXBlock.anonymous_user_id', PropertyMock(return_value=FAKE_USER_ID)) def test_lis_result_sourcedid(self, mock_resource_link_id, mock_context_id): """ Test `lis_result_sourcedid` returns appropriate string @@ -784,7 +784,7 @@ class TestLtiLaunchHandler(TestLtiConsumerXBlock): self.xblock.runtime.service(self, 'user').get_current_user = Mock(return_value=fake_user) @patch('lti_consumer.lti_xblock.LtiConsumerXBlock.course') - @patch('lti_consumer.lti_xblock.LtiConsumerXBlock.user_id', PropertyMock(return_value=FAKE_USER_ID)) + @patch('lti_consumer.lti_xblock.LtiConsumerXBlock.anonymous_user_id', PropertyMock(return_value=FAKE_USER_ID)) def test_generate_launch_request_called(self, mock_course): """ Test LtiConsumer.generate_launch_request is called and a 200 HTML response is returned @@ -832,7 +832,7 @@ class TestLtiLaunchHandler(TestLtiConsumerXBlock): self.assertIn("There was an error while launching the LTI tool.", response_body) @patch('lti_consumer.lti_xblock.LtiConsumerXBlock.course') - @patch('lti_consumer.lti_xblock.LtiConsumerXBlock.user_id', PropertyMock(return_value=FAKE_USER_ID)) + @patch('lti_consumer.lti_xblock.LtiConsumerXBlock.anonymous_user_id', PropertyMock(return_value=FAKE_USER_ID)) def test_publish_tracking_event(self, mock_course): """ Test a tracking event is emitted when generating a launch request @@ -1472,6 +1472,7 @@ class TestLtiConsumer1p3XBlock(TestCase): # Mock out the user role and external_user_id properties. fake_user = Mock() fake_user.opt_attrs = { + 'edx-platform.user_id': 1, 'edx-platform.user_role': 'instructor', 'edx-platform.is_authenticated': True, } @@ -1485,10 +1486,11 @@ class TestLtiConsumer1p3XBlock(TestCase): course_key = str(self.xblock.location.course_key) # pylint: disable=no-member expected_launch_data = Lti1p3LaunchData( - user_id="external_user_id", + user_id=1, user_role="instructor", config_id=config_id_for_block(self.xblock), resource_link_id=str(self.xblock.location), # pylint: disable=no-member + external_user_id="external_user_id", launch_presentation_document_target="iframe", message_type="LtiResourceLinkRequest", context_id=course_key, diff --git a/lti_consumer/tests/unit/test_models.py b/lti_consumer/tests/unit/test_models.py index e6d1298e3aad65eb5588e8423e7843022cd2595b..f9b189c9eee6e14b298e0c23c5d2627604df7b77 100644 --- a/lti_consumer/tests/unit/test_models.py +++ b/lti_consumer/tests/unit/test_models.py @@ -15,13 +15,8 @@ from jwkest.jwk import RSAKey from opaque_keys.edx.locator import CourseLocator from lti_consumer.lti_xblock import LtiConsumerXBlock -from lti_consumer.models import ( - CourseAllowPIISharingInLTIFlag, - LtiAgsLineItem, - LtiAgsScore, - LtiConfiguration, - LtiDlContentItem, -) +from lti_consumer.models import (CourseAllowPIISharingInLTIFlag, LtiAgsLineItem, LtiAgsScore, LtiConfiguration, + LtiDlContentItem) from lti_consumer.tests.test_utils import make_xblock @@ -375,6 +370,13 @@ class TestLtiConfigurationModel(TestCase): self.assertRaises(ValidationError): self.lti_1p3_config_db.clean() + self.lti_1p3_config.lti_1p3_proctoring_enabled = True + + for config_store in [self.lti_1p3_config.CONFIG_ON_XBLOCK, self.lti_1p3_config.CONFIG_EXTERNAL]: + self.lti_1p3_config.config_store = config_store + with self.assertRaises(ValidationError): + self.lti_1p3_config.clean() + class TestLtiAgsLineItemModel(TestCase): """ @@ -411,7 +413,7 @@ class TestLtiAgsScoreModel(TestCase): super().setUp() # patch things related to LtiAgsScore post_save signal receiver - compat_mock = patch("lti_consumer.signals.compat") + compat_mock = patch("lti_consumer.signals.signals.compat") self.addCleanup(compat_mock.stop) self._compat_mock = compat_mock.start() self._compat_mock.load_block_as_user.return_value = make_xblock( diff --git a/lti_consumer/utils.py b/lti_consumer/utils.py index 2514c627a7b2d2e1e52a9e456a58d24decc2648d..9413dcd8bd69d55b2e09fa6de6420b44b8bb3e65 100644 --- a/lti_consumer/utils.py +++ b/lti_consumer/utils.py @@ -10,6 +10,7 @@ from edx_django_utils.cache import get_cache_key, TieredCache from lti_consumer.plugin.compat import get_external_config_waffle_flag, get_database_config_waffle_flag from lti_consumer.lti_1p3.constants import LTI_1P3_CONTEXT_TYPE +from lti_consumer.lti_1p3.exceptions import InvalidClaimValue, MissingRequiredClaim log = logging.getLogger(__name__) @@ -271,3 +272,20 @@ def get_data_from_cache(cache_key): return cached_data.value return None + + +def check_token_claim(token, claim_key, expected_value=None, invalid_claim_error_msg=None): + """ + Checks that the claim with key claim_key appears in the token. Raises a MissingRequiredClaim exception if it does + not. If the optional arguments expected_value and invalid_claim_error_msg are provided, then checks that the claim + in the token with the key claim_key matches the expected_value. Raises an InvalidClaimValue exception with the + invalid_claim_error_msg as the message if not. If the invalid_claim_error_msg argument is provided, then a generic + message is used. + """ + claim_value = token.get(claim_key) + + if claim_value is None: + raise MissingRequiredClaim(f"Token is missing required {claim_key} claim.") + if expected_value and claim_value != expected_value: + msg = invalid_claim_error_msg if invalid_claim_error_msg else f"The claim {claim_key} value is invalid." + raise InvalidClaimValue(msg)