From f8c8bf36a606cbc4f89759c09f14a07d7aedddfa Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Fri, 22 Nov 2019 11:53:40 -0500 Subject: [PATCH] Move password reset logic from student to user_authn. --- common/djangoapps/student/urls.py | 20 - common/djangoapps/student/views/management.py | 327 ---------------- .../courseware/tests/test_password_reset.py | 115 ------ .../core/djangoapps/user_authn/urls_common.py | 18 + .../user_authn/views/password_reset.py | 355 +++++++++++++++++- .../user_authn/views/tests/test_password.py | 2 +- .../views/tests/test_reset_password.py | 24 +- 7 files changed, 388 insertions(+), 473 deletions(-) delete mode 100644 lms/djangoapps/courseware/tests/test_password_reset.py diff --git a/common/djangoapps/student/urls.py b/common/djangoapps/student/urls.py index c27030c5a1..1ba569308f 100644 --- a/common/djangoapps/student/urls.py +++ b/common/djangoapps/student/urls.py @@ -6,7 +6,6 @@ from __future__ import absolute_import from django.conf import settings from django.conf.urls import url -from django.contrib.auth.views import password_reset_complete from . import views @@ -22,15 +21,6 @@ urlpatterns = [ url(r'^change_setting$', views.change_setting, name='change_setting'), url(r'^change_email_settings$', views.change_email_settings, name='change_email_settings'), - # password reset in views (see below for password reset django views) - url(r'^account/password$', views.password_change_request_handler, name='password_change_request'), - url(r'^password_reset/$', views.password_reset, name='password_reset'), - url( - r'^password_reset_confirm/(?P[0-9A-Za-z]+)-(?P.+)/$', - views.password_reset_confirm_wrapper, - name='password_reset_confirm', - ), - url(r'^course_run/{}/refund_status$'.format(settings.COURSE_ID_PATTERN), views.course_run_refund_status, name="course_run_refund_status"), @@ -42,13 +32,3 @@ urlpatterns = [ ), ] - -# password reset django views (see above for password reset views) -urlpatterns += [ - # TODO: Replace with Mako-ized views - url( - r'^password_reset_complete/$', - password_reset_complete, - name='password_reset_complete', - ), -] diff --git a/common/djangoapps/student/views/management.py b/common/djangoapps/student/views/management.py index eb2f339d33..7e2905a6e5 100644 --- a/common/djangoapps/student/views/management.py +++ b/common/djangoapps/student/views/management.py @@ -60,7 +60,6 @@ from openedx.core.djangoapps.theming import helpers as theming_helpers from openedx.core.djangoapps.theming.helpers import get_current_site from openedx.core.djangoapps.user_api.accounts.utils import is_secondary_email_feature_enabled from openedx.core.djangoapps.user_api.config.waffle import PREVENT_AUTH_USER_WRITES, SYSTEM_MAINTENANCE_MSG, waffle -from openedx.core.djangoapps.user_api.errors import UserAPIInternalError, UserNotFound from openedx.core.djangoapps.user_api.models import UserRetirementRequest from openedx.core.djangoapps.user_api.preferences import api as preferences_api from openedx.core.djangoapps.user_authn.message_types import PasswordReset @@ -635,332 +634,6 @@ def activate_account_studio(request, key): ) -@require_http_methods(['POST']) -def password_change_request_handler(request): - """Handle password change requests originating from the account page. - - Uses the Account API to email the user a link to the password reset page. - - Note: - The next step in the password reset process (confirmation) is currently handled - by student.views.password_reset_confirm_wrapper, a custom wrapper around Django's - password reset confirmation view. - - Args: - request (HttpRequest) - - Returns: - HttpResponse: 200 if the email was sent successfully - HttpResponse: 400 if there is no 'email' POST parameter - HttpResponse: 403 if the client has been rate limited - HttpResponse: 405 if using an unsupported HTTP method - - Example usage: - - POST /account/password - - """ - - password_reset_email_limiter = PasswordResetEmailRateLimiter() - - if password_reset_email_limiter.is_rate_limit_exceeded(request): - AUDIT_LOG.warning("Password reset rate limit exceeded") - return HttpResponse( - _("Your previous request is in progress, please try again in a few moments."), - status=403 - ) - - user = request.user - # Prefer logged-in user's email - email = user.email if user.is_authenticated else request.POST.get('email') - - if email: - try: - from openedx.core.djangoapps.user_authn.views.password_reset import request_password_change - request_password_change(email, request.is_secure()) - user = user if user.is_authenticated else get_user_from_email(email=email) - destroy_oauth_tokens(user) - except UserNotFound: - AUDIT_LOG.info("Invalid password reset attempt") - # If enabled, send an email saying that a password reset was attempted, but that there is - # no user associated with the email - if configuration_helpers.get_value('ENABLE_PASSWORD_RESET_FAILURE_EMAIL', - settings.FEATURES['ENABLE_PASSWORD_RESET_FAILURE_EMAIL']): - site = get_current_site() - message_context = get_base_template_context(site) - - message_context.update({ - 'failed': True, - 'request': request, # Used by google_analytics_tracking_pixel - 'email_address': email, - }) - - msg = PasswordReset().personalize( - recipient=Recipient(username='', email_address=email), - language=settings.LANGUAGE_CODE, - user_context=message_context, - ) - ace.send(msg) - except UserAPIInternalError as err: - log.exception('Error occured during password change for user {email}: {error}' - .format(email=email, error=err)) - return HttpResponse(_("Some error occured during password change. Please try again"), status=500) - - password_reset_email_limiter.tick_request_counter(request) - return HttpResponse(status=200) - else: - return HttpResponseBadRequest(_("No email address provided.")) - - -def get_user_from_email(email): - """ - Find a user using given email and return it. - - Arguments: - email (str): primary or secondary email address of the user. - - Raises: - (User.ObjectNotFound): If no user is found with the given email. - (User.MultipleObjectsReturned): If more than one user is found with the given email. - - Returns: - User: Django user object. - """ - try: - return User.objects.get(email=email) - except ObjectDoesNotExist: - return User.objects.filter( - id__in=AccountRecovery.objects.filter(secondary_email__iexact=email, is_active=True).values_list('user') - ).get() - - -@csrf_exempt -@require_POST -def password_reset(request): - """ - Attempts to send a password reset e-mail. - """ - # Add some rate limiting here by re-using the RateLimitMixin as a helper class - limiter = BadRequestRateLimiter() - if limiter.is_rate_limit_exceeded(request): - AUDIT_LOG.warning("Rate limit exceeded in password_reset") - return HttpResponseForbidden() - - from openedx.core.djangoapps.user_authn.views.password_reset import PasswordResetFormNoActive - form = PasswordResetFormNoActive(request.POST) - if form.is_valid(): - form.save(use_https=request.is_secure(), - from_email=configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL), - request=request) - # When password change is complete, a "edx.user.settings.changed" event will be emitted. - # But because changing the password is multi-step, we also emit an event here so that we can - # track where the request was initiated. - tracker.emit( - SETTING_CHANGE_INITIATED, - { - "setting": "password", - "old": None, - "new": None, - "user_id": request.user.id, - } - ) - destroy_oauth_tokens(request.user) - else: - # bad user? tick the rate limiter counter - AUDIT_LOG.info("Bad password_reset user passed in.") - limiter.tick_request_counter(request) - - return JsonResponse({ - 'success': True, - 'value': render_to_string('registration/password_reset_done.html', {}), - }) - - -def uidb36_to_uidb64(uidb36): - """ - Needed to support old password reset URLs that use base36-encoded user IDs - https://github.com/django/django/commit/1184d077893ff1bc947e45b00a4d565f3df81776#diff-c571286052438b2e3190f8db8331a92bR231 - Args: - uidb36: base36-encoded user ID - - Returns: base64-encoded user ID. Otherwise returns a dummy, invalid ID - """ - try: - uidb64 = force_text(urlsafe_base64_encode(force_bytes(base36_to_int(uidb36)))) - except ValueError: - uidb64 = '1' # dummy invalid ID (incorrect padding for base64) - return uidb64 - - -def password_reset_confirm_wrapper(request, uidb36=None, token=None): - """ - A wrapper around django.contrib.auth.views.password_reset_confirm. - Needed because we want to set the user as active at this step. - We also optionally do some additional password policy checks. - """ - # convert old-style base36-encoded user id to base64 - uidb64 = uidb36_to_uidb64(uidb36) - platform_name = { - "platform_name": configuration_helpers.get_value('platform_name', settings.PLATFORM_NAME) - } - - # User can not get this link unless account recovery feature is enabled. - if 'is_account_recovery' in request.GET and not is_secondary_email_feature_enabled(): - raise Http404 - - try: - uid_int = base36_to_int(uidb36) - if request.user.is_authenticated and request.user.id != uid_int: - raise Http404 - - user = User.objects.get(id=uid_int) - except (ValueError, User.DoesNotExist): - # if there's any error getting a user, just let django's - # password_reset_confirm function handle it. - return password_reset_confirm( - request, uidb64=uidb64, token=token, extra_context=platform_name - ) - - if UserRetirementRequest.has_user_requested_retirement(user): - # Refuse to reset the password of any user that has requested retirement. - context = { - 'validlink': True, - 'form': None, - 'title': _('Password reset unsuccessful'), - 'err_msg': _('Error in resetting your password.'), - } - context.update(platform_name) - return TemplateResponse( - request, 'registration/password_reset_confirm.html', context - ) - - if waffle().is_enabled(PREVENT_AUTH_USER_WRITES): - context = { - 'validlink': False, - 'form': None, - 'title': _('Password reset unsuccessful'), - 'err_msg': SYSTEM_MAINTENANCE_MSG, - } - context.update(platform_name) - return TemplateResponse( - request, 'registration/password_reset_confirm.html', context - ) - - if request.method == 'POST': - # We have to make a copy of request.POST because it is a QueryDict object which is immutable until copied. - # We have to use request.POST because the password_reset_confirm method takes in the request and a user's - # password is set to the request.POST['new_password1'] field. We have to also normalize the new_password2 - # field so it passes the equivalence check that new_password1 == new_password2 - # In order to switch out of having to do this copy, we would want to move the normalize_password code into - # a custom User model's set_password method to ensure it is always happening upon calling set_password. - 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: - validate_password(password, user=user) - except ValidationError as err: - # We have a password reset attempt which violates some security - # policy, or any other validation. Use the existing Django template to communicate that - # back to the user. - context = { - 'validlink': True, - 'form': None, - 'title': _('Password reset unsuccessful'), - 'err_msg': ' '.join(err.messages), - } - context.update(platform_name) - return TemplateResponse( - request, 'registration/password_reset_confirm.html', context - ) - - # remember what the old password hash is before we call down - old_password_hash = user.password - - if 'is_account_recovery' in request.GET: - response = password_reset_confirm( - request, - uidb64=uidb64, - token=token, - extra_context=platform_name, - template_name='registration/password_reset_confirm.html', - post_reset_redirect='signin_user', - ) - else: - response = password_reset_confirm( - request, uidb64=uidb64, token=token, extra_context=platform_name - ) - - # If password reset was unsuccessful a template response is returned (status_code 200). - # Check if form is invalid then show an error to the user. - # Note if password reset was successful we get response redirect (status_code 302). - if response.status_code == 200: - form_valid = response.context_data['form'].is_valid() if response.context_data['form'] else False - if not form_valid: - log.warning( - u'Unable to reset password for user [%s] because form is not valid. ' - u'A possible cause is that the user had an invalid reset token', - user.username, - ) - response.context_data['err_msg'] = _('Error in resetting your password. Please try again.') - return response - - # get the updated user - updated_user = User.objects.get(id=uid_int) - if 'is_account_recovery' in request.GET: - try: - updated_user.email = updated_user.account_recovery.secondary_email - updated_user.account_recovery.delete() - # emit an event that the user changed their secondary email to the primary email - tracker.emit( - SETTING_CHANGE_INITIATED, - { - "setting": "email", - "old": user.email, - "new": updated_user.email, - "user_id": updated_user.id, - } - ) - except ObjectDoesNotExist: - log.error( - 'Account recovery process initiated without AccountRecovery instance for user {username}'.format( - username=updated_user.username - ) - ) - - updated_user.save() - - if response.status_code == 302 and 'is_account_recovery' in request.GET: - messages.success( - request, - HTML(_( - '{html_start}Password Creation Complete{html_end}' - 'Your password has been created. {bold_start}{email}{bold_end} is now your primary login email.' - )).format( - support_url=configuration_helpers.get_value('SUPPORT_SITE_LINK', settings.SUPPORT_SITE_LINK), - html_start=HTML('

'), - html_end=HTML('

'), - bold_start=HTML(''), - bold_end=HTML(''), - email=updated_user.email, - ), - extra_tags='account-recovery aa-icon submission-success' - ) - else: - response = password_reset_confirm( - request, uidb64=uidb64, token=token, extra_context=platform_name - ) - - response_was_successful = response.context_data.get('validlink') - if response_was_successful and not user.is_active: - user.is_active = True - user.save() - - return response - - def validate_new_email(user, new_email): """ Given a new email for a user, does some basic verification of the new address If any issues are encountered diff --git a/lms/djangoapps/courseware/tests/test_password_reset.py b/lms/djangoapps/courseware/tests/test_password_reset.py deleted file mode 100644 index 863cbe0728..0000000000 --- a/lms/djangoapps/courseware/tests/test_password_reset.py +++ /dev/null @@ -1,115 +0,0 @@ -""" -This file will test through the LMS some of the password reset features -""" -from __future__ import absolute_import - -from uuid import uuid4 - -import ddt -from django.contrib.auth.models import User -from django.contrib.auth.tokens import default_token_generator -from django.test.utils import override_settings -from django.utils.http import int_to_base36 - -from lms.djangoapps.courseware.tests.helpers import LoginEnrollmentTestCase -from util.password_policy_validators import create_validator_config - - -@ddt.ddt -class TestPasswordReset(LoginEnrollmentTestCase): - """ - Go through some of the password reset use cases - """ - - def _setup_user(self, is_staff=False, password=None): - """ - Override the base implementation to randomize the email - """ - email = 'foo_{0}@test.com'.format(uuid4().hex[:8]) - password = password if password else 'foo' - username = 'test_{0}'.format(uuid4().hex[:8]) - self.create_account(username, email, password) - self.activate_user(email) - - # manually twiddle the is_staff bit, if needed - if is_staff: - user = User.objects.get(email=email) - user.is_staff = True - user.save() - - return email, password - - def assertPasswordResetError(self, response, error_message, valid_link=True): - """ - This method is a custom assertion that verifies that a password reset - view returns an error response as expected. - Args: - response: response from calling a password reset endpoint - error_message: message we expect to see in the response - valid_link: if the current password reset link is still valid - - """ - self.assertEqual(response.status_code, 200) - self.assertEqual(response.context_data['validlink'], valid_link) - self.assertContains(response, error_message) - - @override_settings(AUTH_PASSWORD_VALIDATORS=[ - create_validator_config('util.password_policy_validators.MinimumLengthValidator', {'min_length': 6}) - ]) - def test_password_policy_on_password_reset(self): - """ - This makes sure the proper asserts on password policy also works on password reset - """ - staff_email, _ = self._setup_user(is_staff=True, password='foofoo') - - success_msg = 'Your Password Reset is Complete' - - # try to reset password, it should fail - user = User.objects.get(email=staff_email) - token = default_token_generator.make_token(user) - uidb36 = int_to_base36(user.id) - - # try to do a password reset with the same password as before - resp = self.client.post('/password_reset_confirm/{0}-{1}/'.format(uidb36, token), { - 'new_password1': 'foo', - 'new_password2': 'foo', - }, follow=True) - - self.assertNotContains(resp, success_msg) - - # try to reset password with a long enough password - user = User.objects.get(email=staff_email) - token = default_token_generator.make_token(user) - uidb36 = int_to_base36(user.id) - - # try to do a password reset with the same password as before - resp = self.client.post('/password_reset_confirm/{0}-{1}/'.format(uidb36, token), { - 'new_password1': 'foofoo', - 'new_password2': 'foofoo', - }, follow=True) - - self.assertContains(resp, success_msg) - - @ddt.data( - ('foo', 'foobar', 'Error in resetting your password. Please try again.'), - ('', '', 'This password is too short. It must contain at least'), - ) - @ddt.unpack - def test_password_reset_form_invalid(self, password1, password2, err_msg): - """ - Tests that password reset fail when providing bad passwords and error message is displayed - to the user. - """ - user_email, _ = self._setup_user() - - # try to reset password, it should fail - user = User.objects.get(email=user_email) - token = default_token_generator.make_token(user) - uidb36 = int_to_base36(user.id) - - # try to do a password reset with the same password as before - resp = self.client.post('/password_reset_confirm/{0}-{1}/'.format(uidb36, token), { - 'new_password1': password1, - 'new_password2': password2, - }, follow=True) - self.assertPasswordResetError(resp, err_msg) diff --git a/openedx/core/djangoapps/user_authn/urls_common.py b/openedx/core/djangoapps/user_authn/urls_common.py index 13aaf24991..942642d287 100644 --- a/openedx/core/djangoapps/user_authn/urls_common.py +++ b/openedx/core/djangoapps/user_authn/urls_common.py @@ -13,6 +13,7 @@ from __future__ import absolute_import from django.conf import settings from django.conf.urls import url +from django.contrib.auth.views import password_reset_complete from .views import auto_auth, login, logout, password_reset, register @@ -53,8 +54,25 @@ urlpatterns = [ url(r'^user_api/v1/account/password_reset/$', password_reset.PasswordResetView.as_view(), name="user_api_password_reset"), + # Password reset api views. + url(r'^password_reset/$', password_reset.password_reset, name='password_reset'), + url( + r'^password_reset_confirm/(?P[0-9A-Za-z]+)-(?P.+)/$', + password_reset.password_reset_confirm_wrapper, + name='password_reset_confirm', + ), + url(r'^account/password$', password_reset.password_change_request_handler, name='password_change_request'), + ] +# password reset django views (see above for password reset views) +urlpatterns += [ + url( + r'^password_reset_complete/$', + password_reset_complete, + name='password_reset_complete', + ), +] # enable automatic login if settings.FEATURES.get('AUTOMATIC_AUTH_FOR_TESTING'): diff --git a/openedx/core/djangoapps/user_authn/views/password_reset.py b/openedx/core/djangoapps/user_authn/views/password_reset.py index 663bd3a363..fdc8ed8cbf 100644 --- a/openedx/core/djangoapps/user_authn/views/password_reset.py +++ b/openedx/core/djangoapps/user_authn/views/password_reset.py @@ -1,37 +1,62 @@ -""" Handles password resets logic. """ +""" Password reset logic and views . """ +import logging from django import forms from django.contrib.auth.forms import PasswordResetForm from django.conf import settings +from django.contrib import messages from django.contrib.auth.hashers import UNUSABLE_PASSWORD_PREFIX from django.contrib.auth.models import User from django.contrib.auth.tokens import default_token_generator -from django.http import HttpResponse +from django.contrib.auth.views import password_reset_confirm +from django.core.exceptions import ObjectDoesNotExist +from django.core.validators import ValidationError +from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseForbidden +from django.template.response import TemplateResponse from django.utils.decorators import method_decorator -from django.utils.http import int_to_base36 +from django.utils.encoding import force_bytes, force_text +from django.utils.http import base36_to_int, int_to_base36, urlsafe_base64_encode from django.utils.translation import ugettext_lazy as _ from django.urls import reverse -from django.views.decorators.csrf import ensure_csrf_cookie +from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie +from django.views.decorators.http import require_POST from edx_ace import ace from edx_ace.recipient import Recipient +from edxmako.shortcuts import render_to_string +from eventtracking import tracker from rest_framework.views import APIView from openedx.core.djangoapps.ace_common.template_context import get_base_template_context from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY +from openedx.core.djangoapps.oauth_dispatch.api import destroy_oauth_tokens from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.theming.helpers import get_current_request, get_current_site from openedx.core.djangoapps.user_api import accounts, errors, helpers from openedx.core.djangoapps.user_api.accounts.utils import is_secondary_email_feature_enabled from openedx.core.djangoapps.user_api.helpers import FormDescription +from openedx.core.djangoapps.user_api.models import UserRetirementRequest from openedx.core.djangoapps.user_api.preferences.api import get_user_preference +from openedx.core.djangoapps.user_api.config.waffle import PREVENT_AUTH_USER_WRITES, SYSTEM_MAINTENANCE_MSG, waffle from openedx.core.djangoapps.user_authn.message_types import PasswordReset +from openedx.core.djangolib.markup import HTML from student.models import AccountRecovery from student.forms import send_account_recovery_email_for_user +from util.json_request import JsonResponse +from util.password_policy_validators import normalize_password, validate_password +from util.request_rate_limiter import BadRequestRateLimiter, PasswordResetEmailRateLimiter + + +SETTING_CHANGE_INITIATED = 'edx.user.settings.change_initiated' + +# Maintaining this naming for backwards compatibility. +log = logging.getLogger("edx.student") +AUDIT_LOG = logging.getLogger("audit") + def get_password_reset_form(): """Return a description of the password reset form. @@ -215,3 +240,325 @@ def request_password_change(email, is_secure): else: # No user with the provided email address exists. raise errors.UserNotFound + + +@csrf_exempt +@require_POST +def password_reset(request): + """ + Attempts to send a password reset e-mail. + """ + # Add some rate limiting here by re-using the RateLimitMixin as a helper class + limiter = BadRequestRateLimiter() + if limiter.is_rate_limit_exceeded(request): + AUDIT_LOG.warning("Rate limit exceeded in password_reset") + return HttpResponseForbidden() + + form = PasswordResetFormNoActive(request.POST) + if form.is_valid(): + form.save(use_https=request.is_secure(), + from_email=configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL), + request=request) + # When password change is complete, a "edx.user.settings.changed" event will be emitted. + # But because changing the password is multi-step, we also emit an event here so that we can + # track where the request was initiated. + tracker.emit( + SETTING_CHANGE_INITIATED, + { + "setting": "password", + "old": None, + "new": None, + "user_id": request.user.id, + } + ) + destroy_oauth_tokens(request.user) + else: + # bad user? tick the rate limiter counter + AUDIT_LOG.info("Bad password_reset user passed in.") + limiter.tick_request_counter(request) + + return JsonResponse({ + 'success': True, + 'value': render_to_string('registration/password_reset_done.html', {}), + }) + + +def _uidb36_to_uidb64(uidb36): + """ + Needed to support old password reset URLs that use base36-encoded user IDs + https://github.com/django/django/commit/1184d077893ff1bc947e45b00a4d565f3df81776#diff-c571286052438b2e3190f8db8331a92bR231 + Args: + uidb36: base36-encoded user ID + + Returns: base64-encoded user ID. Otherwise returns a dummy, invalid ID + """ + try: + uidb64 = force_text(urlsafe_base64_encode(force_bytes(base36_to_int(uidb36)))) + except ValueError: + uidb64 = '1' # dummy invalid ID (incorrect padding for base64) + return uidb64 + + +# pylint: disable=too-many-statements +def password_reset_confirm_wrapper(request, uidb36=None, token=None): + """ + A wrapper around django.contrib.auth.views.password_reset_confirm. + Needed because we want to set the user as active at this step. + We also optionally do some additional password policy checks. + """ + # convert old-style base36-encoded user id to base64 + uidb64 = _uidb36_to_uidb64(uidb36) + platform_name = { + "platform_name": configuration_helpers.get_value('platform_name', settings.PLATFORM_NAME) + } + + # User can not get this link unless account recovery feature is enabled. + if 'is_account_recovery' in request.GET and not is_secondary_email_feature_enabled(): + raise Http404 + + try: + uid_int = base36_to_int(uidb36) + if request.user.is_authenticated and request.user.id != uid_int: + raise Http404 + + user = User.objects.get(id=uid_int) + except (ValueError, User.DoesNotExist): + # if there's any error getting a user, just let django's + # password_reset_confirm function handle it. + return password_reset_confirm( + request, uidb64=uidb64, token=token, extra_context=platform_name + ) + + if UserRetirementRequest.has_user_requested_retirement(user): + # Refuse to reset the password of any user that has requested retirement. + context = { + 'validlink': True, + 'form': None, + 'title': _('Password reset unsuccessful'), + 'err_msg': _('Error in resetting your password.'), + } + context.update(platform_name) + return TemplateResponse( + request, 'registration/password_reset_confirm.html', context + ) + + if waffle().is_enabled(PREVENT_AUTH_USER_WRITES): + context = { + 'validlink': False, + 'form': None, + 'title': _('Password reset unsuccessful'), + 'err_msg': SYSTEM_MAINTENANCE_MSG, + } + context.update(platform_name) + return TemplateResponse( + request, 'registration/password_reset_confirm.html', context + ) + + if request.method == 'POST': + # We have to make a copy of request.POST because it is a QueryDict object which is immutable until copied. + # We have to use request.POST because the password_reset_confirm method takes in the request and a user's + # password is set to the request.POST['new_password1'] field. We have to also normalize the new_password2 + # field so it passes the equivalence check that new_password1 == new_password2 + # In order to switch out of having to do this copy, we would want to move the normalize_password code into + # a custom User model's set_password method to ensure it is always happening upon calling set_password. + 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: + validate_password(password, user=user) + except ValidationError as err: + # We have a password reset attempt which violates some security + # policy, or any other validation. Use the existing Django template to communicate that + # back to the user. + context = { + 'validlink': True, + 'form': None, + 'title': _('Password reset unsuccessful'), + 'err_msg': ' '.join(err.messages), + } + context.update(platform_name) + return TemplateResponse( + request, 'registration/password_reset_confirm.html', context + ) + + if 'is_account_recovery' in request.GET: + response = password_reset_confirm( + request, + uidb64=uidb64, + token=token, + extra_context=platform_name, + template_name='registration/password_reset_confirm.html', + post_reset_redirect='signin_user', + ) + else: + response = password_reset_confirm( + request, uidb64=uidb64, token=token, extra_context=platform_name + ) + + # If password reset was unsuccessful a template response is returned (status_code 200). + # Check if form is invalid then show an error to the user. + # Note if password reset was successful we get response redirect (status_code 302). + if response.status_code == 200: + form_valid = response.context_data['form'].is_valid() if response.context_data['form'] else False + if not form_valid: + log.warning( + u'Unable to reset password for user [%s] because form is not valid. ' + u'A possible cause is that the user had an invalid reset token', + user.username, + ) + response.context_data['err_msg'] = _('Error in resetting your password. Please try again.') + return response + + # get the updated user + updated_user = User.objects.get(id=uid_int) + if 'is_account_recovery' in request.GET: + try: + updated_user.email = updated_user.account_recovery.secondary_email + updated_user.account_recovery.delete() + # emit an event that the user changed their secondary email to the primary email + tracker.emit( + SETTING_CHANGE_INITIATED, + { + "setting": "email", + "old": user.email, + "new": updated_user.email, + "user_id": updated_user.id, + } + ) + except ObjectDoesNotExist: + log.error( + u'Account recovery process initiated without AccountRecovery instance for user {username}'.format( + username=updated_user.username + ) + ) + + updated_user.save() + + if response.status_code == 302 and 'is_account_recovery' in request.GET: + messages.success( + request, + HTML(_( + u'{html_start}Password Creation Complete{html_end}' + u'Your password has been created. {bold_start}{email}{bold_end} is now your primary login email.' + )).format( + support_url=configuration_helpers.get_value('SUPPORT_SITE_LINK', settings.SUPPORT_SITE_LINK), + html_start=HTML('

'), + html_end=HTML('

'), + bold_start=HTML(''), + bold_end=HTML(''), + email=updated_user.email, + ), + extra_tags='account-recovery aa-icon submission-success' + ) + else: + response = password_reset_confirm( + request, uidb64=uidb64, token=token, extra_context=platform_name + ) + + response_was_successful = response.context_data.get('validlink') + if response_was_successful and not user.is_active: + user.is_active = True + user.save() + + return response + + +def _get_user_from_email(email): + """ + Find a user using given email and return it. + + Arguments: + email (str): primary or secondary email address of the user. + + Raises: + (User.ObjectNotFound): If no user is found with the given email. + (User.MultipleObjectsReturned): If more than one user is found with the given email. + + Returns: + User: Django user object. + """ + try: + return User.objects.get(email=email) + except ObjectDoesNotExist: + return User.objects.filter( + id__in=AccountRecovery.objects.filter(secondary_email__iexact=email, is_active=True).values_list('user') + ).get() + + +@require_POST +def password_change_request_handler(request): + """Handle password change requests originating from the account page. + + Uses the Account API to email the user a link to the password reset page. + + Note: + The next step in the password reset process (confirmation) is currently handled + by student.views.password_reset_confirm_wrapper, a custom wrapper around Django's + password reset confirmation view. + + Args: + request (HttpRequest) + + Returns: + HttpResponse: 200 if the email was sent successfully + HttpResponse: 400 if there is no 'email' POST parameter + HttpResponse: 403 if the client has been rate limited + HttpResponse: 405 if using an unsupported HTTP method + + Example usage: + + POST /account/password + + """ + + password_reset_email_limiter = PasswordResetEmailRateLimiter() + + if password_reset_email_limiter.is_rate_limit_exceeded(request): + AUDIT_LOG.warning("Password reset rate limit exceeded") + return HttpResponse( + _("Your previous request is in progress, please try again in a few moments."), + status=403 + ) + + user = request.user + # Prefer logged-in user's email + email = user.email if user.is_authenticated else request.POST.get('email') + + if email: + try: + request_password_change(email, request.is_secure()) + user = user if user.is_authenticated else _get_user_from_email(email=email) + destroy_oauth_tokens(user) + except errors.UserNotFound: + AUDIT_LOG.info("Invalid password reset attempt") + # If enabled, send an email saying that a password reset was attempted, but that there is + # no user associated with the email + if configuration_helpers.get_value('ENABLE_PASSWORD_RESET_FAILURE_EMAIL', + settings.FEATURES['ENABLE_PASSWORD_RESET_FAILURE_EMAIL']): + site = get_current_site() + message_context = get_base_template_context(site) + + message_context.update({ + 'failed': True, + 'request': request, # Used by google_analytics_tracking_pixel + 'email_address': email, + }) + + msg = PasswordReset().personalize( + recipient=Recipient(username='', email_address=email), + language=settings.LANGUAGE_CODE, + user_context=message_context, + ) + ace.send(msg) + except errors.UserAPIInternalError as err: + log.exception(u'Error occured during password change for user {email}: {error}' + .format(email=email, error=err)) + return HttpResponse(_("Some error occured during password change. Please try again"), status=500) + + password_reset_email_limiter.tick_request_counter(request) + return HttpResponse(status=200) + else: + return HttpResponseBadRequest(_("No email address provided.")) diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_password.py b/openedx/core/djangoapps/user_authn/views/tests/test_password.py index 538e9c209d..34f5ad2180 100644 --- a/openedx/core/djangoapps/user_authn/views/tests/test_password.py +++ b/openedx/core/djangoapps/user_authn/views/tests/test_password.py @@ -85,5 +85,5 @@ class TestRequestPasswordChange(CreateAccountMixin, TestCase): with patch('crum.get_current_request', return_value=request): request_password_change(self.EMAIL, self.IS_SECURE) - # Verify that the activation email was still sent + # Verify that the password change email was still sent self.assertEqual(len(mail.outbox), 2) 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 26b3ab50af..a4b1ec1b24 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 @@ -33,11 +33,14 @@ from openedx.core.djangoapps.user_api.config.waffle import PREVENT_AUTH_USER_WRI 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, password_reset_confirm_wrapper +) from openedx.core.djangolib.testing.utils import CacheIsolationTestCase from student.tests.factories import UserFactory from student.tests.test_configuration_overrides import fake_get_value from student.tests.test_email import mock_render_to_string -from student.views import SETTING_CHANGE_INITIATED, password_reset, password_reset_confirm_wrapper + from util.password_policy_validators import create_validator_config from util.testing import EventTestMixin @@ -56,7 +59,7 @@ class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase): ENABLED_CACHES = ['default'] def setUp(self): # pylint: disable=arguments-differ - super(ResetPasswordTests, self).setUp('student.views.management.tracker') + super(ResetPasswordTests, self).setUp('openedx.core.djangoapps.user_authn.views.password_reset.tracker') self.user = UserFactory.create() self.user.is_active = False self.user.save() @@ -68,7 +71,10 @@ class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase): self.user_bad_passwd.password = UNUSABLE_PASSWORD_PREFIX self.user_bad_passwd.save() - @patch('student.views.management.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) + @patch( + 'openedx.core.djangoapps.user_authn.views.password_reset.render_to_string', + Mock(side_effect=mock_render_to_string, autospec=True) + ) def test_user_bad_password_reset(self): """ Tests password reset behavior for user with password marked UNUSABLE_PASSWORD_PREFIX @@ -85,7 +91,10 @@ class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase): }) self.assert_no_events_were_emitted() - @patch('student.views.management.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) + @patch( + 'openedx.core.djangoapps.user_authn.views.password_reset.render_to_string', + Mock(side_effect=mock_render_to_string, autospec=True) + ) def test_nonexist_email_password_reset(self): """ Now test the exception cases with of reset_password called with invalid email. @@ -104,7 +113,10 @@ class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase): }) self.assert_no_events_were_emitted() - @patch('student.views.management.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) + @patch( + 'openedx.core.djangoapps.user_authn.views.password_reset.render_to_string', + Mock(side_effect=mock_render_to_string, autospec=True) + ) def test_password_reset_ratelimited(self): """ Try (and fail) resetting password 30 times in a row on an non-existant email address @@ -437,7 +449,7 @@ class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase): self.assertEqual(response.context_data['err_msg'], password_dict['error_message']) - @patch('student.views.management.password_reset_confirm') + @patch('openedx.core.djangoapps.user_authn.views.password_reset.password_reset_confirm') @patch("openedx.core.djangoapps.site_configuration.helpers.get_value", fake_get_value) def test_reset_password_good_token_configuration_override(self, reset_confirm): """