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:
Zachary Hancock
2023-02-17 10:00:52 -05:00
committed by GitHub
parent 3b1c049429
commit 9522cbdc8b
9 changed files with 146 additions and 1 deletions

View File

@@ -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):

View File

@@ -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)

View File

@@ -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'

View File

@@ -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/"

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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,