From 31ac0db33681bea1eaf9aed198da64499759cf76 Mon Sep 17 00:00:00 2001 From: michaelroytman Date: Tue, 16 Aug 2022 11:44:40 -0400 Subject: [PATCH] feat: add support for LTI launch refactor in xblock-lti-consumer This commit updates the sample LTI proctoring integration to use the new LTI launch refactor in xblock-lti-consumer (https://github.com/openedx/xblock-lti-consumer/pull/288) and the Proctoring Services implementation (https://github.com/openedx/xblock-lti-consumer/pull/297). As a reminder, this application currently serves as a mock implementation of the proctoring flow to demonstrate how we might use the xblock-lti-consumer library for proctoring. It is not intended to be deployed as-is or to serve as an actual proctoring flow implementation. Here is a digest of important changes. * The launch_proctoring, start_assessment, public_keyset, and access_token views are removed. * The start_proctoring and end_assessment views are added. * A signal handler for the LTI_1P3_PROCTORING_ASSESSMENT_STARTED is added. * The lti AppConfig is modified to support the above signal. --- edx_exams/apps/lti/api.py | 59 ---- edx_exams/apps/lti/apps.py | 6 + edx_exams/apps/lti/models.py | 7 - edx_exams/apps/lti/signals/__init__.py | 0 edx_exams/apps/lti/signals/handlers.py | 13 + edx_exams/apps/lti/urls.py | 7 +- edx_exams/apps/lti/utils.py | 11 + edx_exams/apps/lti/views.py | 371 ++++--------------------- edx_exams/settings/base.py | 7 +- edx_exams/settings/local.py | 12 - edx_exams/urls.py | 1 + requirements/base.in | 5 +- requirements/base.txt | 4 +- requirements/dev.txt | 13 +- requirements/doc.txt | 10 +- requirements/production.txt | 6 +- requirements/quality.txt | 10 +- requirements/test.txt | 7 +- requirements/validation.txt | 13 +- 19 files changed, 120 insertions(+), 442 deletions(-) delete mode 100644 edx_exams/apps/lti/api.py delete mode 100644 edx_exams/apps/lti/models.py create mode 100644 edx_exams/apps/lti/signals/__init__.py create mode 100644 edx_exams/apps/lti/signals/handlers.py create mode 100644 edx_exams/apps/lti/utils.py diff --git a/edx_exams/apps/lti/api.py b/edx_exams/apps/lti/api.py deleted file mode 100644 index 57cb8a95..00000000 --- a/edx_exams/apps/lti/api.py +++ /dev/null @@ -1,59 +0,0 @@ -""" -LTI API -""" - -from django.conf import settings -from lti_consumer.lti_1p3.consumer import LtiAdvantageConsumer -from lti_consumer.models import LtiConfiguration - - -def get_lti1p3_consumer(): - """ - Returns an configured instance of LTI consumer. - """ - # TODO: We need a better look up than a hard-coded primary key. This will be - # informed by how we decide to store LTI configuration. - lti_config = LtiConfiguration.objects.get(pk=1) - return LtiAdvantageConsumer( - # configuration provided by the LTI tool - lti_oidc_url=lti_config.lti_1p3_oidc_url, - lti_launch_url=lti_config.lti_1p3_launch_url, - # platform and deployment configuration provided by the platform - iss=settings.LMS_ROOT_URL, - client_id=lti_config.lti_1p3_client_id, - deployment_id="1", - # platform asymmetric public key configuration - rsa_key=lti_config.lti_1p3_private_key, - rsa_key_id=lti_config.lti_1p3_private_key_id, - # tool asymmetric public key configuration - tool_key=lti_config.lti_1p3_tool_public_key, - tool_keyset_url=lti_config.lti_1p3_tool_keyset_url - ) - - -def get_lti_preflight_url(lti_message_hint): - lti_consumer = get_lti1p3_consumer() - context = lti_consumer.prepare_preflight_url(lti_hint=lti_message_hint) - return context - - -def get_resource_link(): # pylint: disable=missing-function-docstring - # TODO: The resource link should uniquely represent the assessment in the Assessment Platform. - # TODO: We SHOULD provide a value for the title attribute. - # TODO: It's RECOMMENDED to provide a value for the description attribute. - # TODO: The xblock-lti-consumer library does not currently support setting these attributes. - return 'edx:proctored_exam:12345' - - -def get_optional_user_identity_claims(): - # These claims are optional. - # TODO: This will need to have additional consideration for PII. - return { - 'given_name': 'Michael', - 'family_name': 'Roytman', - 'name': 'Michael Roytman', - 'email': 'michaelroytman@example.com', - 'email_verified': True, - 'picture': 'example.com/michaelroytman.jpg', - 'locale': 'en_US' - } diff --git a/edx_exams/apps/lti/apps.py b/edx_exams/apps/lti/apps.py index 8343ada2..9f58a4bd 100644 --- a/edx_exams/apps/lti/apps.py +++ b/edx_exams/apps/lti/apps.py @@ -6,5 +6,11 @@ class LtiConfig(AppConfig): + """ + AppConfig for lti Djangoapp. + """ default_auto_field = 'django.db.models.BigAutoField' name = 'edx_exams.apps.lti' + + def ready(self): + from edx_exams.apps.lti.signals import handlers # pylint: disable=import-outside-toplevel,unused-import diff --git a/edx_exams/apps/lti/models.py b/edx_exams/apps/lti/models.py deleted file mode 100644 index ed94cd2e..00000000 --- a/edx_exams/apps/lti/models.py +++ /dev/null @@ -1,7 +0,0 @@ -""" -LTI models -""" - -from django.db import models # pylint: disable=unused-import - -# Create your models here. diff --git a/edx_exams/apps/lti/signals/__init__.py b/edx_exams/apps/lti/signals/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/edx_exams/apps/lti/signals/handlers.py b/edx_exams/apps/lti/signals/handlers.py new file mode 100644 index 00000000..5ab04c3b --- /dev/null +++ b/edx_exams/apps/lti/signals/handlers.py @@ -0,0 +1,13 @@ +""" +edX Exams Signal Handlers +""" +from django.dispatch import receiver +from lti_consumer.signals.signals import LTI_1P3_PROCTORING_ASSESSMENT_STARTED + + +@receiver(LTI_1P3_PROCTORING_ASSESSMENT_STARTED) +def assessment_started(sender, **kwargs): # pylint: disable=unused-argument + """ + Signal handler for the lti_consumer LTI_1P3_PROCTORING_ASSESSMENT_STARTED signal. + """ + print(f"LTI_1P3_PROCTORING_ASSESSMENT_STARTED signal received with kwargs: {kwargs}") diff --git a/edx_exams/apps/lti/urls.py b/edx_exams/apps/lti/urls.py index 07bbbd70..c4a9a096 100644 --- a/edx_exams/apps/lti/urls.py +++ b/edx_exams/apps/lti/urls.py @@ -8,9 +8,6 @@ app_name = 'lti' urlpatterns = [ - path('start_proctoring', views.start_proctoring), - path('authenticate', views.authenticate), - path('start_assessment', views.start_assessment, name='start-assessment'), - path('end_assessment', views.end_assessment), - path('public_keyset', views.public_keyset), + path('end_assessment/', views.end_assessment), + path('start_proctoring/', views.start_proctoring, name='start_proctoring'), ] diff --git a/edx_exams/apps/lti/utils.py b/edx_exams/apps/lti/utils.py new file mode 100644 index 00000000..3120eeac --- /dev/null +++ b/edx_exams/apps/lti/utils.py @@ -0,0 +1,11 @@ +""" +LTI Utility Functions +""" +from django.conf import settings + + +def get_lti_root(): + if hasattr(settings, 'LTI_ROOT_URL_OVERRIDE'): + return settings.LTI_ROOT_URL_OVERRIDE + else: + return settings.ROOT_URL diff --git a/edx_exams/apps/lti/views.py b/edx_exams/apps/lti/views.py index a64525eb..e536ab90 100644 --- a/edx_exams/apps/lti/views.py +++ b/edx_exams/apps/lti/views.py @@ -4,340 +4,89 @@ from urllib.parse import urljoin -from django.conf import settings from django.http import JsonResponse -from django.shortcuts import redirect, render +from django.shortcuts import redirect from django.urls import reverse -from django.utils.crypto import get_random_string -from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods -from lti_consumer.lti_1p3.exceptions import ( - BadJwtSignature, - InvalidClaimValue, - MalformedJwtToken, - MissingRequiredClaim, - NoSuitableKeys, - TokenSignatureExpired, - UnauthorizedToken -) +from lti_consumer.api import get_end_assessment_return, get_lti_1p3_launch_start_url +from lti_consumer.data import Lti1p3LaunchData, Lti1p3ProctoringLaunchData +from lti_consumer.models import LtiConfiguration -from edx_exams.apps.lti.api import ( - get_lti1p3_consumer, - get_lti_preflight_url, - get_optional_user_identity_claims, - get_resource_link -) +from edx_exams.apps.core.models import ExamAttempt +from edx_exams.apps.lti.utils import get_lti_root -def start_proctoring(request): +@require_http_methods(['GET']) +def start_proctoring(request, attempt_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. - - "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 learner'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 learner's browser to the - Assessment Platform's "OIDC Authorization end-point", which starts the OpenID Connect authentication flow, - implemented by the authenticate 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. - """ - # TODO: Here we'd do all the start of proctoring things - - lti_message_hint = 'LtiStartProctoring' - preflight_url = get_lti_preflight_url(lti_message_hint) - - return redirect(preflight_url) - - -def end_assessment(request): + LTI Start Proctoring """ - 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. + # TODO: Here we'd do all the start of proctoring things. - "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 + exam_attempt = ExamAttempt.objects.get(pk=attempt_id) + exam = exam_attempt.exam + lti_config_id = exam.provider.lti_configuration_id + lti_config = LtiConfiguration.objects.get(id=lti_config_id) - This view redirects the learner'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 learner's browser to the - Assessment Platform's "OIDC Authorization end-point", which starts the OpenID Connect authentication flow, - implemented by the authenticate view. - - The Assessment Platform needs to know the Protoring 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. - """ - # TODO: Here we'd do all the end of assessment things. - - # TODO: "If the assessment needs to close due to an error NOT handled by the Assessment Platform - # that error MUST be passed along using the LtiEndAssessment message and the errormsg and errorlog claims. - # The message utilizes the OpenID connect workflow prior to sending the message." - # See 4.4 End Assessment Message. - # TODO: I'm unsure whether the above requires that we send this message with the errormsg and errorlog claims - # if end_assessment_return was not specified in the request to lti_start_assessment. - - # We remove the end_assessment_return session data, since the learner has completed the proctoring flow. - end_assessment_return = request.session.pop('end_assessment_return') - if end_assessment_return: - lti_message_hint = 'LtiEndAssessment' - preflight_url = get_lti_preflight_url(lti_message_hint) - - return redirect(preflight_url) - - return JsonResponse() # pylint: disable=no-value-for-parameter - - -def public_keyset(request): - """ - This is the view that serves as the Assessment Platform's LTI Public Keyset. - - The Proctoring Tool needs to know the Assessment Platform's public keyset URL. - This information is exchanged out-of-band during the registration phase. - """ - return JsonResponse( - get_lti1p3_consumer().get_public_keyset(), + proctoring_start_assessment_url = urljoin( + get_lti_root(), + reverse('lti_consumer:lti_consumer.start_proctoring_assessment_endpoint') ) - -# 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 is sent with the request to the Assessment Platform that contains the anti-CSRF -# token. When the learner's browser makes a request to the start_proctoring 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 learner'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 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 name "csrfmiddlewaretoken", as it's not part of the Proctoring Services Specification. -# This is why we use the csrf_exempt decorator. It exempts this view from CSRF protection for POST requests. -# TODO: From the Django documentation, "This should not be done for POST forms that target external URLs, -# since that would cause the CSRF token to be leaked, leading to a vulnerability." -# What does this mean for this endpoint? Is it unsafe to include the CSRF token in the JWT? -@csrf_exempt -# Per the Proctoring Services specification, the Proctoring Tool can direct the learner's browser to make either a GET -# or POST request to this endpoint. -@require_http_methods(['GET', 'POST']) -def authenticate(request): - """ - This view is the first step of the OpenID Connect authentication flow. This view may be called the "OIDC - Authorization end-point." - - The Proctoring Tool responds to the browser's request to Tool's OIDC login initiation URL by directing the learner's - browser. to make a request against this endpoint. This starts the authentication flow. - - The Assessment Platform directs the learner's browser to make a request to the Proctoring Tool, acting as the - "authentication response". This request must be made to the URL specified by the "redirect_uri" claim in the - request. - - This signifies the second leg of the LTI launch workflow - otherwise know as the LTI launch or "OpenID Connect - authorization flow". - - It receives, as a request, the response to the Assessment Platform's request to the Proctoring Tool's OIDC login - initiation URL and creates a LTI launch request to the Proctoring Tool. - - The Assessment Platform needs to know the Proctoring Tool's OIDC login initiation URL. - This information is exchanged out-of-band during the registration phase. - """ - lti_consumer = get_lti1p3_consumer() - - # "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 somehow on the server. - # Note that this generates an anti-CSRF token PER USER SESSION, not per request. Per request is more secure, but - # I elected to do is on the session for the POC, because it is simpler. - session_data = request.session.get('lti_proctoring_session_data') - if session_data is None: - session_data = get_random_string(32) - request.session['lti_proctoring_session_data'] = session_data - - start_assessment_url = urljoin(settings.ROOT_URL, reverse('lti:start-assessment')) - - # TODO: The resource link should uniquely represent the assessment in the Assessment Platform. - # TODO: We SHOULD provide a value for the title attribute. - # TODO: It's RECOMMENDED to provide a value for the description attribute. - # TODO: The xblock-lti-consumer library does not currently support setting these attributes. - resource_link = get_resource_link() - - lti_consumer.enable_proctoring( - # NOTE TO SELF: attempt_number is an auto-incrementing integer from 1 per learner, per assessment. - 29, # attempt_number - session_data, - resource_link, - start_assessment_url=start_assessment_url, - ) - - # This is necessary for testing with the IMS tool, since the user will be an AnonymousUser without an id. - # TODO: Replace this with the authenticated user's id. - # TODO: Remove this once testing is complete. - user_id = 1 if request.user.id is None else request.user.id - - # Required user claim data - lti_consumer.set_user_data( - # user_id=request.user.id, - user_id=user_id, - # Pass Django user role to library - role='student' + proctoring_launch_data = Lti1p3ProctoringLaunchData( + attempt_number=exam_attempt.attempt_number, + start_assessment_url=proctoring_start_assessment_url, ) - # These claims are optional. - # 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 + launch_data = Lti1p3LaunchData( + user_id=request.user.id, + user_role=None, + config_id=lti_config.config_id, + resource_link_id=exam.resource_id, + external_user_id=str(request.user.anonymous_user_id), + message_type="LtiStartProctoring", + proctoring_launch_data=proctoring_launch_data, ) - context = {} + return redirect(get_lti_1p3_launch_start_url(launch_data)) - # 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 - preflight_response_method = request.method - preflight_response = request.GET if preflight_response_method == 'GET' else request.POST - - context.update({ - 'preflight_response': preflight_response.dict(), - 'launch_request': lti_consumer.generate_launch_request( - preflight_response, - resource_link, - ) - }) - # This template renders an auto-submitting form, which makes a POST request to the redirect_uri, specified in the - # Tool's response to the request the Assessment Platform made to the Tool's OIDC login initiation URL. - return render(request, 'lti/lti_launch_request_form.html', context) - - -# We do not want Django's CSRF protection enabled for POSTs made by external services to this endpoint. -# Please see the comment for the authenticate view for a more detailed justification. -@csrf_exempt -# Per the Proctoring Services specification, the Tool can direct the learner's browser to make only a POST request to -# this endpoint. -@require_http_methods(['POST']) -def start_assessment(request): +def end_assessment(request, attempt_id): """ - 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 send 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's; 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. It is included - as the required claim "start_assessment_url" in the ID Token. + LTI End Assessment """ - # TODO: Here we'd do all the start of assessment things. - - lti_consumer = get_lti1p3_consumer() - - # Let's grab the session_data stored in the learner's session. This will need to be compared - # against the session_data claim in the proctoring token included by the Tool in the request. - # TODO: The use of the user session to store the CSRF token on the server - # does not work without changing the value of the SESSION_COOKIE_SAMESITE Django setting to - # 'None', which allows the browser to send the session cookie as a third-party cookie. What - # security considerations are there? - session_data = request.session.get('lti_proctoring_session_data') - - start_assessment_url = urljoin(settings.ROOT_URL, reverse('lti:start-assessment')) - - # TODO: The resource link should uniquely represent the assessment in the Assessment Platform. - # TODO: We SHOULD provide a value for the title attribute. - # TODO: It's RECOMMENDED to provide a value for the description attribute. - # TODO: The xblock-lti-consumer library does not currently support setting these attributes. - resource_link = get_resource_link() - - lti_consumer.enable_proctoring( - # NOTE TO SELF: attempt_number is an auto-incrementing integer from 1 per learner, per assessment. - 29, # attempt_number, - session_data, - resource_link, - start_assessment_url=start_assessment_url, - ) - - # This is necessary for testing with the IMS tool, since the user will be an AnonymousUser without an id. - # TODO: Replace this with the authenticated user's id. - # TODO: Remove this once testing is complete. - user_id = 1 if request.user.id is None else request.user.id - - # Required user claim data - lti_consumer.set_user_data( - user_id=user_id, - # Pass Django user role to library - # TODO: A role of 'student' is not correctly mapped to the corresponding LTI claim for the Proctoring - # Specification. - role='student' - ) - - # 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 - ) + # TODO: Here we'd do all the end of assessment things. + exam_attempt = ExamAttempt.objects.get(pk=attempt_id) + exam = exam_attempt.exam + resource_link_id = exam.resource_id + end_assessment_return = get_end_assessment_return(request.user.anonymous_user_id, resource_link_id) + + # 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 end_assessment_return: + lti_config_id = exam.provider.lti_configuration_id + lti_config = LtiConfiguration.objects.get(id=lti_config_id) - try: - lti_response = lti_consumer.check_and_decode_proctoring_token(request.POST.get('JWT')) - except (MalformedJwtToken, TokenSignatureExpired): - return JsonResponse( - {'error': 'invalid_grant'}, - status=400, - ) - except NoSuitableKeys: - return JsonResponse( - {'error': 'invalid_client'}, - status=400 - ) - except (BadJwtSignature, InvalidClaimValue, MissingRequiredClaim): - # TODO: I'm not sure whether this is the right OIDC error ID. - return JsonResponse( - {'error': 'invalid_token'}, - status=400 + proctoring_launch_data = Lti1p3ProctoringLaunchData( + attempt_number=exam_attempt.attempt_number, ) - except UnauthorizedToken: - return JsonResponse( - {'error': 'invalid_token'}, - status=403 + + launch_data = Lti1p3LaunchData( + user_id=request.user.id, + user_role=None, + config_id=lti_config.config_id, + resource_link_id=resource_link_id, + external_user_id=str(request.user.anonymous_user_id), + message_type="LtiEndAssessment", + proctoring_launch_data=proctoring_launch_data, ) - response = JsonResponse(lti_response) + # TODO: "If the assessment needs to close due to an error NOT handled by the Assessment Platform that error MUST + # be passed along using the LtiEndAssessment message and the errormsg and errorlog claims. The message + # utilizes the OpenID connect workflow prior to sending the message." See 4.4 End Assessment Message. + preflight_url = get_lti_1p3_launch_start_url(launch_data) - # 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 learner's - # proctored exam. - # See 4.4 End Assessment Message. - end_assessment_return = lti_response.get('end_assessment_return') - if end_assessment_return: - # TODO: We have to store this value somehow. We could store it in a cookie or in the user's session, - # for example. - request.session['end_assessment_return'] = True + return redirect(preflight_url) - return response + return JsonResponse({}) diff --git a/edx_exams/settings/base.py b/edx_exams/settings/base.py index caa3aa43..320c6124 100644 --- a/edx_exams/settings/base.py +++ b/edx_exams/settings/base.py @@ -19,8 +19,6 @@ def root(*path_fragments): # SECURITY WARNING: don't run with debug turned on in production! DEBUG = False -ALLOWED_HOSTS = [] - # Application definition INSTALLED_APPS = ( @@ -88,6 +86,11 @@ def root(*path_fragments): ROOT_URLCONF = 'edx_exams.urls' +# This is the usage_id pattern. It's needed by the xblock-lti-consumer library, because some Django view URLs still +# use the usage_ids of components. For now, we include these here to support the use of the library. When the library +# is fully decoupled this can be removed. This setting is taken from edx-platform. +USAGE_ID_PATTERN = r'(?P(?:i4x://?[^/]+/[^/]+/[^/]+/[^@]+(?:@[^/]+)?)|(?:[^/]+))' + # Python dotted path to the WSGI application used by Django's runserver. WSGI_APPLICATION = 'edx_exams.wsgi.application' diff --git a/edx_exams/settings/local.py b/edx_exams/settings/local.py index d097e780..092de5c2 100644 --- a/edx_exams/settings/local.py +++ b/edx_exams/settings/local.py @@ -97,15 +97,3 @@ # Lastly, see if the developer has any local overrides. if os.path.isfile(join(dirname(abspath(__file__)), 'private.py')): from .private import * # pylint: disable=import-error - -# Use the below URL when using port forwarding for testing with the IMS testing tool. -ROOT_URL = 'localhost:18740' - -LMS_BASE = 'localhost:18000' -LMS_ROOT_URL = f'http://{LMS_BASE}' - -# TODO: What security considerations are there for settings these settings to these values? Should we store the -# anti-CSRF token on the attempt model instead and forego the need for the session cookie? -# TODO: These settings should be moved to base.py if we feel comfortable with them. -SESSION_COOKIE_SAMESITE = 'None' -SESSION_COOKIE_SECURE = True diff --git a/edx_exams/urls.py b/edx_exams/urls.py index 48b2c876..40e46b7a 100644 --- a/edx_exams/urls.py +++ b/edx_exams/urls.py @@ -36,6 +36,7 @@ path('', include('csrf.urls')), # Include csrf urls from edx-drf-extensions path('health/', core_views.Health.as_view(), name='health'), path('lti/', include(lti_urls)), + path('lti/', include('lti_consumer.plugin.urls')), ] if settings.DEBUG and os.environ.get('ENABLE_DJANGO_TOOLBAR', False): # pragma: no cover diff --git a/requirements/base.in b/requirements/base.in index f15dcc53..0ac8f940 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -14,9 +14,6 @@ edx-django-utils edx-django-release-util edx-drf-extensions edx-rest-api-client +lti-consumer-xblock===6.3.0 mysqlclient pytz - -# Libraries to install from GitHub -git+https://github.com/openedx/xblock-lti-consumer.git@6625749fcc80e4e0df6f1deb4392b8a84d33d259#egg=lti-consumer-xblock - diff --git a/requirements/base.txt b/requirements/base.txt index 23433e32..6d2b2cb3 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -8,6 +8,8 @@ appdirs==1.4.4 # via fs asgiref==3.5.2 # via django +attrs==22.1.0 + # via lti-consumer-xblock bleach==5.0.1 # via lti-consumer-xblock certifi==2022.9.24 @@ -126,7 +128,7 @@ jsonfield==3.1.0 # via lti-consumer-xblock lazy==1.5 # via lti-consumer-xblock -lti-consumer-xblock @ git+https://github.com/openedx/xblock-lti-consumer.git@6625749fcc80e4e0df6f1deb4392b8a84d33d259 +lti-consumer-xblock===6.3.0 # via -r requirements/base.in lxml==4.9.1 # via diff --git a/requirements/dev.txt b/requirements/dev.txt index 82ab09c8..a1ad06cf 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -20,6 +20,7 @@ astroid==2.12.12 attrs==22.1.0 # via # -r requirements/validation.txt + # lti-consumer-xblock # pytest bleach==5.0.1 # via @@ -85,7 +86,6 @@ cryptography==38.0.3 # via # -r requirements/validation.txt # pyjwt - # secretstorage # social-auth-core ddt==1.6.0 # via -r requirements/validation.txt @@ -253,11 +253,6 @@ jaraco-classes==3.2.3 # via # -r requirements/validation.txt # keyring -jeepney==0.8.0 - # via - # -r requirements/validation.txt - # keyring - # secretstorage jinja2==3.1.2 # via # -r requirements/validation.txt @@ -280,7 +275,7 @@ lazy-object-proxy==1.8.0 # via # -r requirements/validation.txt # astroid -lti-consumer-xblock @ git+https://github.com/openedx/xblock-lti-consumer.git@6625749fcc80e4e0df6f1deb4392b8a84d33d259 +lti-consumer-xblock===6.3.0 # via -r requirements/validation.txt lxml==4.9.1 # via @@ -518,10 +513,6 @@ ruamel-yaml-clib==0.2.7 # via # -r requirements/validation.txt # ruamel-yaml -secretstorage==3.3.3 - # via - # -r requirements/validation.txt - # keyring semantic-version==2.10.0 # via # -r requirements/validation.txt diff --git a/requirements/doc.txt b/requirements/doc.txt index 37e14651..4f564bcb 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -22,6 +22,7 @@ astroid==2.12.12 attrs==22.1.0 # via # -r requirements/test.txt + # lti-consumer-xblock # pytest babel==2.11.0 # via sphinx @@ -81,7 +82,6 @@ cryptography==38.0.3 # via # -r requirements/test.txt # pyjwt - # secretstorage # social-auth-core ddt==1.6.0 # via -r requirements/test.txt @@ -248,10 +248,6 @@ itypes==1.2.0 # coreapi jaraco-classes==3.2.3 # via keyring -jeepney==0.8.0 - # via - # keyring - # secretstorage jinja2==3.1.2 # via # -r requirements/test.txt @@ -272,7 +268,7 @@ lazy-object-proxy==1.8.0 # via # -r requirements/test.txt # astroid -lti-consumer-xblock @ git+https://github.com/openedx/xblock-lti-consumer.git@6625749fcc80e4e0df6f1deb4392b8a84d33d259 +lti-consumer-xblock===6.3.0 # via -r requirements/test.txt lxml==4.9.1 # via @@ -487,8 +483,6 @@ ruamel-yaml-clib==0.2.7 # via # -r requirements/test.txt # ruamel-yaml -secretstorage==3.3.3 - # via keyring semantic-version==2.10.0 # via # -r requirements/test.txt diff --git a/requirements/production.txt b/requirements/production.txt index 0e70f8d3..d5e1909f 100644 --- a/requirements/production.txt +++ b/requirements/production.txt @@ -12,6 +12,10 @@ asgiref==3.5.2 # via # -r requirements/base.txt # django +attrs==22.1.0 + # via + # -r requirements/base.txt + # lti-consumer-xblock bleach==5.0.1 # via # -r requirements/base.txt @@ -175,7 +179,7 @@ lazy==1.5 # via # -r requirements/base.txt # lti-consumer-xblock -lti-consumer-xblock @ git+https://github.com/openedx/xblock-lti-consumer.git@6625749fcc80e4e0df6f1deb4392b8a84d33d259 +lti-consumer-xblock===6.3.0 # via -r requirements/base.txt lxml==4.9.1 # via diff --git a/requirements/quality.txt b/requirements/quality.txt index 68ccb4d6..ca59114e 100644 --- a/requirements/quality.txt +++ b/requirements/quality.txt @@ -20,6 +20,7 @@ astroid==2.12.12 attrs==22.1.0 # via # -r requirements/test.txt + # lti-consumer-xblock # pytest bleach==5.0.1 # via @@ -75,7 +76,6 @@ cryptography==38.0.3 # via # -r requirements/test.txt # pyjwt - # secretstorage # social-auth-core ddt==1.6.0 # via -r requirements/test.txt @@ -234,10 +234,6 @@ itypes==1.2.0 # coreapi jaraco-classes==3.2.3 # via keyring -jeepney==0.8.0 - # via - # keyring - # secretstorage jinja2==3.1.2 # via # -r requirements/test.txt @@ -257,7 +253,7 @@ lazy-object-proxy==1.8.0 # via # -r requirements/test.txt # astroid -lti-consumer-xblock @ git+https://github.com/openedx/xblock-lti-consumer.git@6625749fcc80e4e0df6f1deb4392b8a84d33d259 +lti-consumer-xblock===6.3.0 # via -r requirements/test.txt lxml==4.9.1 # via @@ -466,8 +462,6 @@ ruamel-yaml-clib==0.2.7 # via # -r requirements/test.txt # ruamel-yaml -secretstorage==3.3.3 - # via keyring semantic-version==2.10.0 # via # -r requirements/test.txt diff --git a/requirements/test.txt b/requirements/test.txt index f1664c6c..08c5511c 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -17,7 +17,10 @@ astroid==2.12.12 # pylint # pylint-celery attrs==22.1.0 - # via pytest + # via + # -r requirements/base.txt + # lti-consumer-xblock + # pytest bleach==5.0.1 # via # -r requirements/base.txt @@ -214,7 +217,7 @@ lazy==1.5 # lti-consumer-xblock lazy-object-proxy==1.8.0 # via astroid -lti-consumer-xblock @ git+https://github.com/openedx/xblock-lti-consumer.git@6625749fcc80e4e0df6f1deb4392b8a84d33d259 +lti-consumer-xblock===6.3.0 # via -r requirements/base.txt lxml==4.9.1 # via diff --git a/requirements/validation.txt b/requirements/validation.txt index 56250ff4..50ab4f3e 100644 --- a/requirements/validation.txt +++ b/requirements/validation.txt @@ -24,6 +24,7 @@ attrs==22.1.0 # via # -r requirements/quality.txt # -r requirements/test.txt + # lti-consumer-xblock # pytest bleach==5.0.1 # via @@ -92,7 +93,6 @@ cryptography==38.0.3 # -r requirements/quality.txt # -r requirements/test.txt # pyjwt - # secretstorage # social-auth-core ddt==1.6.0 # via @@ -301,11 +301,6 @@ jaraco-classes==3.2.3 # via # -r requirements/quality.txt # keyring -jeepney==0.8.0 - # via - # -r requirements/quality.txt - # keyring - # secretstorage jinja2==3.1.2 # via # -r requirements/quality.txt @@ -331,7 +326,7 @@ lazy-object-proxy==1.8.0 # -r requirements/quality.txt # -r requirements/test.txt # astroid -lti-consumer-xblock @ git+https://github.com/openedx/xblock-lti-consumer.git@6625749fcc80e4e0df6f1deb4392b8a84d33d259 +lti-consumer-xblock===6.3.0 # via # -r requirements/quality.txt # -r requirements/test.txt @@ -600,10 +595,6 @@ ruamel-yaml-clib==0.2.7 # -r requirements/quality.txt # -r requirements/test.txt # ruamel-yaml -secretstorage==3.3.3 - # via - # -r requirements/quality.txt - # keyring semantic-version==2.10.0 # via # -r requirements/quality.txt