diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index 188a73f2ef..66d34ad6ea 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -2724,6 +2724,7 @@ class TestIndexViewWithVerticalPositions(ModuleStoreTestCase): self._assert_correct_position(resp, expected_position) +@ddt.ddt class TestRenderXBlock(RenderXBlockTestMixin, ModuleStoreTestCase, CompletionWaffleTestMixin): """ Tests for the courseware.render_xblock endpoint. @@ -2926,6 +2927,76 @@ class TestRenderXBlock(RenderXBlockTestMixin, ModuleStoreTestCase, CompletionWaf banner_text = get_expiration_banner_text(self.user, self.course) self.assertNotContains(response, banner_text, html=True) + @ddt.data( + ('valid-jwt-for-exam-sequence', 200), + ('valid-jwt-for-incorrect-sequence', 403), + ('invalid-jwt', 403), + ) + @override_settings( + PROCTORING_BACKENDS={ + 'DEFAULT': 'null', + 'null': {}, + 'lti_external': {} + } + ) + @ddt.unpack + @patch.dict('django.conf.settings.FEATURES', {'ENABLE_PROCTORED_EXAMS': True}) + @patch('lms.djangoapps.courseware.views.views.unpack_token_for') + def test_render_descendant_of_exam_gated_by_access_token(self, exam_access_token, + expected_response, _mock_token_unpack): + """ + Verify blocks inside an exam that requires token access are gated by + a valid exam access JWT issued for that exam sequence. + """ + with self.store.default_store(ModuleStoreEnum.Type.split): + # pylint:disable=attribute-defined-outside-init + self.course = CourseFactory.create(proctoring_provider='lti_external', **self.course_options()) + self.chapter = BlockFactory.create(parent=self.course, category='chapter') + self.sequence = BlockFactory.create( + parent=self.chapter, + category='sequential', + display_name='Sequence', + is_time_limited=True, + ) + self.vertical_block = BlockFactory.create( + parent=self.sequence, + category='vertical', + display_name="Vertical", + ) + self.problem_block = BlockFactory.create( + parent=self.vertical_block, + category='problem', + display_name='Problem' + ) + self.other_sequence = BlockFactory.create( + parent=self.chapter, + category='sequential', + display_name='Sequence 2', + ) + CourseOverview.load_from_module_store(self.course.id) + self.setup_user(admin=False, enroll=True, login=True) + + def _mock_token_unpack_fn(token, user_id): + if token == 'valid-jwt-for-exam-sequence': + return {'content_id': str(self.sequence.location)} + elif token == 'valid-jwt-for-incorrect-sequence': + return {'content_id': str(self.other_sequence.location)} + else: + raise Exception('invalid JWT') + + _mock_token_unpack.side_effect = _mock_token_unpack_fn + + # Problem and Vertical response should be gated on access token + for block in [self.problem_block, self.vertical_block]: + response = self.get_response( + usage_key=block.location, url_encoded_params=f'exam_access={exam_access_token}') + assert response.status_code == expected_response + + # The Sequence itself should also be gated + response = self.get_response( + usage_key=self.sequence.location, url_encoded_params=f'exam_access={exam_access_token}') + assert response.status_code == expected_response + @ddt.ddt class TestRenderPublicVideoXBlock(ModuleStoreTestCase): diff --git a/lms/djangoapps/courseware/views/views.py b/lms/djangoapps/courseware/views/views.py index cb29efb8e4..c0faf69109 100644 --- a/lms/djangoapps/courseware/views/views.py +++ b/lms/djangoapps/courseware/views/views.py @@ -44,6 +44,7 @@ from rest_framework import status from rest_framework.decorators import api_view, throttle_classes from rest_framework.response import Response from rest_framework.throttling import UserRateThrottle +from token_utils.api import unpack_token_for from web_fragments.fragment import Fragment from xmodule.course_block import COURSE_VISIBILITY_PUBLIC, COURSE_VISIBILITY_PUBLIC_OUTLINE from xmodule.modulestore.django import modulestore @@ -1485,6 +1486,30 @@ def enclosing_sequence_for_gating_checks(block): return None +def _check_sequence_exam_access(request, location): + """ + Checks the client request for an exam access token for a sequence. + Exam access is always granted at the sequence block. This method of gating is + only used by the edx-exams system and NOT edx-proctoring. + """ + if request.user.is_staff or is_masquerading_as_specific_student(request.user, location.course_key): + return True + + exam_access_token = request.GET.get('exam_access') + if exam_access_token: + try: + # unpack will validate both expiration and the requesting user matches the + # token user + exam_access_unpacked = unpack_token_for(exam_access_token, request.user.id) + except: # pylint: disable=bare-except + log.exception(f"Failed to validate exam access token. user_id={request.user.id} location={location}") + return False + + return str(location) == exam_access_unpacked.get('content_id') + + return False + + @require_http_methods(["GET", "POST"]) @ensure_valid_usage_key @xframe_options_exempt @@ -1583,6 +1608,18 @@ def render_xblock(request, usage_key_string, check_if_enrolled=True): ) ) + # For courses using an LTI provider managed by edx-exams: + # Access to exam content is determined by edx-exams and passed to the LMS using a + # JWT url param. There is no longer a need for exam gating or logic inside the + # sequence block or its render call. descendants_are_gated shoule not return true + # for these timed exams. Instead, sequences are assumed gated by default and we look for + # an access token on the request to allow rendering to continue. + if course.proctoring_provider == 'lti_external': + seq_block = ancestor_sequence_block if ancestor_sequence_block else block + if getattr(seq_block, 'is_time_limited', None): + if not _check_sequence_exam_access(request, seq_block.location): + return HttpResponseForbidden("Access to exam content is restricted") + fragment = block.render(requested_view, context=student_view_context) optimization_flags = get_optimization_flags_for_content(block, fragment) diff --git a/lms/envs/common.py b/lms/envs/common.py index f9df26d72d..c2d49137c0 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -4215,6 +4215,16 @@ ECOMMERCE_ORDERS_API_CACHE_TIMEOUT = 3600 ECOMMERCE_SERVICE_WORKER_USERNAME = 'ecommerce_worker' ECOMMERCE_API_SIGNING_KEY = 'SET-ME-PLEASE' +# Exam Service +EXAMS_SERVICE_URL = 'http://localhost:8740/api/v1' + +TOKEN_SIGNING = { + 'JWT_ISSUER': 'http://127.0.0.1:8740', + 'JWT_SIGNING_ALGORITHM': 'RS512', + 'JWT_SUPPORTED_VERSION': '1.2.0', + 'JWT_PUBLIC_SIGNING_JWK_SET': None, +} + COURSE_CATALOG_URL_ROOT = 'http://localhost:8008' COURSE_CATALOG_API_URL = f'{COURSE_CATALOG_URL_ROOT}/api/v1' diff --git a/lms/envs/devstack.py b/lms/envs/devstack.py index 2ef060bb4b..c10c5d81d3 100644 --- a/lms/envs/devstack.py +++ b/lms/envs/devstack.py @@ -245,6 +245,15 @@ CREDENTIALS_PUBLIC_SERVICE_URL = 'http://localhost:18150' ############## Exams CONFIGURATION SETTINGS #################### EXAMS_SERVICE_URL = 'http://localhost:8740/api/v1' +TOKEN_SIGNING.update({ + 'JWT_PUBLIC_SIGNING_JWK_SET': ( + '{"keys": [{"kid": "localdev_exam_token_key", "e": "AQAB", "kty": "RSA", "n": "o5cn3ljSRi6FaDEKTn0PS-oL9EFyv1pI' + '7dRgffQLD1qf5D6sprmYfWWokSsrWig8u2y0HChSygR6Jn5KXBqQn6FpM0dDJLnWQDRXHLl3Ey1iPYgDSmOIsIGrV9ZyNCQwk03wAgWbfdBTig' + '3QSDYD-sTNOs3pc4UD_PqAvU2nz_1SS2ZiOwOn5F6gulE1L0iE3KEUEvOIagfHNVhz0oxa_VRZILkzV-zr6R_TW1m97h4H8jXl_VJyQGyhMGGy' + 'puDrQ9_vaY_RLEulLCyY0INglHWQ7pckxBtI5q55-Vio2wgewe2_qYcGsnBGaDNbySAsvYcWRrqDiFyzrJYivodqTQ"}]}' + ) +}) + ############################### BLOCKSTORE ##################################### BLOCKSTORE_API_URL = "http://edx.devstack.blockstore:18250/api/v1/" diff --git a/requirements/edx/base.in b/requirements/edx/base.in index 51fe3deb58..51b234448e 100644 --- a/requirements/edx/base.in +++ b/requirements/edx/base.in @@ -98,6 +98,7 @@ edx-search edx-sga edx-submissions edx-toggles # Feature toggles management +edx-token-utils # Validate exam access tokens edx-user-state-client edx-when edxval diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 922de61332..339da32bc6 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -223,6 +223,7 @@ django==3.2.18 # edx-search # edx-submissions # edx-toggles + # edx-token-utils # edx-when # edxval # enmerkar @@ -538,6 +539,8 @@ edx-toggles==5.0.0 # edxval # learner-pathway-progress # ora2 +edx-token-utils==0.2.1 + # via -r requirements/edx/base.in edx-user-state-client==1.3.2 # via -r requirements/edx/base.in edx-when==2.3.0 @@ -843,6 +846,7 @@ pyjwkest==1.4.2 # via # -r requirements/edx/base.in # edx-drf-extensions + # edx-token-utils # lti-consumer-xblock pyjwt[crypto]==2.6.0 # via diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index c64f12ee7b..95db1ea8b6 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -318,6 +318,7 @@ django==3.2.18 # edx-search # edx-submissions # edx-toggles + # edx-token-utils # edx-when # edxval # enmerkar @@ -670,6 +671,8 @@ edx-toggles==5.0.0 # edxval # learner-pathway-progress # ora2 +edx-token-utils==0.2.1 + # via -r requirements/edx/testing.txt edx-user-state-client==1.3.2 # via -r requirements/edx/testing.txt edx-when==2.3.0 @@ -1146,6 +1149,7 @@ pyjwkest==1.4.2 # via # -r requirements/edx/testing.txt # edx-drf-extensions + # edx-token-utils # lti-consumer-xblock pyjwt[crypto]==2.6.0 # via diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 08d1dd70ad..16718f9a8d 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -62,7 +62,6 @@ attrs==22.2.0 # lti-consumer-xblock # openedx-blockstore # openedx-events - # outcome # pytest babel==2.11.0 # via @@ -301,6 +300,7 @@ django==3.2.18 # edx-search # edx-submissions # edx-toggles + # edx-token-utils # edx-when # edxval # enmerkar @@ -648,6 +648,8 @@ edx-toggles==5.0.0 # edxval # learner-pathway-progress # ora2 +edx-token-utils==0.2.1 + # via -r requirements/edx/base.txt edx-user-state-client==1.3.2 # via -r requirements/edx/base.txt edx-when==2.3.0 @@ -1085,6 +1087,7 @@ pyjwkest==1.4.2 # via # -r requirements/edx/base.txt # edx-drf-extensions + # edx-token-utils # lti-consumer-xblock pyjwt[crypto]==2.6.0 # via diff --git a/xmodule/seq_block.py b/xmodule/seq_block.py index 242dbedfa4..fc7202bfe0 100644 --- a/xmodule/seq_block.py +++ b/xmodule/seq_block.py @@ -933,6 +933,12 @@ class SequenceBlock( user_role_in_course = 'staff' if user_is_staff else 'student' course_id = self.scope_ids.usage_id.context_key content_id = self.location + course = self._get_course() + + # LTI exam tools are not managed by the edx-proctoring library + # Return None rather than reaching into the edx-proctoring subsystem + if course.proctoring_provider == 'lti_external': + return None context = { 'display_name': self.display_name,