diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 7bb4142a..094d6a0c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -12,6 +12,15 @@ Change Log Unreleased ---------- +[10.1.0] - 2024-01-26 +--------------------- + +* Added permanent toggle EDX_DRF_EXTENSIONS[ENABLE_JWT_AND_LMS_USER_EMAIL_MATCH]: + + * This toggle should only get enabled in the LMS, and should remain disabled in all other services. + * If enabled, makes sure that the user email in JWT cookies and LMS user email matches + * If email matches, it allows authentication otherwise raise JwtUserEmailMismatchError error. + [10.0.0] - 2023-11-30 --------------------- diff --git a/edx_rest_framework_extensions/__init__.py b/edx_rest_framework_extensions/__init__.py index 53051dbc..2917331f 100644 --- a/edx_rest_framework_extensions/__init__.py +++ b/edx_rest_framework_extensions/__init__.py @@ -1,3 +1,3 @@ """ edx Django REST Framework extensions. """ -__version__ = '10.0.0' # pragma: no cover +__version__ = '10.1.0' # pragma: no cover diff --git a/edx_rest_framework_extensions/auth/jwt/authentication.py b/edx_rest_framework_extensions/auth/jwt/authentication.py index ba2e6249..378b3ebb 100644 --- a/edx_rest_framework_extensions/auth/jwt/authentication.py +++ b/edx_rest_framework_extensions/auth/jwt/authentication.py @@ -14,7 +14,10 @@ configured_jwt_decode_handler, unsafe_jwt_decode_handler, ) -from edx_rest_framework_extensions.config import ENABLE_SET_REQUEST_USER_FOR_JWT_COOKIE +from edx_rest_framework_extensions.config import ( + ENABLE_JWT_AND_LMS_USER_EMAIL_MATCH, + ENABLE_SET_REQUEST_USER_FOR_JWT_COOKIE, +) from edx_rest_framework_extensions.settings import get_setting @@ -31,6 +34,10 @@ class JwtSessionUserMismatchError(JwtAuthenticationError): pass +class JwtUserEmailMismatchError(JwtAuthenticationError): + pass + + class CSRFCheck(CsrfViewMiddleware): def _reject(self, request, reason): # Return the failure reason instead of an HttpResponse @@ -103,6 +110,14 @@ def authenticate(self, request): set_custom_attribute('jwt_auth_result', 'n/a') return user_and_auth + if get_setting(ENABLE_JWT_AND_LMS_USER_EMAIL_MATCH): + is_email_mismatch = self._is_jwt_and_lms_user_email_mismatch(request, user_and_auth[0]) + if is_email_mismatch: + raise JwtUserEmailMismatchError( + 'Failing JWT authentication due to jwt user email mismatch ' + 'with lms user email.' + ) + # Not using JWT cookie, CSRF validation not required if not is_authenticating_with_jwt_cookie: set_custom_attribute('jwt_auth_result', 'success-auth-header') @@ -313,6 +328,23 @@ def _is_jwt_cookie_and_session_user_mismatch(self, request): return True + def _is_jwt_and_lms_user_email_mismatch(self, request, user): + """ + Returns True if user email in JWT and email of user do not match, False otherwise. + Arguments: + request: The request. + user: user from user_and_auth + """ + lms_user_email = getattr(user, 'email', None) + + # This function will check for token in the authorization header and return it + # otherwise it will return token from JWT cookies. + token = JSONWebTokenAuthentication.get_token_from_request(request) + decoded_jwt = configured_jwt_decode_handler(token) + jwt_user_email = decoded_jwt.get('email', None) + + return lms_user_email != jwt_user_email + def _get_unsafe_jwt_cookie_username_and_lms_user_id(self, request): """ Returns a tuple of the (username, lms user id) from the JWT cookie, or (None, None) if not found. diff --git a/edx_rest_framework_extensions/auth/jwt/tests/test_authentication.py b/edx_rest_framework_extensions/auth/jwt/tests/test_authentication.py index 0cae5177..eb6ba3ee 100644 --- a/edx_rest_framework_extensions/auth/jwt/tests/test_authentication.py +++ b/edx_rest_framework_extensions/auth/jwt/tests/test_authentication.py @@ -32,7 +32,10 @@ generate_jwt_token, generate_latest_version_payload, ) -from edx_rest_framework_extensions.config import ENABLE_SET_REQUEST_USER_FOR_JWT_COOKIE +from edx_rest_framework_extensions.config import ( + ENABLE_JWT_AND_LMS_USER_EMAIL_MATCH, + ENABLE_SET_REQUEST_USER_FOR_JWT_COOKIE, +) from edx_rest_framework_extensions.tests import factories @@ -534,6 +537,117 @@ def test_authenticate_jwt_and_no_session_and_set_request_user(self, mock_set_cus assert 'jwt_auth_mismatch_jwt_cookie_username' not in set_custom_attribute_keys assert response.status_code == 200 + @override_settings( + MIDDLEWARE=( + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'edx_rest_framework_extensions.auth.jwt.middleware.JwtAuthCookieMiddleware', + ), + ROOT_URLCONF='edx_rest_framework_extensions.auth.jwt.tests.test_authentication', + ) + def test_authenticate_user_lms_and_jwt_email_mismatch_toggle_disabled(self): + """ + Test success for JwtAuthentication when ENABLE_JWT_AND_LMS_USER_EMAIL_MATCH is disabled. + """ + user = factories.UserFactory(email='old@example.com') + jwt_header_payload, jwt_signature = self._get_test_jwt_token_payload_and_signature(user=user) + + # Cookie parts will be recombined by JwtAuthCookieMiddleware + self.client.cookies = SimpleCookie({ + jwt_cookie_header_payload_name(): jwt_header_payload, + jwt_cookie_signature_name(): jwt_signature, + }) + + # simulating email change + user.email = 'new@example.com' + user.save() # pylint: disable=no-member + + self.client.force_login(user) + + response = self.client.get(reverse('authenticated-view')) + + assert response.status_code == 200 + + @override_settings( + EDX_DRF_EXTENSIONS={ + ENABLE_JWT_AND_LMS_USER_EMAIL_MATCH: True, + 'JWT_PAYLOAD_USER_ATTRIBUTE_MAPPING': {}, + 'JWT_PAYLOAD_MERGEABLE_USER_ATTRIBUTES': [] + }, + MIDDLEWARE=( + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'edx_rest_framework_extensions.auth.jwt.middleware.JwtAuthCookieMiddleware', + ), + ROOT_URLCONF='edx_rest_framework_extensions.auth.jwt.tests.test_authentication', + ) + @mock.patch('edx_rest_framework_extensions.auth.jwt.authentication.set_custom_attribute') + def test_authenticate_user_lms_and_jwt_email_match_failure(self, mock_set_custom_attribute): + """ + Test failure for JwtAuthentication when ENABLE_JWT_AND_LMS_USER_EMAIL_MATCH + is enabled and the lms and jwt user email do not match. + """ + user = factories.UserFactory(email='old@example.com') + jwt_header_payload, jwt_signature = self._get_test_jwt_token_payload_and_signature(user=user) + + # Cookie parts will be recombined by JwtAuthCookieMiddleware + self.client.cookies = SimpleCookie({ + jwt_cookie_header_payload_name(): jwt_header_payload, + jwt_cookie_signature_name(): jwt_signature, + }) + + # simulating email change + user.email = 'new@example.com' + user.save() # pylint: disable=no-member + + self.client.force_login(user) + + response = self.client.get(reverse('authenticated-view')) + + assert response.status_code == 401 + mock_set_custom_attribute.assert_any_call( + 'jwt_auth_failed', + "Exception:JwtUserEmailMismatchError('Failing JWT authentication due to jwt user email mismatch with lms " + "user email.')" + ) + + @override_settings( + EDX_DRF_EXTENSIONS={ + ENABLE_JWT_AND_LMS_USER_EMAIL_MATCH: True, + 'JWT_PAYLOAD_USER_ATTRIBUTE_MAPPING': {}, + 'JWT_PAYLOAD_MERGEABLE_USER_ATTRIBUTES': [] + }, + MIDDLEWARE=( + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'edx_rest_framework_extensions.auth.jwt.middleware.JwtAuthCookieMiddleware', + ), + ROOT_URLCONF='edx_rest_framework_extensions.auth.jwt.tests.test_authentication', + ) + @mock.patch('edx_rest_framework_extensions.auth.jwt.authentication.set_custom_attribute') + def test_authenticate_user_lms_and_jwt_email_match_success(self, mock_set_custom_attribute): + """ + Test success for JwtAuthentication when ENABLE_JWT_AND_LMS_USER_EMAIL_MATCH + is enabled and the lms and jwt user email match. + """ + user = factories.UserFactory(email='old@example.com') + jwt_header_payload, jwt_signature = self._get_test_jwt_token_payload_and_signature(user=user) + + # Cookie parts will be recombined by JwtAuthCookieMiddleware + self.client.cookies = SimpleCookie({ + jwt_cookie_header_payload_name(): jwt_header_payload, + jwt_cookie_signature_name(): jwt_signature, + }) + + # Not changing email + + self.client.force_login(user) + + response = self.client.get(reverse('authenticated-view')) + + assert response.status_code == 200 + mock_set_custom_attribute.assert_any_call('jwt_auth_result', 'success-cookie') + def _get_test_jwt_token(self, user=None, is_valid_signature=True, lms_user_id=None): """ Returns a test jwt token for the provided user """ test_user = factories.UserFactory() if user is None else user diff --git a/edx_rest_framework_extensions/config.py b/edx_rest_framework_extensions/config.py index f8366ee1..35ab89ad 100644 --- a/edx_rest_framework_extensions/config.py +++ b/edx_rest_framework_extensions/config.py @@ -15,3 +15,14 @@ # work in all services, or find a replacement. Consider making this a permanent toggle instead. # .. toggle_tickets: ARCH-1210, ARCH-1199, ARCH-1197 ENABLE_SET_REQUEST_USER_FOR_JWT_COOKIE = 'ENABLE_SET_REQUEST_USER_FOR_JWT_COOKIE' + +# .. toggle_name: EDX_DRF_EXTENSIONS[ENABLE_JWT_AND_LMS_USER_EMAIL_MATCH] +# .. toggle_implementation: DjangoSetting +# .. toggle_default: False +# .. toggle_description: Toggle to add a check for matching user email in JWT and LMS user email +# for authentication in JwtAuthentication class. This toggle should only be enabled in the +# LMS as our identity service that is also creating the JWTs. +# .. toggle_use_cases: open_edx +# .. toggle_creation_date: 2023-12-20 +# .. toggle_tickets: VAN-1694 +ENABLE_JWT_AND_LMS_USER_EMAIL_MATCH = 'ENABLE_JWT_AND_LMS_USER_EMAIL_MATCH' diff --git a/edx_rest_framework_extensions/settings.py b/edx_rest_framework_extensions/settings.py index 7bb12c6d..3d16890f 100644 --- a/edx_rest_framework_extensions/settings.py +++ b/edx_rest_framework_extensions/settings.py @@ -15,13 +15,17 @@ from django.conf import settings from rest_framework_jwt.settings import api_settings -from edx_rest_framework_extensions.config import ENABLE_SET_REQUEST_USER_FOR_JWT_COOKIE +from edx_rest_framework_extensions.config import ( + ENABLE_JWT_AND_LMS_USER_EMAIL_MATCH, + ENABLE_SET_REQUEST_USER_FOR_JWT_COOKIE, +) logger = logging.getLogger(__name__) DEFAULT_SETTINGS = { + ENABLE_JWT_AND_LMS_USER_EMAIL_MATCH: False, ENABLE_SET_REQUEST_USER_FOR_JWT_COOKIE: False, 'JWT_PAYLOAD_MERGEABLE_USER_ATTRIBUTES': (),