From 51a254a45c0bf7ee88cb828dbef9a7124a63fe3b Mon Sep 17 00:00:00 2001 From: Muhammad Adeel Tajamul <77053848+muhammadadeeltajamul@users.noreply.github.com> Date: Mon, 15 Sep 2025 13:45:46 +0500 Subject: [PATCH] feat: added feature to rate limit secondary email change (#37356) --- .../user_api/accounts/tests/test_views.py | 31 +++++++++++++++++++ .../djangoapps/user_api/accounts/views.py | 17 ++++++---- openedx/envs/common.py | 1 + 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_views.py b/openedx/core/djangoapps/user_api/accounts/tests/test_views.py index 4b5bc53895..46d6b5232b 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_views.py @@ -1110,6 +1110,37 @@ class TestAccountsAPI(FilteredQueryCountMixin, CacheIsolationTestCase, UserAPITe field_errors['email']['developer_message'] assert 'Valid e-mail address required.' == field_errors['email']['user_message'] + @override_settings(SECONDARY_EMAIL_RATE_LIMIT='1/m') + def test_patch_secondary_email_ratelimit(self): + """ + Tests if rate limit is applied on secondary_email patch + """ + client = self.login_client("client", "user") + self.send_patch(client, {"secondary_email": "new_email_01@example.com"}, + expected_status=status.HTTP_200_OK) + self.send_patch(client, {"secondary_email": "new_email_02@example.com"}, + expected_status=status.HTTP_429_TOO_MANY_REQUESTS) + + @override_settings(SECONDARY_EMAIL_RATE_LIMIT='') + def test_ratelimit_is_disabled_on_secondary_email_patch_if_settings_is_empty(self): + """ + Tests rate limit doesn't applied on secondary_email patch if SECONDARY_EMAIL_RATE_LIMIT is empty string or None + """ + client = self.login_client("client", "user") + self.send_patch(client, {"secondary_email": "email_new_01@example.com"}, + expected_status=status.HTTP_200_OK) + self.send_patch(client, {"secondary_email": "email_new_02@example.com"}, + expected_status=status.HTTP_200_OK) + + @override_settings(SECONDARY_EMAIL_RATE_LIMIT='1/d') + def test_ratelimit_is_only_on_secondary_email_change(self): + """ + Tests if rate limit is only applied for secondary_email attribute i.e. when user changes recovery email + """ + client = self.login_client("client", "user") + for i in range(5): + self.send_patch(client, {"name": f"new_name_{i}"}, expected_status=status.HTTP_200_OK) + @mock.patch('common.djangoapps.student.views.management.do_email_change_request') def test_patch_duplicate_email(self, do_email_change_request): """ diff --git a/openedx/core/djangoapps/user_api/accounts/views.py b/openedx/core/djangoapps/user_api/accounts/views.py index 5aab757a49..0464187b5d 100644 --- a/openedx/core/djangoapps/user_api/accounts/views.py +++ b/openedx/core/djangoapps/user_api/accounts/views.py @@ -396,12 +396,17 @@ class AccountViewSet(ViewSet): """ if request.content_type != MergePatchParser.media_type: raise UnsupportedMediaType(request.content_type) - if request.data.get("email") and settings.EMAIL_CHANGE_RATE_LIMIT: - if is_ratelimited( - request=request, group="email_change_rate_limit", key="user", - rate=settings.EMAIL_CHANGE_RATE_LIMIT, increment=True, - ): - return Response({"error": "Too many requests"}, status=status.HTTP_429_TOO_MANY_REQUESTS) + + for key, limit in [ + ('email', settings.EMAIL_CHANGE_RATE_LIMIT), + ('secondary_email', settings.SECONDARY_EMAIL_RATE_LIMIT) + ]: + if request.data.get(key) and limit: + if is_ratelimited( + request=request, group=f"{key}_change_rate_limit", key="user", + rate=limit, increment=True, + ): + return Response({"error": "Too many requests"}, status=status.HTTP_429_TOO_MANY_REQUESTS) try: with transaction.atomic(): diff --git a/openedx/envs/common.py b/openedx/envs/common.py index 151e5f6af5..c7b387dbeb 100644 --- a/openedx/envs/common.py +++ b/openedx/envs/common.py @@ -830,6 +830,7 @@ SKIP_RATE_LIMIT_ON_ACCOUNT_AFTER_DAYS = 0 ONE_CLICK_UNSUBSCRIBE_RATE_LIMIT = '100/m' EMAIL_CHANGE_RATE_LIMIT = '' +SECONDARY_EMAIL_RATE_LIMIT = '' LMS_ROOT_URL = None LMS_INTERNAL_ROOT_URL = Derived(lambda settings: settings.LMS_ROOT_URL)