diff --git a/common/djangoapps/student/forms.py b/common/djangoapps/student/forms.py index 2881525413..676c4f89de 100644 --- a/common/djangoapps/student/forms.py +++ b/common/djangoapps/student/forms.py @@ -26,7 +26,7 @@ from openedx.core.djangoapps.site_configuration import helpers as configuration_ from openedx.core.djangoapps.theming.helpers import get_current_site from openedx.core.djangoapps.user_api import accounts as accounts_settings from openedx.core.djangoapps.user_api.preferences.api import get_user_preference -from student.message_types import PasswordReset +from student.message_types import AccountRecovery as AccountRecoveryMessage, PasswordReset from student.models import AccountRecovery, CourseEnrollmentAllowed, email_exists_or_retired from util.password_policy_validators import validate_password @@ -64,6 +64,38 @@ def send_password_reset_email_for_user(user, request, preferred_email=None): ace.send(msg) +def send_account_recovery_email_for_user(user, request, email=None): + """ + Send out a account recovery email for the given user. + + Arguments: + user (User): Django User object + request (HttpRequest): Django request object + email (str): Send email to this address. + """ + site = get_current_site() + message_context = get_base_template_context(site) + message_context.update({ + 'request': request, # Used by google_analytics_tracking_pixel + 'platform_name': configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME), + 'reset_link': '{protocol}://{site}{link}'.format( + protocol='https' if request.is_secure() else 'http', + site=configuration_helpers.get_value('SITE_NAME', settings.SITE_NAME), + link=reverse('account_recovery_confirm', kwargs={ + 'uidb36': int_to_base36(user.id), + 'token': default_token_generator.make_token(user), + }), + ) + }) + + msg = AccountRecoveryMessage().personalize( + recipient=Recipient(user.username, email), + language=get_user_preference(user, LANGUAGE_KEY), + user_context=message_context, + ) + ace.send(msg) + + class PasswordResetFormNoActive(PasswordResetForm): error_messages = { 'unknown': _("That e-mail address doesn't have an associated " @@ -138,6 +170,18 @@ class AccountRecoveryForm(PasswordResetFormNoActive): raise forms.ValidationError(self.error_messages['unusable']) return email + def save(self, # pylint: disable=arguments-differ + use_https=False, + token_generator=default_token_generator, + request=None, + **_kwargs): + """ + Generates a one-use only link for setting the password and sends to the + user. + """ + for user in self.users_cache: + send_account_recovery_email_for_user(user, request, user.account_recovery.secondary_email) + class TrueCheckbox(widgets.CheckboxInput): """ diff --git a/common/djangoapps/student/message_types.py b/common/djangoapps/student/message_types.py index 5829557067..95f63a3da7 100644 --- a/common/djangoapps/student/message_types.py +++ b/common/djangoapps/student/message_types.py @@ -12,6 +12,13 @@ class PasswordReset(BaseMessageType): self.options['transactional'] = True +class AccountRecovery(BaseMessageType): + def __init__(self, *args, **kwargs): + super(AccountRecovery, self).__init__(*args, **kwargs) + + self.options['transactional'] = True + + class EmailChange(BaseMessageType): def __init__(self, *args, **kwargs): super(EmailChange, self).__init__(*args, **kwargs) diff --git a/common/djangoapps/student/tests/factories.py b/common/djangoapps/student/tests/factories.py index 4ef699a6c5..c5c4d82840 100644 --- a/common/djangoapps/student/tests/factories.py +++ b/common/djangoapps/student/tests/factories.py @@ -13,6 +13,7 @@ from pytz import UTC from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory from student.models import ( + AccountRecovery, CourseAccessRole, CourseEnrollment, CourseEnrollmentAllowed, @@ -200,3 +201,12 @@ class PermissionFactory(DjangoModelFactory): codename = factory.Faker('codename') content_type = factory.SubFactory(ContentTypeFactory) + + +class AccountRecoveryFactory(DjangoModelFactory): + class Meta(object): + model = AccountRecovery + django_get_or_create = ('user',) + + user = None + secondary_email = factory.Sequence(u'robot+test+recovery+{0}@edx.org'.format) diff --git a/common/djangoapps/student/urls.py b/common/djangoapps/student/urls.py index 55b3911b11..5f0a8d8982 100644 --- a/common/djangoapps/student/urls.py +++ b/common/djangoapps/student/urls.py @@ -29,6 +29,11 @@ urlpatterns = [ views.password_reset_confirm_wrapper, name='password_reset_confirm', ), + url( + r'^account_recovery_confirm/(?P[0-9A-Za-z]+)-(?P.+)/$', + views.account_recovery_confirm_wrapper, + name='account_recovery_confirm', + ), url(r'^course_run/{}/refund_status$'.format(settings.COURSE_ID_PATTERN), views.course_run_refund_status, diff --git a/common/djangoapps/student/views/management.py b/common/djangoapps/student/views/management.py index bab16b4b04..5a979dc996 100644 --- a/common/djangoapps/student/views/management.py +++ b/common/djangoapps/student/views/management.py @@ -765,6 +765,7 @@ def account_recovery_request_handler(request): AUDIT_LOG.warning( "Account recovery attempt via invalid secondary email '{email}'.".format(email=email) ) + limiter.tick_bad_request_counter(request) return HttpResponse(status=200) else: @@ -941,6 +942,132 @@ def password_reset_confirm_wrapper(request, uidb36=None, token=None): return response +def account_recovery_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 secondary email feature is enabled. + if not is_secondary_email_feature_enabled(): + raise Http404 + + try: + uid_int = base36_to_int(uidb36) + 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, + template_name='account_recovery/password_create_confirm.html' + ) + + 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 creation unsuccessful'), + 'err_msg': ' '.join(err.messages), + } + context.update(platform_name) + + return TemplateResponse( + request, 'account_recovery/password_create_confirm.html', context + ) + + # remember what the old password hash is before we call down + old_password_hash = user.password + + response = password_reset_confirm( + request, + uidb64=uidb64, + token=token, + extra_context=platform_name, + template_name='account_recovery/password_create_confirm.html', + post_reset_redirect='signin_user', + ) + + # 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 create password for user [%s] because form is not valid. ' + u'A possible cause is that the user had an invalid create token', + user.username, + ) + response.context_data['err_msg'] = _('Error in creating your password. Please try again.') + return response + + # get the updated user + updated_user = User.objects.get(id=uid_int) + updated_user.email = updated_user.account_recovery.secondary_email + updated_user.save() + + if response.status_code == 302: + 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, + template_name='account_recovery/password_create_confirm.html', + ) + + 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/common/templates/student/edx_ace/accountrecovery/email/body.html b/common/templates/student/edx_ace/accountrecovery/email/body.html new file mode 100644 index 0000000000..1bbe370c7b --- /dev/null +++ b/common/templates/student/edx_ace/accountrecovery/email/body.html @@ -0,0 +1,28 @@ +{% extends 'ace_common/edx_ace/common/base_body.html' %} + +{% load i18n %} +{% load static %} +{% block content %} + + + + +
+

+ {% trans "Create Password" %} +

+

+ {% blocktrans %}You're receiving this e-mail because you requested to create a password for your user account at {{ platform_name }}.{% endblocktrans %} +
+

+ +

+ {% trans "If you didn't request this change, you can disregard this email - we have not yet created your password." %} +
+

+ + {% trans "Create my Password" as course_cta_text %} + + {% include "ace_common/edx_ace/common/return_to_course_cta.html" with course_cta_text=course_cta_text course_cta_url=reset_link %} +
+{% endblock %} diff --git a/common/templates/student/edx_ace/accountrecovery/email/body.txt b/common/templates/student/edx_ace/accountrecovery/email/body.txt new file mode 100644 index 0000000000..94dc1c4672 --- /dev/null +++ b/common/templates/student/edx_ace/accountrecovery/email/body.txt @@ -0,0 +1,12 @@ +{% load i18n %}{% autoescape off %} +{% blocktrans %}You're receiving this e-mail because you requested to create a password for your user account at {{ platform_name }}.{% endblocktrans %} + +{% trans "Please go to the following page and choose a new password:" %} + +{{ reset_link }} + +{% trans "If you didn't request this change, you can disregard this email - we have not yet created your password." %} + +{% trans "Thanks for using our site!" %} +{% blocktrans %}The {{ platform_name }} Team{% endblocktrans %} +{% endautoescape %} diff --git a/common/templates/student/edx_ace/accountrecovery/email/from_name.txt b/common/templates/student/edx_ace/accountrecovery/email/from_name.txt new file mode 100644 index 0000000000..dcbc23c004 --- /dev/null +++ b/common/templates/student/edx_ace/accountrecovery/email/from_name.txt @@ -0,0 +1 @@ +{{ platform_name }} diff --git a/common/templates/student/edx_ace/accountrecovery/email/head.html b/common/templates/student/edx_ace/accountrecovery/email/head.html new file mode 100644 index 0000000000..366ada7ad9 --- /dev/null +++ b/common/templates/student/edx_ace/accountrecovery/email/head.html @@ -0,0 +1 @@ +{% extends 'ace_common/edx_ace/common/base_head.html' %} diff --git a/common/templates/student/edx_ace/accountrecovery/email/subject.txt b/common/templates/student/edx_ace/accountrecovery/email/subject.txt new file mode 100644 index 0000000000..959c30c293 --- /dev/null +++ b/common/templates/student/edx_ace/accountrecovery/email/subject.txt @@ -0,0 +1,4 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans trimmed %}Create password on {{ platform_name }}{% endblocktrans %} +{% endautoescape %} diff --git a/lms/static/js/student_account/components/PasswordResetConfirmation.jsx b/lms/static/js/student_account/components/PasswordResetConfirmation.jsx index a18d2327f9..f4a26a1ef5 100644 --- a/lms/static/js/student_account/components/PasswordResetConfirmation.jsx +++ b/lms/static/js/student_account/components/PasswordResetConfirmation.jsx @@ -85,7 +85,7 @@ class PasswordResetConfirmation extends React.Component {

- {gettext('Reset Your Password')} + {this.props.formTitle}

@@ -121,7 +121,7 @@ class PasswordResetConfirmation extends React.Component {