diff --git a/openedx/core/djangoapps/user_authn/urls_common.py b/openedx/core/djangoapps/user_authn/urls_common.py index 826c937916..2edf28f24b 100644 --- a/openedx/core/djangoapps/user_authn/urls_common.py +++ b/openedx/core/djangoapps/user_authn/urls_common.py @@ -63,9 +63,17 @@ urlpatterns = [ name='password_reset_confirm', ), url(r'^account/password$', password_reset.password_change_request_handler, name='password_change_request'), + + # logistration MFE flow url(r'^user_api/v1/account/password_reset/token/validate/$', password_reset.password_reset_token_validate, name="user_api_password_reset_token_validate"), + # logistration MFE reset flow + url( + r'^password/reset/(?P[0-9A-Za-z]+)-(?P.+)/$', + password_reset.password_reset_logistration, + name='logistration_password_reset', + ), ] # password reset django views (see above for password reset views) diff --git a/openedx/core/djangoapps/user_authn/views/password_reset.py b/openedx/core/djangoapps/user_authn/views/password_reset.py index 752de48807..22af6e81f7 100644 --- a/openedx/core/djangoapps/user_authn/views/password_reset.py +++ b/openedx/core/djangoapps/user_authn/views/password_reset.py @@ -12,7 +12,7 @@ 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 from django.core.validators import ValidationError -from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseRedirect +from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseRedirect from django.template.response import TemplateResponse from django.urls import reverse from django.utils.decorators import method_decorator @@ -651,6 +651,9 @@ def password_reset_token_validate(request): return JsonResponse({'is_valid': is_valid}) user = User.objects.get(id=uid_int) + if UserRetirementRequest.has_user_requested_retirement(user): + return JsonResponse({'is_valid': is_valid}) + is_valid = default_token_generator.check_token(user, token[1]) if is_valid and not user.is_active: user.is_active = True @@ -659,3 +662,63 @@ def password_reset_token_validate(request): AUDIT_LOG.exception("Invalid password reset confirm token") return JsonResponse({'is_valid': is_valid}) + + +def _check_token_has_required_values(uidb36, token): + """ + Helper function to test that token + string passed has the required kwargs needed + to process token validation. + """ + + if not uidb36 or not token: + return False, None + try: + uid_int = base36_to_int(uidb36) + except ValueError: + return False, None + return True, uid_int + + +@require_POST +@ensure_csrf_cookie +def password_reset_logistration(request, **kwargs): + """Reset learner password using passed token and new credentials""" + + reset_status = False + uidb36 = kwargs.get('uidb36') + token = kwargs.get('token') + + has_required_values, uid_int = _check_token_has_required_values(uidb36, token) + if not has_required_values: + AUDIT_LOG.exception("Invalid password reset confirm token") + return JsonResponse({'reset_status': reset_status}) + + request.POST = request.POST.copy() + request.POST['new_password1'] = normalize_password(request.POST['new_password1']) + request.POST['new_password2'] = normalize_password(request.POST['new_password2']) + + password = request.POST['new_password1'] + try: + user = User.objects.get(id=uid_int) + if not default_token_generator.check_token(user, token): + AUDIT_LOG.exception("Token validation failed") + return JsonResponse({'reset_status': reset_status}) + + validate_password(password, user=user) + form = SetPasswordForm(user, request.POST) + if form.is_valid(): + form.save() + reset_status = True + + except ValidationError as err: + AUDIT_LOG.exception("Password validation failed") + error_status = { + 'reset_status': reset_status, + 'err_msg': ' '.join(err.messages) + } + return JsonResponse(error_status) + except Exception: # pylint: disable=broad-except + AUDIT_LOG.exception("Setting new password failed") + + return JsonResponse({'reset_status': reset_status}) 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 e318c704dd..d0547dbc92 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 @@ -35,7 +35,7 @@ from openedx.core.djangoapps.user_api.models import UserRetirementRequest from openedx.core.djangoapps.user_api.tests.test_views import UserAPITestCase from openedx.core.djangoapps.user_api.accounts import EMAIL_MAX_LENGTH, EMAIL_MIN_LENGTH from openedx.core.djangoapps.user_authn.views.password_reset import ( - SETTING_CHANGE_INITIATED, password_reset, + SETTING_CHANGE_INITIATED, password_reset, password_reset_logistration, PasswordResetConfirmWrapper) from openedx.core.djangolib.testing.utils import CacheIsolationTestCase from student.tests.factories import TEST_PASSWORD, UserFactory @@ -713,3 +713,72 @@ class PasswordResetTokenValidateViewTest(UserAPITestCase): self.user = User.objects.get(pk=self.user.pk) self.assertFalse(self.user.is_active) + + +@ddt.ddt +@unittest.skipUnless( + settings.ROOT_URLCONF == "lms.urls", + "reset password tests should only run in LMS" +) +class ResetPasswordAPITests(CacheIsolationTestCase): + """Tests of the logistration API's password reset endpoint. """ + request_factory = RequestFactory() + ENABLED_CACHES = ['default'] + + def setUp(self): + super(ResetPasswordAPITests, self).setUp() + self.user = UserFactory.create() + self.user.save() + self.token = default_token_generator.make_token(self.user) + self.uidb36 = int_to_base36(self.user.id) + + def create_reset_request(self, uidb36, token, new_password2='new_password1'): + """Helper to create reset password post request""" + + request_param = {'new_password1': 'new_password1', 'new_password2': new_password2} + post_request = self.request_factory.post( + reverse( + "logistration_password_reset", + kwargs={"uidb36": uidb36, "token": token} + ), + request_param + ) + return post_request + + @ddt.data( + (None, None, True), + (None, 'invalid_token', False), + ) + @ddt.unpack + def test_password_reset_request(self, uidb36, token, status): + """Tests password reset request with valid/invalid token""" + + uidb36 = uidb36 or self.uidb36 + token = token or self.token + + post_request = self.create_reset_request(uidb36, token) + post_request.user = AnonymousUser() + json_response = password_reset_logistration(post_request, uidb36=uidb36, token=token) + json_response = json.loads(json_response.content.decode('utf-8')) + self.assertEqual(json_response.get('reset_status'), status) + + def test_none_token_in_password_reset_request(self): + """ + Test that user should not be able to reset password through no token/uidb36 + """ + uidb36 = None + token = None + + post_request = self.create_reset_request(self.uidb36, self.token) + post_request.user = AnonymousUser() + self.assertRaises(Exception, password_reset_logistration(post_request, uidb36=uidb36, token=token)) + + def test_password_mismatch_in_reset_request(self): + """ + Test that user should not be able to reset password with password mismatch + """ + post_request = self.create_reset_request(self.uidb36, self.token, 'new_password2') + post_request.user = AnonymousUser() + json_response = password_reset_logistration(post_request, uidb36=self.uidb36, token=self.token) + json_response = json.loads(json_response.content.decode('utf-8')) + self.assertFalse(json_response.get('reset_status'))