From 141ed804a121f3880bc75683e5ee475c899bd19f Mon Sep 17 00:00:00 2001 From: nakhan-sonata-afk Date: Tue, 16 Jun 2026 01:58:02 +0530 Subject: [PATCH] =?UTF-8?q?Merge=20pull=20request=20#346=20from=20edx/AUT-?= =?UTF-8?q?139-Bug-EDX-Stage-Prod=E2=80=93Reset-Password-Accepts-Old-Passw?= =?UTF-8?q?ord-and-Sends-Success-Email?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix: enforce new password must differ from current on reset (cherry picked from commit 101260d030e919f3470a333c911767c24982d684) --- openedx/core/djangoapps/user_authn/toggles.py | 17 +++ .../user_authn/views/password_reset.py | 15 ++- .../views/tests/test_reset_password.py | 116 +++++++++++++++--- 3 files changed, 131 insertions(+), 17 deletions(-) diff --git a/openedx/core/djangoapps/user_authn/toggles.py b/openedx/core/djangoapps/user_authn/toggles.py index ffa50b49fe92..2eb168f47163 100644 --- a/openedx/core/djangoapps/user_authn/toggles.py +++ b/openedx/core/djangoapps/user_authn/toggles.py @@ -7,6 +7,7 @@ from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.theming.helpers import get_current_request +from edx_toggles.toggles import WaffleFlag def is_require_third_party_auth_enabled(): @@ -33,3 +34,19 @@ def is_auto_generated_username_enabled(): return configuration_helpers.get_value( 'ENABLE_AUTO_GENERATED_USERNAME', getattr(settings, 'ENABLE_AUTO_GENERATED_USERNAME', False) ) + + +# .. toggle_name: user_authn.prevent_password_reuse_on_reset +# .. toggle_implementation: WaffleFlag +# .. toggle_default: False +# .. toggle_description: When enabled, prevents users from resetting their password +# to the same value as their current password. +# .. toggle_use_cases: temporary +# .. toggle_creation_date: 2026-06-15 +# .. toggle_target_removal_date: 2026-08-15 +# .. toggle_tickets: https://2u-internal.atlassian.net/browse/AUT-139 + + +PREVENT_PASSWORD_REUSE_ON_RESET = WaffleFlag( + 'user_authn.prevent_password_reuse_on_reset', __name__ +) diff --git a/openedx/core/djangoapps/user_authn/views/password_reset.py b/openedx/core/djangoapps/user_authn/views/password_reset.py index b2a086c113b0..8af96f758ea0 100644 --- a/openedx/core/djangoapps/user_authn/views/password_reset.py +++ b/openedx/core/djangoapps/user_authn/views/password_reset.py @@ -6,8 +6,8 @@ from django.contrib import messages from django.contrib.auth import update_session_auth_hash from django.contrib.auth.forms import PasswordResetForm, SetPasswordForm -from django.contrib.auth.hashers import UNUSABLE_PASSWORD_PREFIX -from django.contrib.auth.models import User # pylint: disable=imported-auth-user +from django.contrib.auth.hashers import UNUSABLE_PASSWORD_PREFIX, check_password +from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.contrib.auth.tokens import default_token_generator from django.contrib.auth.views import INTERNAL_RESET_SESSION_TOKEN, PasswordResetConfirmView from django.core.exceptions import ObjectDoesNotExist @@ -48,6 +48,11 @@ from openedx.core.djangoapps.user_authn.toggles import should_redirect_to_authn_microfrontend from openedx.core.djangoapps.user_authn.utils import check_pwned_password from openedx.core.djangolib.markup import HTML +from common.djangoapps.student.forms import send_account_recovery_email_for_user +from common.djangoapps.student.models import AccountRecovery, LoginFailures +from common.djangoapps.util.json_request import JsonResponse +from common.djangoapps.util.password_policy_validators import normalize_password, validate_password +from openedx.core.djangoapps.user_authn.toggles import PREVENT_PASSWORD_REUSE_ON_RESET POST_EMAIL_KEY = 'openedx.core.djangoapps.util.ratelimit.request_post_email' REAL_IP_KEY = 'openedx.core.djangoapps.util.ratelimit.real_ip' @@ -766,6 +771,12 @@ def post(self, request, **kwargs): AUDIT_LOG.exception(f"Token validation failed for user {user_id}") return Response({'reset_status': reset_status, 'token_invalid': True}) + if PREVENT_PASSWORD_REUSE_ON_RESET.is_enabled() and check_password(password, user.password): + return Response({ + 'reset_status': reset_status, + 'err_msg': _('Your new password must be different from your current password.') + }) + validate_password(password, user=user) if settings.ENABLE_AUTHN_RESET_PASSWORD_HIBP_POLICY: diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_reset_password.py b/openedx/core/djangoapps/user_authn/views/tests/test_reset_password.py index e826aee0e11e..493d9da55e43 100644 --- a/openedx/core/djangoapps/user_authn/views/tests/test_reset_password.py +++ b/openedx/core/djangoapps/user_authn/views/tests/test_reset_password.py @@ -32,21 +32,13 @@ from common.djangoapps.student.tests.test_email import mock_render_to_string from common.djangoapps.util.password_policy_validators import create_validator_config from common.djangoapps.util.testing import EventTestMixin -from openedx.core.djangoapps.oauth_dispatch.tests import factories as dot_factories -from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers -from openedx.core.djangoapps.user_api.accounts import EMAIL_MAX_LENGTH, EMAIL_MIN_LENGTH -from openedx.core.djangoapps.user_api.accounts.utils import create_retirement_request_and_deactivate_account -from openedx.core.djangoapps.user_api.models import RetirementState -from openedx.core.djangoapps.user_api.tests.test_views import UserAPITestCase -from openedx.core.djangoapps.user_authn.views.password_reset import ( - PASSWORD_RESET_INITIATED, - SETTING_CHANGE_INITIATED, - LogistrationPasswordResetView, - PasswordResetConfirmWrapper, - password_change_request_handler, - password_reset, -) -from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms +from django.utils.translation import gettext as _ + +from edx_toggles.toggles.testutils import override_waffle_flag +from openedx.core.djangoapps.user_authn.toggles import PREVENT_PASSWORD_REUSE_ON_RESET + +ENABLE_AUTHN_MICROFRONTEND = settings.FEATURES.copy() +ENABLE_AUTHN_MICROFRONTEND['ENABLE_AUTHN_MICROFRONTEND'] = True def process_request(request): @@ -1062,3 +1054,97 @@ def test_password_reset_request_with_login_failures_feature_disabled(self): # Verify that the user's login failures lockout count is not reset. assert not LoginFailures.is_feature_enabled() assert LoginFailures.is_user_locked_out(self.user) + + @override_waffle_flag(PREVENT_PASSWORD_REUSE_ON_RESET, active=True) + def test_password_reset_with_same_current_password(self): + """ + Test that user cannot reset password to their current password + when waffle flag is enabled. + """ + current_password = 'CurrentPass@123' + self.user.set_password(current_password) + self.user.save() + original_password_hash = self.user.password + + token = default_token_generator.make_token(self.user) + uidb36 = int_to_base36(self.user.id) + + request_param = {'new_password1': current_password, 'new_password2': current_password} + post_request = self.request_factory.post( + reverse( + "logistration_password_reset", + kwargs={"uidb36": uidb36, "token": token} + ) + "?track=pwreset", + request_param, format='json' + ) + post_request.user = AnonymousUser() + reset_view = LogistrationPasswordResetView.as_view() + response = reset_view(post_request, uidb36=uidb36, token=token) + assert response.status_code == 200 + response.render() + json_response = json.loads(response.content.decode('utf-8')) + + expected_msg = _('Your new password must be different from your current password.') + assert json_response.get('reset_status') is False + assert expected_msg in json_response.get('err_msg', '') + refreshed_user = User.objects.get(id=self.user.id) + assert refreshed_user.password == original_password_hash + + @override_waffle_flag(PREVENT_PASSWORD_REUSE_ON_RESET, active=False) + def test_same_password_allowed_when_flag_disabled(self): + """ + Test that user CAN reset password to same password + when waffle flag is disabled (preserves old behavior). + """ + current_password = 'CurrentPass@123' + self.user.set_password(current_password) + self.user.save() + + token = default_token_generator.make_token(self.user) + uidb36 = int_to_base36(self.user.id) + + request_param = {'new_password1': current_password, 'new_password2': current_password} + post_request = self.request_factory.post( + reverse( + "logistration_password_reset", + kwargs={"uidb36": uidb36, "token": token} + ) + "?track=pwreset", + request_param, format='json' + ) + post_request.user = AnonymousUser() + reset_view = LogistrationPasswordResetView.as_view() + response = reset_view(post_request, uidb36=uidb36, token=token) + assert response.status_code == 200 + response.render() + json_response = json.loads(response.content.decode('utf-8')) + + assert json_response.get('reset_status') is True + + @override_waffle_flag(PREVENT_PASSWORD_REUSE_ON_RESET, active=True) + def test_different_password_works_when_flag_enabled(self): + """ + Test that user CAN reset to a different password + when waffle flag is enabled. + """ + self.user.set_password('OldPassword@123') + self.user.save() + + token = default_token_generator.make_token(self.user) + uidb36 = int_to_base36(self.user.id) + + request_param = {'new_password1': 'NewPassword@456', 'new_password2': 'NewPassword@456'} + post_request = self.request_factory.post( + reverse( + "logistration_password_reset", + kwargs={"uidb36": uidb36, "token": token} + ) + "?track=pwreset", + request_param, format='json' + ) + post_request.user = AnonymousUser() + reset_view = LogistrationPasswordResetView.as_view() + response = reset_view(post_request, uidb36=uidb36, token=token) + assert response.status_code == 200 + response.render() + json_response = json.loads(response.content.decode('utf-8')) + + assert json_response.get('reset_status') is True