diff --git a/cms/envs/common.py b/cms/envs/common.py index a9ea495b0841..b1b8c2cab05e 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -1082,7 +1082,7 @@ } DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' -DEFAULT_HASHING_ALGORITHM = 'sha1' +DEFAULT_HASHING_ALGORITHM = 'sha256' #################### Python sandbox ############################################ diff --git a/lms/djangoapps/certificates/tests/test_webview_views.py b/lms/djangoapps/certificates/tests/test_webview_views.py index 96832e3cb694..5489f28e0f7e 100644 --- a/lms/djangoapps/certificates/tests/test_webview_views.py +++ b/lms/djangoapps/certificates/tests/test_webview_views.py @@ -49,6 +49,7 @@ ) from openedx.core.djangolib.js_utils import js_escaped_string from openedx.core.djangolib.testing.utils import CacheIsolationTestCase +from openedx.core.lib.courses import course_image_url from openedx.core.lib.tests.assertions.events import assert_event_matches from openedx.features.name_affirmation_api.utils import get_name_affirmation_service from xmodule.data import CertificatesDisplayBehaviors # lint-amnesty, pylint: disable=wrong-import-order @@ -351,6 +352,68 @@ def test_linkedin_share_url_site(self): js_escaped_string(self.linkedin_url.format(params=urlencode(params))), ) + @patch.dict("django.conf.settings.SOCIAL_SHARING_SETTINGS", { + "CERTIFICATE_FACEBOOK": True, + "CERTIFICATE_FACEBOOK_TEXT": "test FB text" + }) + @override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED) + def test_render_certificate_html_view_with_facebook_meta_tags(self): + """ + Test view html certificate if share to FB is enabled. + If 'facebook_share_enabled=True', tags with property="og:..." + must be enabled to pass parameters to FB. + """ + self._add_course_certificates(count=1, signatory_count=1, is_active=True) + self.course.cert_html_view_enabled = True + self.course.save() + self.update_course(self.course, self.user.id) + test_url = get_certificate_url( + user_id=self.user.id, + course_id=str(self.course.id), + uuid=self.cert.verify_uuid + ) + platform_name = settings.PLATFORM_NAME + share_url = f'http://testserver{test_url}' + full_course_image_url = f'http://testserver{course_image_url(self.course)}' + document_title = f'{self.course.org} {self.course.number} Certificate | {platform_name}' + response = self.client.get(test_url) + + assert response.status_code == 200 + self.assertContains(response, f'') + self.assertContains(response, f'') + self.assertContains(response, '') + self.assertContains(response, f'') + self.assertContains(response, '') + + @patch.dict("django.conf.settings.SOCIAL_SHARING_SETTINGS", { + "CERTIFICATE_FACEBOOK": False, + }) + @override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED) + def test_render_certificate_html_view_without_facebook_meta_tags(self): + """ + Test view html certificate if share to FB is disabled. + If 'facebook_share_enabled=False', html certificate view + should not contain tags with parameters property="og:..." + """ + self._add_course_certificates(count=1, signatory_count=1, is_active=True) + self.course.cert_html_view_enabled = True + self.course.save() + self.update_course(self.course, self.user.id) + + test_url = get_certificate_url( + user_id=self.user.id, + course_id=str(self.course.id), + uuid=self.cert.verify_uuid + ) + response = self.client.get(test_url) + + assert response.status_code == 200 + self.assertNotContains(response, '') + self.assertNotContains(response, '${_("Print or share your certificate:")} % if facebook_share_enabled: ${_("Post on Facebook")} diff --git a/lms/templates/certificates/accomplishment-base.html b/lms/templates/certificates/accomplishment-base.html index 158c7698e80a..92c58dc138ff 100644 --- a/lms/templates/certificates/accomplishment-base.html +++ b/lms/templates/certificates/accomplishment-base.html @@ -15,7 +15,15 @@ - + % if facebook_share_enabled: + ## OG (Open Graph) url, title, type, image and description added below to give social media info to display + ## (https://developers.facebook.com/docs/opengraph/howtos/maximizing-distribution-media-content#tags) + + + + + + %endif ${document_title} <%static:css group='style-certificates'/> diff --git a/openedx/core/djangoapps/cache_toolbox/middleware.py b/openedx/core/djangoapps/cache_toolbox/middleware.py index 4ba2162fe35f..9d1ea2bf0690 100644 --- a/openedx/core/djangoapps/cache_toolbox/middleware.py +++ b/openedx/core/djangoapps/cache_toolbox/middleware.py @@ -95,6 +95,7 @@ from django.contrib.auth.models import AnonymousUser, User # lint-amnesty, pylint: disable=imported-auth-user from django.utils.crypto import constant_time_compare from django.utils.deprecation import MiddlewareMixin +from edx_django_utils.monitoring import set_custom_attribute from openedx.core.djangoapps.safe_sessions.middleware import SafeSessionMiddleware, _mark_cookie_for_deletion @@ -112,6 +113,7 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) def process_request(self, request): + set_custom_attribute('DEFAULT_HASHING_ALGORITHM', settings.DEFAULT_HASHING_ALGORITHM) try: # Try and construct a User instance from data stored in the cache session_user_id = SafeSessionMiddleware.get_user_id_from_session(request) @@ -141,9 +143,29 @@ def _verify_session_auth(self, request): auto_auth_enabled = settings.FEATURES.get('AUTOMATIC_AUTH_FOR_TESTING', False) if not auto_auth_enabled and hasattr(request.user, 'get_session_auth_hash'): session_hash = request.session.get(HASH_SESSION_KEY) - if not (session_hash and constant_time_compare(session_hash, request.user.get_session_auth_hash())): - # The session hash has changed due to a password - # change. Log the user out. - request.session.flush() - request.user = AnonymousUser() - _mark_cookie_for_deletion(request) + session_hash_verified = session_hash and constant_time_compare( + session_hash, request.user.get_session_auth_hash()) + + # session hash is verified from the default algo, so skip legacy check + if session_hash_verified: + set_custom_attribute('session_hash_verified', "default") + return + + if ( + session_hash and + hasattr(request.user, '_legacy_get_session_auth_hash') and + constant_time_compare( + session_hash, + request.user._legacy_get_session_auth_hash() # pylint: disable=protected-access + ) + ): + # session hash is verified from legacy hashing algorithm. + set_custom_attribute('session_hash_verified', "fallback") + return + + # The session hash has changed due to a password + # change. Log the user out. + request.session.flush() + request.user = AnonymousUser() + _mark_cookie_for_deletion(request) + set_custom_attribute('failed_session_verification', True) diff --git a/openedx/core/djangoapps/cache_toolbox/tests/test_middleware.py b/openedx/core/djangoapps/cache_toolbox/tests/test_middleware.py index 21547b69ca1c..a55090b1207b 100644 --- a/openedx/core/djangoapps/cache_toolbox/tests/test_middleware.py +++ b/openedx/core/djangoapps/cache_toolbox/tests/test_middleware.py @@ -1,17 +1,18 @@ """Tests for cached authentication middleware.""" -from unittest.mock import patch +from unittest.mock import call, patch +import django from django.conf import settings -from django.contrib.auth.models import User, AnonymousUser # lint-amnesty, pylint: disable=imported-auth-user -from django.urls import reverse -from django.test import TestCase from django.contrib.auth import SESSION_KEY +from django.contrib.auth.models import AnonymousUser, User # lint-amnesty, pylint: disable=imported-auth-user from django.http import HttpResponse, SimpleCookie +from django.test import TestCase +from django.urls import reverse +from common.djangoapps.student.tests.factories import UserFactory from openedx.core.djangoapps.cache_toolbox.middleware import CacheBackedAuthenticationMiddleware from openedx.core.djangoapps.safe_sessions.middleware import SafeCookieData, SafeSessionMiddleware -from openedx.core.djangolib.testing.utils import skip_unless_cms, skip_unless_lms, get_mock_request -from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangolib.testing.utils import get_mock_request, skip_unless_cms, skip_unless_lms class CachedAuthMiddlewareTestCase(TestCase): @@ -36,9 +37,68 @@ def _test_change_session_hash(self, test_url, redirect_url, target_status_code=2 """ response = self.client.get(test_url) assert response.status_code == 200 - with patch.object(User, 'get_session_auth_hash', return_value='abc123'): - response = self.client.get(test_url) - self.assertRedirects(response, redirect_url, target_status_code=target_status_code) + + with patch( + "openedx.core.djangoapps.cache_toolbox.middleware.set_custom_attribute" + ) as mock_set_custom_attribute: + with patch.object(User, 'get_session_auth_hash', return_value='abc123', autospec=True): + # Django 3.2 has _legacy_get_session_auth_hash, and Django 4 does not + # Remove once we reach Django 4 + if hasattr(User, '_legacy_get_session_auth_hash'): + with patch.object(User, '_legacy_get_session_auth_hash', return_value='abc123'): + response = self.client.get(test_url) + else: + response = self.client.get(test_url) + + self.assertRedirects(response, redirect_url, target_status_code=target_status_code) + mock_set_custom_attribute.assert_any_call('failed_session_verification', True) + + def _test_custom_attribute_after_changing_hash(self, test_url, mock_set_custom_attribute): + """verify that set_custom_attribute is called with expected values""" + password = 'test-password' + + # Test DEFAULT_HASHING_ALGORITHM of 'sha1' for both login and client get + with self.settings(DEFAULT_HASHING_ALGORITHM='sha1'): + self.client.login(username=self.user.username, password=password) + self.client.get(test_url) + # For Django 3.2, the setting 'sha1' applies and is the "default". + # For Django 4, the setting no longer applies, and 'sha256' will be used for both as the "default". + mock_set_custom_attribute.assert_has_calls([ + call('DEFAULT_HASHING_ALGORITHM', 'sha1'), + call('session_hash_verified', "default"), + ]) + mock_set_custom_attribute.reset_mock() + + # Test DEFAULT_HASHING_ALGORITHM of 'sha1' for login and switch to 'sha256' for client get. + with self.settings(DEFAULT_HASHING_ALGORITHM='sha1'): + self.client.login(username=self.user.username, password=password) + with self.settings(DEFAULT_HASHING_ALGORITHM='sha256'): + self.client.get(test_url) + if django.VERSION < (4, 0): + # For Django 3.2, the setting 'sha1' applies to login, and uses 'she256' for client get, + # and should "fallback" to 'sha1". + mock_set_custom_attribute.assert_has_calls([ + call('DEFAULT_HASHING_ALGORITHM', 'sha256'), + call('session_hash_verified', "fallback"), + ]) + else: + # For Django 4, the setting no longer applies, and again 'sha256' will be used for both as the "default". + mock_set_custom_attribute.assert_has_calls([ + call('DEFAULT_HASHING_ALGORITHM', 'sha256'), + call('session_hash_verified', "default"), + ]) + mock_set_custom_attribute.reset_mock() + + # Test DEFAULT_HASHING_ALGORITHM of 'sha256' for both login and client get + with self.settings(DEFAULT_HASHING_ALGORITHM='sha256'): + self.client.login(username=self.user.username, password=password) + self.client.get(test_url) + # For Django 3.2, the setting 'sha256' applies and is the "default". + # For Django 4, the setting no longer applies, and 'sha256' will be used for both as the "default". + mock_set_custom_attribute.assert_has_calls([ + call('DEFAULT_HASHING_ALGORITHM', 'sha256'), + call('session_hash_verified', "default"), + ]) @skip_unless_lms def test_session_change_lms(self): @@ -53,6 +113,20 @@ def test_session_change_cms(self): # Studio login redirects to LMS login self._test_change_session_hash(home_url, settings.LOGIN_URL + '?next=' + home_url, target_status_code=302) + @skip_unless_lms + @patch("openedx.core.djangoapps.cache_toolbox.middleware.set_custom_attribute") + def test_custom_attribute_after_changing_hash_lms(self, mock_set_custom_attribute): + """Test set_custom_attribute is called with expected values in LMS""" + test_url = reverse('dashboard') + self._test_custom_attribute_after_changing_hash(test_url, mock_set_custom_attribute) + + @skip_unless_cms + @patch("openedx.core.djangoapps.cache_toolbox.middleware.set_custom_attribute") + def test_custom_attribute_after_changing_hash_cms(self, mock_set_custom_attribute): + """Test set_custom_attribute is called with expected values in CMS""" + test_url = reverse('home') + self._test_custom_attribute_after_changing_hash(test_url, mock_set_custom_attribute) + def test_user_logout_on_session_hash_change(self): """ Verify that if a user's session auth hash and the request's hash @@ -75,9 +149,18 @@ def test_user_logout_on_session_hash_change(self): assert self.client.response.cookies.get(settings.SESSION_COOKIE_NAME).value == session_id assert self.client.response.cookies.get('edx-jwt-cookie-header-payload').value == 'test-jwt-payload' - with patch.object(User, 'get_session_auth_hash', return_value='abc123'): - CacheBackedAuthenticationMiddleware().process_request(self.request) - SafeSessionMiddleware().process_response(self.request, self.client.response) + with patch.object(User, 'get_session_auth_hash', return_value='abc123', autospec=True): + # Django 3.2 has _legacy_get_session_auth_hash, and Django 4 does not + # Remove once we reach Django 4 + if hasattr(User, '_legacy_get_session_auth_hash'): + with patch.object(User, '_legacy_get_session_auth_hash', return_value='abc123'): + CacheBackedAuthenticationMiddleware(get_response=lambda request: None).process_request(self.request) + + else: + CacheBackedAuthenticationMiddleware(get_response=lambda request: None).process_request(self.request) + SafeSessionMiddleware(get_response=lambda request: None).process_response( + self.request, self.client.response + ) # asserts that user, session, and JWT cookies do not exist assert self.request.session.get(SESSION_KEY) is None diff --git a/openedx/core/djangoapps/safe_sessions/tests/test_middleware.py b/openedx/core/djangoapps/safe_sessions/tests/test_middleware.py index f23773b842a7..babc3160f81e 100644 --- a/openedx/core/djangoapps/safe_sessions/tests/test_middleware.py +++ b/openedx/core/djangoapps/safe_sessions/tests/test_middleware.py @@ -223,6 +223,16 @@ def test_update_cookie_data_at_step_3(self): assert safe_cookie_data.session_id == 'some_session_id' assert safe_cookie_data.verify(self.user.id) + def test_update_cookie_data_at_step_3_with_sha256(self): + """ first encode cookie with default algo sha1 and then check with sha256""" + self.assert_response(set_request_user=True, set_session_cookie=True) + serialized_cookie_data = self.client.response.cookies[settings.SESSION_COOKIE_NAME].value + safe_cookie_data = SafeCookieData.parse(serialized_cookie_data) + assert safe_cookie_data.version == SafeCookieData.CURRENT_VERSION + assert safe_cookie_data.session_id == 'some_session_id' + with self.settings(DEFAULT_HASHING_ALGORITHM='sha256'): + assert safe_cookie_data.verify(self.user.id) + def test_cant_update_cookie_at_step_3_error(self): self.client.response.cookies[settings.SESSION_COOKIE_NAME] = None with self.assert_invalid_session_id(): diff --git a/openedx/core/djangoapps/safe_sessions/tests/test_safe_cookie_data.py b/openedx/core/djangoapps/safe_sessions/tests/test_safe_cookie_data.py index b8a23c567d24..bbad0e85851d 100644 --- a/openedx/core/djangoapps/safe_sessions/tests/test_safe_cookie_data.py +++ b/openedx/core/djangoapps/safe_sessions/tests/test_safe_cookie_data.py @@ -234,5 +234,5 @@ def test_pinned_values(self): "|HvGnjXf1b3jU" "|ImExZWZiNzVlZGFmM2FkZWZmYjM4YjI0ZmZkOWU4MzExODU0MTk4NmVlNGRiYzBlODdhYWUzOGM5MzVlNzk4NjUi" ":1m6Hve" - ":OMhY2FL2pudJjSSXChtI-zR8QVA" + ":Pra4iochviPvKUoIV33gdVZFDgG-cMDlIYfl8iFIMaY" )