Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions openedx/core/djangoapps/user_authn/toggles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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__
)
15 changes: 13 additions & 2 deletions openedx/core/djangoapps/user_authn/views/password_reset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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:
Expand Down
116 changes: 101 additions & 15 deletions openedx/core/djangoapps/user_authn/views/tests/test_reset_password.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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