diff --git a/.github/workflows/check_python_dependencies.yml b/.github/workflows/check_python_dependencies.yml index 85a4e796ce78..b691e68d4be9 100644 --- a/.github/workflows/check_python_dependencies.yml +++ b/.github/workflows/check_python_dependencies.yml @@ -14,18 +14,18 @@ jobs: steps: - name: Checkout Repository uses: actions/checkout@v4 - + - name: Set up Python uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - + - name: Install repo-tools run: pip install edx-repo-tools[find_dependencies] - name: Install setuptool - run: pip install setuptools - + run: pip install setuptools + - name: Run Python script run: | find_python_dependencies \ @@ -35,6 +35,5 @@ jobs: --ignore https://github.com/edx/braze-client \ --ignore https://github.com/edx/edx-name-affirmation \ --ignore https://github.com/mitodl/edx-sga \ - --ignore https://github.com/edx/token-utils \ --ignore https://github.com/open-craft/xblock-poll - + diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index 2bedcd0d8e33..da31ebccce45 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -2933,9 +2933,9 @@ def test_render_xblock_with_course_duration_limits_in_mobile_browser(self, mock_ ) @ddt.unpack @patch.dict('django.conf.settings.FEATURES', {'ENABLE_PROCTORED_EXAMS': True}) - @patch('lms.djangoapps.courseware.views.views.unpack_token_for') + @patch('lms.djangoapps.courseware.views.views.unpack_jwt') def test_render_descendant_of_exam_gated_by_access_token(self, exam_access_token, - expected_response, _mock_token_unpack): + expected_response, _mock_unpack_jwt): """ Verify blocks inside an exam that requires token access are gated by a valid exam access JWT issued for that exam sequence. @@ -2968,7 +2968,7 @@ def test_render_descendant_of_exam_gated_by_access_token(self, exam_access_token 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): + def _mock_unpack_jwt_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': @@ -2976,7 +2976,7 @@ def _mock_token_unpack_fn(token, user_id): else: raise Exception('invalid JWT') - _mock_token_unpack.side_effect = _mock_token_unpack_fn + _mock_unpack_jwt.side_effect = _mock_unpack_jwt_fn # Problem and Vertical response should be gated on access token for block in [self.problem_block, self.vertical_block]: diff --git a/lms/djangoapps/courseware/views/views.py b/lms/djangoapps/courseware/views/views.py index 6e0804db8ca0..19eabe692997 100644 --- a/lms/djangoapps/courseware/views/views.py +++ b/lms/djangoapps/courseware/views/views.py @@ -46,7 +46,6 @@ 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, @@ -138,6 +137,7 @@ from openedx.core.djangoapps.zendesk_proxy.utils import create_zendesk_ticket from openedx.core.djangolib.markup import HTML, Text from openedx.core.lib.courses import get_course_by_id +from openedx.core.lib.jwt import unpack_jwt from openedx.core.lib.mobile_utils import is_request_from_mobile_app from openedx.features.course_duration_limits.access import generate_course_expired_fragment from openedx.features.course_experience import course_home_url @@ -1535,7 +1535,7 @@ def _check_sequence_exam_access(request, location): 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) + exam_access_unpacked = unpack_jwt(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 diff --git a/lms/envs/common.py b/lms/envs/common.py index cb7643c3668e..76127d062d81 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -4311,13 +4311,21 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring # Exam Service EXAMS_SERVICE_URL = 'http://localhost:18740/api/v1' +############## Settings for JWT token handling ############## TOKEN_SIGNING = { 'JWT_ISSUER': 'http://127.0.0.1:8740', 'JWT_SIGNING_ALGORITHM': 'RS512', 'JWT_SUPPORTED_VERSION': '1.2.0', + 'JWT_PRIVATE_SIGNING_JWK': None, 'JWT_PUBLIC_SIGNING_JWK_SET': None, } +# NOTE: In order to create both JWT_PRIVATE_SIGNING_JWK and JWT_PUBLIC_SIGNING_JWK_SET, +# in an lms shell run the following command: +# > python manage.py lms generate_jwt_signing_key +# This will output asymmetric JWTs to use here. Read more on this on: +# https://github.com/openedx/edx-platform/blob/master/openedx/core/djangoapps/oauth_dispatch/docs/decisions/0008-use-asymmetric-jwts.rst + COURSE_CATALOG_URL_ROOT = 'http://localhost:8008' COURSE_CATALOG_API_URL = f'{COURSE_CATALOG_URL_ROOT}/api/v1' diff --git a/lms/envs/test.py b/lms/envs/test.py index a9e8aaf9f2e2..38c12370f1c7 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -657,3 +657,35 @@ # case of new django version these values will override. if django.VERSION[0] >= 4: # for greater than django 3.2 use with schemes. CSRF_TRUSTED_ORIGINS = CSRF_TRUSTED_ORIGINS_WITH_SCHEME + + +############## Settings for JWT token handling ############## +TOKEN_SIGNING = { + 'JWT_ISSUER': 'token-test-issuer', + 'JWT_SIGNING_ALGORITHM': 'RS512', + 'JWT_SUPPORTED_VERSION': '1.2.0', + 'JWT_PRIVATE_SIGNING_JWK': '''{ + "e": "AQAB", + "d": "HIiV7KNjcdhVbpn3KT-I9n3JPf5YbGXsCIedmPqDH1d4QhBofuAqZ9zebQuxkRUpmqtYMv0Zi6ECSUqH387GYQF_XvFUFcjQRPycISd8TH0DAKaDpGr-AYNshnKiEtQpINhcP44I1AYNPCwyoxXA1fGTtmkKChsuWea7o8kytwU5xSejvh5-jiqu2SF4GEl0BEXIAPZsgbzoPIWNxgO4_RzNnWs6nJZeszcaDD0CyezVSuH9QcI6g5QFzAC_YuykSsaaFJhZ05DocBsLczShJ9Omf6PnK9xlm26I84xrEh_7x4fVmNBg3xWTLh8qOnHqGko93A1diLRCrKHOvnpvgQ", + "n": "o5cn3ljSRi6FaDEKTn0PS-oL9EFyv1pI7dRgffQLD1qf5D6sprmYfWWokSsrWig8u2y0HChSygR6Jn5KXBqQn6FpM0dDJLnWQDRXHLl3Ey1iPYgDSmOIsIGrV9ZyNCQwk03wAgWbfdBTig3QSDYD-sTNOs3pc4UD_PqAvU2nz_1SS2ZiOwOn5F6gulE1L0iE3KEUEvOIagfHNVhz0oxa_VRZILkzV-zr6R_TW1m97h4H8jXl_VJyQGyhMGGypuDrQ9_vaY_RLEulLCyY0INglHWQ7pckxBtI5q55-Vio2wgewe2_qYcGsnBGaDNbySAsvYcWRrqDiFyzrJYivodqTQ", + "q": "3T3DEtBUka7hLGdIsDlC96Uadx_q_E4Vb1cxx_4Ss_wGp1Loz3N3ZngGyInsKlmbBgLo1Ykd6T9TRvRNEWEtFSOcm2INIBoVoXk7W5RuPa8Cgq2tjQj9ziGQ08JMejrPlj3Q1wmALJr5VTfvSYBu0WkljhKNCy1KB6fCby0C9WE", + "p": "vUqzWPZnDG4IXyo-k5F0bHV0BNL_pVhQoLW7eyFHnw74IOEfSbdsMspNcPSFIrtgPsn7981qv3lN_staZ6JflKfHayjB_lvltHyZxfl0dvruShZOx1N6ykEo7YrAskC_qxUyrIvqmJ64zPW3jkuOYrFs7Ykj3zFx3Zq1H5568G0", + "kid": "token-test-sign", "kty": "RSA" + }''', + 'JWT_PUBLIC_SIGNING_JWK_SET': '''{ + "keys": [ + { + "kid":"token-test-wrong-key", + "e": "AQAB", + "kty": "RSA", + "n": "o5cn3ljSRi6FaDEKTn0PS-oL9EFyv1pI7dffgRQLD1qf5D6sprmYfWVokSsrWig8u2y0HChSygR6Jn5KXBqQn6FpM0dDJLnWQDRXHLl3Ey1iPYgDSmOIsIGrV9ZyNCQwk03wAgWbfdBTig3QSDYD-sTNOs3pc4UD_PqAvU2nz_1SS2ZiOwOn5F6gulE1L0iE3KEUEvOIagfHNVhz0oxa_VRZILkzV-zr6R_TW1m97h4H8jXl_VJyQGyhMGGypuDrQ9_vaY_RLEulLCyY0INglHWQ7pckxBtI5q55-Vio2wgewe2_qYcGsnBGaDNbySAsvYcWRrqDiFyzrJYivodqTQ" + }, + { + "kid":"token-test-sign", + "e": "AQAB", + "kty": "RSA", + "n": "o5cn3ljSRi6FaDEKTn0PS-oL9EFyv1pI7dRgffQLD1qf5D6sprmYfWWokSsrWig8u2y0HChSygR6Jn5KXBqQn6FpM0dDJLnWQDRXHLl3Ey1iPYgDSmOIsIGrV9ZyNCQwk03wAgWbfdBTig3QSDYD-sTNOs3pc4UD_PqAvU2nz_1SS2ZiOwOn5F6gulE1L0iE3KEUEvOIagfHNVhz0oxa_VRZILkzV-zr6R_TW1m97h4H8jXl_VJyQGyhMGGypuDrQ9_vaY_RLEulLCyY0INglHWQ7pckxBtI5q55-Vio2wgewe2_qYcGsnBGaDNbySAsvYcWRrqDiFyzrJYivodqTQ" + } + ] + }''', +} diff --git a/openedx/core/lib/jwt.py b/openedx/core/lib/jwt.py new file mode 100644 index 000000000000..47642b869560 --- /dev/null +++ b/openedx/core/lib/jwt.py @@ -0,0 +1,91 @@ +""" +JWT Token handling and signing functions. +""" + +import json +from time import time + +from django.conf import settings +from jwkest import Expired, Invalid, MissingKey, jwk +from jwkest.jws import JWS + + +def create_jwt(lms_user_id, expires_in_seconds, additional_token_claims, now=None): + """ + Produce an encoded JWT (string) indicating some temporary permission for the indicated user. + + What permission that is must be encoded in additional_claims. + Arguments: + lms_user_id (int): LMS user ID this token is being generated for + expires_in_seconds (int): Time to token expiry, specified in seconds. + additional_token_claims (dict): Additional claims to include in the token. + now(int): optional now value for testing + """ + now = now or int(time()) + + payload = { + 'lms_user_id': lms_user_id, + 'exp': now + expires_in_seconds, + 'iat': now, + 'iss': settings.TOKEN_SIGNING['JWT_ISSUER'], + 'version': settings.TOKEN_SIGNING['JWT_SUPPORTED_VERSION'], + } + payload.update(additional_token_claims) + return _encode_and_sign(payload) + + +def _encode_and_sign(payload): + """ + Encode and sign the provided payload. + + The signing key and algorithm are pulled from settings. + """ + keys = jwk.KEYS() + + serialized_keypair = json.loads(settings.TOKEN_SIGNING['JWT_PRIVATE_SIGNING_JWK']) + keys.add(serialized_keypair) + algorithm = settings.TOKEN_SIGNING['JWT_SIGNING_ALGORITHM'] + + data = json.dumps(payload) + jws = JWS(data, alg=algorithm) + return jws.sign_compact(keys=keys) + + +def unpack_jwt(token, lms_user_id, now=None): + """ + Unpack and verify an encoded JWT. + + Validate the user and expiration. + + Arguments: + token (string): The token to be unpacked and verified. + lms_user_id (int): LMS user ID this token should match with. + now (int): Optional now value for testing. + + Returns a valid, decoded json payload (string). + """ + now = now or int(time()) + payload = _unpack_and_verify(token) + + if "lms_user_id" not in payload: + raise MissingKey("LMS user id is missing") + if "exp" not in payload: + raise MissingKey("Expiration is missing") + if payload["lms_user_id"] != lms_user_id: + raise Invalid("User does not match") + if payload["exp"] < now: + raise Expired("Token is expired") + + return payload + + +def _unpack_and_verify(token): + """ + Unpack and verify the provided token. + + The signing key and algorithm are pulled from settings. + """ + keys = jwk.KEYS() + keys.load_jwks(settings.TOKEN_SIGNING['JWT_PUBLIC_SIGNING_JWK_SET']) + decoded = JWS().verify_compact(token.encode('utf-8'), keys) + return decoded diff --git a/openedx/core/lib/tests/test_jwt.py b/openedx/core/lib/tests/test_jwt.py new file mode 100644 index 000000000000..7a678dd3c09b --- /dev/null +++ b/openedx/core/lib/tests/test_jwt.py @@ -0,0 +1,129 @@ +""" +Tests for token handling +""" +import unittest + +from django.conf import settings +from jwkest import BadSignature, Expired, Invalid, MissingKey, jwk +from jwkest.jws import JWS + +from openedx.core.djangolib.testing.utils import skip_unless_lms +from openedx.core.lib.jwt import _encode_and_sign, create_jwt, unpack_jwt + + +test_user_id = 121 +invalid_test_user_id = 120 +test_timeout = 60 +test_now = 1661432902 +test_claims = {"foo": "bar", "baz": "quux", "meaning": 42} +expected_full_token = { + "lms_user_id": test_user_id, + "iat": 1661432902, + "exp": 1661432902 + 60, + "iss": "token-test-issuer", # these lines from test_settings.py + "version": "1.2.0", # these lines from test_settings.py +} + + +@skip_unless_lms +class TestSign(unittest.TestCase): + """ + Tests for JWT creation and signing. + """ + + def test_create_jwt(self): + token = create_jwt(test_user_id, test_timeout, {}, test_now) + + decoded = _verify_jwt(token) + self.assertEqual(expected_full_token, decoded) + + def test_create_jwt_with_claims(self): + token = create_jwt(test_user_id, test_timeout, test_claims, test_now) + + expected_token_with_claims = expected_full_token.copy() + expected_token_with_claims.update(test_claims) + + decoded = _verify_jwt(token) + self.assertEqual(expected_token_with_claims, decoded) + + def test_malformed_token(self): + token = create_jwt(test_user_id, test_timeout, test_claims, test_now) + token = token + "a" + + expected_token_with_claims = expected_full_token.copy() + expected_token_with_claims.update(test_claims) + + with self.assertRaises(BadSignature): + _verify_jwt(token) + + +def _verify_jwt(jwt_token): + """ + Helper function which verifies the signature and decodes the token + from string back to claims form + """ + keys = jwk.KEYS() + keys.load_jwks(settings.TOKEN_SIGNING['JWT_PUBLIC_SIGNING_JWK_SET']) + decoded = JWS().verify_compact(jwt_token.encode('utf-8'), keys) + return decoded + + +@skip_unless_lms +class TestUnpack(unittest.TestCase): + """ + Tests for JWT unpacking. + """ + + def test_unpack_jwt(self): + token = create_jwt(test_user_id, test_timeout, {}, test_now) + decoded = unpack_jwt(token, test_user_id, test_now) + + self.assertEqual(expected_full_token, decoded) + + def test_unpack_jwt_with_claims(self): + token = create_jwt(test_user_id, test_timeout, test_claims, test_now) + + expected_token_with_claims = expected_full_token.copy() + expected_token_with_claims.update(test_claims) + + decoded = unpack_jwt(token, test_user_id, test_now) + + self.assertEqual(expected_token_with_claims, decoded) + + def test_malformed_token(self): + token = create_jwt(test_user_id, test_timeout, test_claims, test_now) + token = token + "a" + + expected_token_with_claims = expected_full_token.copy() + expected_token_with_claims.update(test_claims) + + with self.assertRaises(BadSignature): + unpack_jwt(token, test_user_id, test_now) + + def test_unpack_token_with_invalid_user(self): + token = create_jwt(invalid_test_user_id, test_timeout, {}, test_now) + + with self.assertRaises(Invalid): + unpack_jwt(token, test_user_id, test_now) + + def test_unpack_expired_token(self): + token = create_jwt(test_user_id, test_timeout, {}, test_now) + + with self.assertRaises(Expired): + unpack_jwt(token, test_user_id, test_now + test_timeout + 1) + + def test_missing_expired_lms_user_id(self): + payload = expected_full_token.copy() + del payload['lms_user_id'] + token = _encode_and_sign(payload) + + with self.assertRaises(MissingKey): + unpack_jwt(token, test_user_id, test_now) + + def test_missing_expired_key(self): + payload = expected_full_token.copy() + del payload['exp'] + token = _encode_and_sign(payload) + + with self.assertRaises(MissingKey): + unpack_jwt(token, test_user_id, test_now) diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 4861b7be1f63..bd412f92132a 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -221,7 +221,6 @@ django==4.2.18 # edx-search # edx-submissions # edx-toggles - # edx-token-utils # edx-when # edxval # enmerkar @@ -538,8 +537,6 @@ edx-toggles==5.2.0 # edxval # event-tracking # ora2 -edx-token-utils==0.2.1 - # via -r requirements/edx/kernel.in edx-when==2.5.1 # via # -r requirements/edx/kernel.in @@ -931,7 +928,6 @@ pygments==2.19.1 pyjwkest==1.4.2 # via # -r requirements/edx/kernel.in - # edx-token-utils # lti-consumer-xblock pyjwt[crypto]==2.10.1 # via diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index ae377bd5cc7c..6a8c617d41be 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -395,7 +395,6 @@ django==4.2.18 # edx-search # edx-submissions # edx-toggles - # edx-token-utils # edx-when # edxval # enmerkar @@ -845,10 +844,6 @@ edx-toggles==5.2.0 # edxval # event-tracking # ora2 -edx-token-utils==0.2.1 - # via - # -r requirements/edx/doc.txt - # -r requirements/edx/testing.txt edx-when==2.5.1 # via # -r requirements/edx/doc.txt @@ -1598,7 +1593,6 @@ pyjwkest==1.4.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt - # edx-token-utils # lti-consumer-xblock pyjwt[crypto]==2.10.1 # via diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 354e85c98f55..66bd24f8fcfc 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -280,7 +280,6 @@ django==4.2.18 # edx-search # edx-submissions # edx-toggles - # edx-token-utils # edx-when # edxval # enmerkar @@ -629,8 +628,6 @@ edx-toggles==5.2.0 # edxval # event-tracking # ora2 -edx-token-utils==0.2.1 - # via -r requirements/edx/base.txt edx-when==2.5.1 # via # -r requirements/edx/base.txt @@ -1147,7 +1144,6 @@ pygments==2.19.1 pyjwkest==1.4.2 # via # -r requirements/edx/base.txt - # edx-token-utils # lti-consumer-xblock pyjwt[crypto]==2.10.1 # via diff --git a/requirements/edx/kernel.in b/requirements/edx/kernel.in index d1a132778133..a17b9db4c868 100644 --- a/requirements/edx/kernel.in +++ b/requirements/edx/kernel.in @@ -84,7 +84,6 @@ edx-rest-api-client edx-search edx-submissions edx-toggles # Feature toggles management -edx-token-utils # Validate exam access tokens edx-when edxval event-tracking diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 799f8b16ed26..27fe32c152b2 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -306,7 +306,6 @@ django==4.2.18 # edx-search # edx-submissions # edx-toggles - # edx-token-utils # edx-when # edxval # enmerkar @@ -652,8 +651,6 @@ edx-toggles==5.2.0 # edxval # event-tracking # ora2 -edx-token-utils==0.2.1 - # via -r requirements/edx/base.txt edx-when==2.5.1 # via # -r requirements/edx/base.txt @@ -1211,7 +1208,6 @@ pygments==2.19.1 pyjwkest==1.4.2 # via # -r requirements/edx/base.txt - # edx-token-utils # lti-consumer-xblock pyjwt[crypto]==2.10.1 # via