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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user