feat: gate exam content using access token (#31653)
Gate access to exam content by requiring an access token. This is a signed JWT issued by the edx-exams service that grants a user access to a sequence locator for a short lived window while an exam is in progress. This feature only applies to courses using the new exam service instead of edx-proctoring.
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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/"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user