Merge pull request #19611 from edx/saleem-latif/ENT-1452

ENT-1452: Consolidate recovery assistance forms
This commit is contained in:
Saleem Latif
2019-01-22 16:12:06 +05:00
committed by GitHub
18 changed files with 69 additions and 709 deletions

View File

@@ -21,10 +21,10 @@ from edx_ace import ace
from edx_ace.recipient import Recipient
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.djangolib.markup import HTML, Text
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
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.accounts.utils import is_secondary_email_feature_enabled
from openedx.core.djangoapps.user_api.preferences.api import get_user_preference
from student.message_types import AccountRecovery as AccountRecoveryMessage, PasswordReset
from student.models import AccountRecovery, CourseEnrollmentAllowed, email_exists_or_retired
@@ -78,10 +78,10 @@ def send_account_recovery_email_for_user(user, request, email=None):
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(
'reset_link': '{protocol}://{site}{link}?is_account_recovery=true'.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={
link=reverse('password_reset_confirm', kwargs={
'uidb36': int_to_base36(user.id),
'token': default_token_generator.make_token(user),
}),
@@ -104,6 +104,8 @@ class PasswordResetFormNoActive(PasswordResetForm):
"address cannot reset the password."),
}
is_account_recovery = True
def clean_email(self):
"""
This is a literal copy from Django 1.4.5's django.contrib.auth.forms.PasswordResetForm
@@ -113,6 +115,14 @@ class PasswordResetFormNoActive(PasswordResetForm):
email = self.cleaned_data["email"]
#The line below contains the only change, removing is_active=True
self.users_cache = User.objects.filter(email__iexact=email)
if len(self.users_cache) == 0 and is_secondary_email_feature_enabled():
# Check if user has entered the secondary email.
self.users_cache = User.objects.filter(
id__in=AccountRecovery.objects.filter(secondary_email__iexact=email, is_active=True).values_list('user')
)
self.is_account_recovery = not bool(self.users_cache)
if not len(self.users_cache):
raise forms.ValidationError(self.error_messages['unknown'])
if any((user.password.startswith(UNUSABLE_PASSWORD_PREFIX))
@@ -130,57 +140,10 @@ class PasswordResetFormNoActive(PasswordResetForm):
user.
"""
for user in self.users_cache:
send_password_reset_email_for_user(user, request)
class AccountRecoveryForm(PasswordResetFormNoActive):
error_messages = {
'unknown': _(
HTML(
'That secondary e-mail address doesn\'t have an associated user account. Are you sure you had added '
'a verified secondary email address for account recovery in your account settings? Please '
'<a href={support_url}">contact support</a> for further assistance.'
)
).format(
support_url=configuration_helpers.get_value('SUPPORT_SITE_LINK', settings.SUPPORT_SITE_LINK),
),
'unusable': _(
Text(
'The user account associated with this secondary e-mail address cannot reset the password.'
)
),
}
def clean_email(self):
"""
This is a literal copy from Django's django.contrib.auth.forms.PasswordResetForm
Except removing the requirement of active users
Validates that a user exists with the given secondary email.
"""
email = self.cleaned_data["email"]
# The line below contains the only change, getting users via AccountRecovery
self.users_cache = User.objects.filter(
id__in=AccountRecovery.objects.filter(secondary_email__iexact=email, is_active=True).values_list('user')
)
if not len(self.users_cache):
raise forms.ValidationError(self.error_messages['unknown'])
if any((user.password.startswith(UNUSABLE_PASSWORD_PREFIX))
for user in self.users_cache):
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)
if self.is_account_recovery:
send_password_reset_email_for_user(user, request)
else:
send_account_recovery_email_for_user(user, request, user.account_recovery.secondary_email)
class TrueCheckbox(widgets.CheckboxInput):

View File

@@ -22,18 +22,12 @@ urlpatterns = [
# 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'^account/account_recovery', views.account_recovery_request_handler, name='account_recovery'),
url(r'^password_reset/$', views.password_reset, name='password_reset'),
url(
r'^password_reset_confirm/(?P<uidb36>[0-9A-Za-z]+)-(?P<token>.+)/$',
views.password_reset_confirm_wrapper,
name='password_reset_confirm',
),
url(
r'^account_recovery_confirm/(?P<uidb36>[0-9A-Za-z]+)-(?P<token>.+)/$',
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,

View File

@@ -15,6 +15,7 @@ from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import AnonymousUser, User
from django.contrib.auth.views import password_reset_confirm
from django.contrib.sites.models import Site
from django.core.exceptions import ObjectDoesNotExist
from django.core import mail
from django.urls import reverse
from django.core.validators import ValidationError, validate_email
@@ -681,7 +682,7 @@ def password_change_request_handler(request):
try:
from openedx.core.djangoapps.user_api.accounts.api import request_password_change
request_password_change(email, request.is_secure())
user = user if user.is_authenticated else User.objects.get(email=email)
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")
@@ -719,58 +720,26 @@ def password_change_request_handler(request):
return HttpResponseBadRequest(_("No email address provided."))
@require_http_methods(['POST'])
def account_recovery_request_handler(request):
def get_user_from_email(email):
"""
Handle account recovery requests.
Find a user using given email and return it.
Arguments:
request (HttpRequest)
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:
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
HttpResponse: 404 if account recovery feature is not enabled
Example:
POST /account/account_recovery
User: Django user object.
"""
if not is_secondary_email_feature_enabled():
raise Http404
limiter = BadRequestRateLimiter()
if limiter.is_rate_limit_exceeded(request):
AUDIT_LOG.warning("Account recovery rate limit exceeded")
return HttpResponseForbidden()
user = request.user
# Prefer logged-in user's email
email = request.POST.get('email')
if email:
try:
# Send an email with a link to direct user towards account recovery.
from openedx.core.djangoapps.user_api.accounts.api import request_account_recovery
request_account_recovery(email, request.is_secure())
# Check if a user exists with the given secondary email, if so then invalidate the existing oauth tokens.
user = user if user.is_authenticated else User.objects.get(
id=AccountRecovery.objects.get_active(secondary_email__iexact=email).user.id
)
destroy_oauth_tokens(user)
except UserNotFound:
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:
return HttpResponseBadRequest(_("No email address provided."))
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
@@ -841,6 +810,11 @@ def password_reset_confirm_wrapper(request, uidb36=None, token=None):
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)
user = User.objects.get(id=uid_int)
@@ -909,9 +883,19 @@ def password_reset_confirm_wrapper(request, uidb36=None, token=None):
# 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
)
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.
@@ -930,113 +914,20 @@ def password_reset_confirm_wrapper(request, uidb36=None, token=None):
# get the updated user
updated_user = User.objects.get(id=uid_int)
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 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,
if 'is_account_recovery' in request.GET:
try:
updated_user.email = updated_user.account_recovery.secondary_email
updated_user.account_recovery.delete()
except ObjectDoesNotExist:
log.error(
'Account recovery process initiated without AccountRecovery instance for user {username}'.format(
username=updated_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:
if response.status_code == 302 and 'is_account_recovery' in request.GET:
messages.success(
request,
HTML(_(
@@ -1054,11 +945,7 @@ def account_recovery_confirm_wrapper(request, uidb36=None, token=None):
)
else:
response = password_reset_confirm(
request,
uidb64=uidb64,
token=token,
extra_context=platform_name,
template_name='account_recovery/password_create_confirm.html',
request, uidb64=uidb64, token=token, extra_context=platform_name
)
response_was_successful = response.context_data.get('validlink')

View File

@@ -1,152 +0,0 @@
(function(define) {
'use strict';
define([
'jquery',
'underscore',
'common/js/spec_helpers/template_helpers',
'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers',
'js/student_account/models/AccountRecoveryModel',
'js/student_account/views/AccountRecoveryView'
],
function($, _, TemplateHelpers, AjaxHelpers, AccountRecoveryModel, AccountRecoveryView) {
describe('edx.student.account.AccountRecoveryView', function() {
var model = null,
view = null,
requests = null,
EMAIL = 'xsy@edx.org',
FORM_DESCRIPTION = {
method: 'post',
submit_url: '/account/password',
fields: [{
name: 'email',
label: 'Secondary email',
defaultValue: '',
type: 'text',
required: true,
placeholder: 'place@holder.org',
instructions: 'Enter your secondary email.',
restrictions: {}
}]
};
var createAccountRecoveryView = function(that) {
// Initialize the account recovery model
model = new AccountRecoveryModel({}, {
url: FORM_DESCRIPTION.submit_url,
method: FORM_DESCRIPTION.method
});
// Initialize the account recovery view
view = new AccountRecoveryView({
fields: FORM_DESCRIPTION.fields,
model: model
});
// Spy on AJAX requests
requests = AjaxHelpers.requests(that);
};
var submitEmail = function(validationSuccess) {
// Create a fake click event
var clickEvent = $.Event('click');
// Simulate manual entry of an email address
$('#password-reset-email').val(EMAIL);
// If validationSuccess isn't passed, we avoid
// spying on `view.validate` twice
if (!_.isUndefined(validationSuccess)) {
// Force validation to return as expected
spyOn(view, 'validate').and.returnValue({
isValid: validationSuccess,
message: 'Submission was validated.'
});
}
// Submit the email address
view.submitForm(clickEvent);
};
beforeEach(function() {
setFixtures('<div id="password-reset-form" class="form-wrapper hidden"></div>');
TemplateHelpers.installTemplate('templates/student_account/account_recovery');
TemplateHelpers.installTemplate('templates/student_account/form_field');
});
it('allows the user to request account recovery', function() {
var syncSpy, passwordEmailSentSpy;
createAccountRecoveryView(this);
// We expect these events to be triggered upon a successful account recovery
syncSpy = jasmine.createSpy('syncEvent');
passwordEmailSentSpy = jasmine.createSpy('passwordEmailSentEvent');
view.listenTo(view.model, 'sync', syncSpy);
view.listenTo(view, 'account-recovery-email-sent', passwordEmailSentSpy);
// Submit the form, with successful validation
submitEmail(true);
// Verify that the client contacts the server with the expected data
AjaxHelpers.expectRequest(
requests, 'POST',
FORM_DESCRIPTION.submit_url,
$.param({email: EMAIL})
);
// Respond with status code 200
AjaxHelpers.respondWithJson(requests, {});
// Verify that the events were triggered
expect(syncSpy).toHaveBeenCalled();
expect(passwordEmailSentSpy).toHaveBeenCalled();
// Verify that account recovery view has been removed
expect($(view.el).html().length).toEqual(0);
});
it('validates the email field', function() {
createAccountRecoveryView(this);
// Submit the form, with successful validation
submitEmail(true);
// Verify that validation of the email field occurred
expect(view.validate).toHaveBeenCalledWith($('#password-reset-email')[0]);
// Verify that no submission errors are visible
expect(view.$formFeedback.find('.' + view.formErrorsJsHook).length).toEqual(0);
});
it('displays account recovery validation errors', function() {
createAccountRecoveryView(this);
// Submit the form, with failed validation
submitEmail(false);
// Verify that submission errors are visible
expect(view.$formFeedback.find('.' + view.formErrorsJsHook).length).toEqual(1);
});
it('displays error if the server returns an error while sending account recovery email', function() {
createAccountRecoveryView(this);
submitEmail(true);
// Simulate an error from the LMS servers
AjaxHelpers.respondWithError(requests);
// Expect that an error is displayed
expect(view.$formFeedback.find('.' + view.formErrorsJsHook).length).toEqual(1);
// If we try again and succeed, the error should go away
submitEmail();
// This time, respond with status code 200
AjaxHelpers.respondWithJson(requests, {});
// Expect that the error is hidden
expect(view.$formFeedback.find('.' + view.formErrorsJsHook).length).toEqual(0);
});
});
});
}).call(this, define || RequireJS.define);

View File

@@ -15,13 +15,11 @@
'js/student_account/views/RegisterView',
'js/student_account/views/InstitutionLoginView',
'js/student_account/views/HintedLoginView',
'js/student_account/views/AccountRecoveryView',
'edx-ui-toolkit/js/utils/html-utils',
'js/vendor/history'
],
function($, utility, _, _s, Backbone, LoginModel, PasswordResetModel, RegisterModel, AccountRecoveryModel,
LoginView, PasswordResetView, RegisterView, InstitutionLoginView, HintedLoginView, AccountRecoveryView,
HtmlUtils) {
LoginView, PasswordResetView, RegisterView, InstitutionLoginView, HintedLoginView, HtmlUtils) {
return Backbone.View.extend({
tpl: '#access-tpl',
events: {
@@ -69,7 +67,6 @@
login: options.login_form_desc,
register: options.registration_form_desc,
reset: options.password_reset_form_desc,
account_recovery: options.account_recovery_form_desc,
institution_login: null,
hinted_login: null
};
@@ -125,9 +122,6 @@
if (Backbone.history.getHash() === 'forgot-password-modal') {
this.resetPassword();
}
else if (Backbone.history.getHash() === 'account-recovery-modal') {
this.accountRecovery();
}
this.loadForm(this.activeForm);
},
@@ -163,9 +157,6 @@
// Listen for 'password-help' event to toggle sub-views
this.listenTo(this.subview.login, 'password-help', this.resetPassword);
// Listen for 'account-recovery-help' event to toggle sub-views
this.listenTo(this.subview.login, 'account-recovery-help', this.accountRecovery);
// Listen for 'auth-complete' event so we can enroll/redirect the user appropriately.
this.listenTo(this.subview.login, 'auth-complete', this.authComplete);
},
@@ -186,24 +177,6 @@
$('.password-reset-form').focus();
},
account_recovery: function(data) {
this.accountRecoveryModel.ajaxType = data.method;
this.accountRecoveryModel.urlRoot = data.submit_url;
this.subview.accountRecoveryHelp = new AccountRecoveryView({
fields: data.fields,
model: this.accountRecoveryModel
});
// Listen for 'account-recovery-email-sent' event to toggle sub-views
this.listenTo(
this.subview.accountRecoveryHelp, 'account-recovery-email-sent', this.passwordEmailSent
);
// Focus on the form
$('.password-reset-form').focus();
},
register: function(data) {
var model = new RegisterModel({}, {
method: data.method,
@@ -260,19 +233,6 @@
this.element.scrollTop($('#password-reset-anchor'));
},
accountRecovery: function() {
if (this.isAccountRecoveryFeatureEnabled) {
window.analytics.track('edx.bi.account_recovery.viewed', {
category: 'user-engagement'
});
this.element.hide($(this.el).find('#login-anchor'));
this.loadForm('account_recovery');
this.element.scrollTop($('#password-reset-anchor'));
}
},
toggleForm: function(e) {
var type = $(e.currentTarget).data('type'),
$form = $('#' + type + '-form'),

View File

@@ -1,39 +0,0 @@
(function(define) {
'use strict';
define([
'jquery',
'js/student_account/views/FormView'
],
function($, FormView) {
return FormView.extend({
el: '#password-reset-form',
tpl: '#account_recovery-tpl',
events: {
'click .js-reset': 'submitForm'
},
formType: 'password-reset',
requiredStr: '',
optionalStr: '',
submitButton: '.js-reset',
preRender: function() {
this.element.show($(this.el));
this.element.show($(this.el).parent());
this.listenTo(this.model, 'sync', this.saveSuccess);
},
saveSuccess: function() {
this.trigger('account-recovery-email-sent');
// Destroy the view (but not el) and unbind events
this.$el.empty().off();
this.stopListening();
}
});
});
}).call(this, define || RequireJS.define);

View File

@@ -139,12 +139,6 @@
this.trigger('password-help');
},
accountRecovery: function(event) {
event.preventDefault();
this.trigger('account-recovery-help');
},
getFormData: function() {
var obj = {},
$form = this.$form,

View File

@@ -23,7 +23,6 @@
events: {
'click .js-login': 'submitForm',
'click .forgot-password': 'forgotPassword',
'click .account-recovery': 'accountRecovery',
'click .login-provider': 'thirdPartyAuth'
},
formType: 'login',
@@ -137,13 +136,6 @@
this.clearPasswordResetSuccess();
},
accountRecovery: function(event) {
event.preventDefault();
this.trigger('account-recovery-help');
this.clearPasswordResetSuccess();
},
postFormSubmission: function() {
this.clearPasswordResetSuccess();
},
@@ -152,7 +144,7 @@
var email = $('#password-reset-email').val(),
successTitle = gettext('Check Your Email'),
successMessageHtml = HtmlUtils.interpolateHtml(
gettext('{paragraphStart}You entered {boldStart}{email}{boldEnd}. If this email address is associated with your {platform_name} account, we will send a message with password reset instructions to this email address.{paragraphEnd}' + // eslint-disable-line max-len
gettext('{paragraphStart}You entered {boldStart}{email}{boldEnd}. If this email address is associated with your {platform_name} account, we will send a message with password recovery instructions to this email address.{paragraphEnd}' + // eslint-disable-line max-len
'{paragraphStart}If you do not receive a password reset message, verify that you entered the correct email address, or check your spam folder.{paragraphEnd}' + // eslint-disable-line max-len
'{paragraphStart}If you need further assistance, {anchorStart}contact technical support{anchorEnd}.{paragraphEnd}'), { // eslint-disable-line max-len
boldStart: HtmlUtils.HTML('<b>'),

View File

@@ -787,7 +787,6 @@
'js/spec/student_account/login_spec.js',
'js/spec/student_account/logistration_factory_spec.js',
'js/spec/student_account/password_reset_spec.js',
'js/spec/student_account/account_recovery_spec.js',
'js/spec/student_account/register_spec.js',
'js/spec/student_account/shoppingcart_spec.js',
'js/spec/verify_student/image_input_spec.js',

View File

@@ -1,56 +0,0 @@
## mako
<%page expression_filter="h"/>
<%!
from django.utils.translation import ugettext as _
from openedx.core.djangolib.js_utils import js_escaped_string
from openedx.core.djangolib.markup import HTML, Text
%>
<%inherit file="../main.html"/>
<%namespace name='static' file='../static_content.html'/>
<%block name="title">
<title>${_("Create Your {platform_name} Password").format(platform_name=platform_name)}</title>
</%block>
<%block name="head_extra">
<link type="text/css" rel="stylesheet" href="${STATIC_URL}paragon/static/paragon.min.css">
</%block>
<%block name="bodyclass">view-passwordreset</%block>
<%block name="body">
<div id="password-reset-confirm-container" class="login-register-content login-register">
% if validlink:
${static.renderReact(
component="PasswordResetConfirmation",
id="password-reset-confirm-react",
props={
'csrfToken': csrf_token,
'errorMessage': js_escaped_string(err_msg) if err_msg else '',
'primaryActionButtonLabel': 'Create My Password',
'formTitle': 'Create Your Password',
},
)}
% else:
<div class="status submission-error">
<h4 class="message-title">${_("Invalid Password Create Link")}</h4>
<ul class="message-copy">
${Text(_((
"This password create link is invalid. It may have been used already. "
"To create your password, go to the {start_link}sign-in{end_link} page and "
"select {start_strong}Recovery your account{end_strong}."
))).format(
start_link=HTML('<a href="/login">'),
end_link=HTML('</a>'),
start_strong=HTML('<strong>'),
end_strong=HTML('</strong>')
)
}
</ul>
</div>
% endif
</div>
</%block>

View File

@@ -1,13 +0,0 @@
<div class="js-form-feedback" aria-live="assertive" tabindex="-1">
</div>
<h2><%- gettext("Account Recovery") %></h2>
<form id="account-recovery" class="account-recovery-form password-reset-form" tabindex="-1" method="POST">
<p class="action-label"><%- gettext("Please enter your secondary email address below and we will send you instructions for recovering your account and setting a new password.") %></p>
<%= HtmlUtils.HTML(fields) %>
<button type="submit" class="action action-primary action-update js-reset"><%- gettext("Recover my account") %></button>
</form>

View File

@@ -133,6 +133,6 @@
<% } %>
<% if( form === 'login' && name === 'password' ) { %>
<button type="button" class="forgot-password field-link"><%- gettext("Forgot password?") %></button>
<button type="button" class="forgot-password field-link"><%- gettext("Need help logging in?") %></button>
<% } %>
</div>

View File

@@ -30,7 +30,7 @@
</%block>
<%block name="header_extras">
% for template_name in ["account", "access", "form_field", "login", "register", "institution_login", "institution_register", "password_reset", "account_recovery", "hinted_login"]:
% for template_name in ["account", "access", "form_field", "login", "register", "institution_login", "institution_register", "password_reset", "hinted_login"]:
<script type="text/template" id="${template_name}-tpl">
<%static:include path="student_account/${template_name}.underscore" />
</script>

View File

@@ -5,9 +5,9 @@
<form id="password-reset" class="password-reset-form" tabindex="-1" method="POST">
<p class="action-label"><%- gettext("Please enter your email address below and we will send you instructions for setting a new password.") %></p>
<p class="action-label"><%- gettext("Please enter your registration or recovery email address below and we will send you an email with instructions.") %></p>
<%= fields %>
<%= HtmlUtils.HTML(fields) %>
<button type="submit" class="action action-primary action-update js-reset"><%- gettext("Reset my password") %></button>
<button type="submit" class="action action-primary action-update js-reset"><%- gettext("Recover my password") %></button>
</form>

View File

@@ -486,36 +486,6 @@ def request_password_change(email, is_secure):
raise errors.UserNotFound
@helpers.intercept_errors(errors.UserAPIInternalError, ignore_errors=[errors.UserAPIRequestError])
def request_account_recovery(email, is_secure):
"""
Email a single-use link for performing a password reset so users can login with new email and password.
Arguments:
email (str): An email address
is_secure (bool): Whether the request was made with HTTPS
Raises:
errors.UserNotFound: Raised if secondary email address does not exist.
"""
# Binding data to a form requires that the data be passed as a dictionary
# to the Form class constructor.
form = student_forms.AccountRecoveryForm({'email': email})
# Validate that a user exists with the given email address.
if form.is_valid():
# Generate a single-use link for performing a password reset
# and email it to the user.
form.save(
from_email=configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL),
use_https=is_secure,
request=get_current_request(),
)
else:
# No user with the provided email address exists.
raise errors.UserNotFound
def get_name_validation_error(name):
"""Get the built-in validation error message for when
the user's real name is invalid in some way (we wonder how).

View File

@@ -66,54 +66,6 @@ def get_password_reset_form():
return form_desc
def get_account_recovery_form():
"""
Return a description of the password reset, using secondary email, form.
This decouples clients from the API definition:
if the API decides to modify the form, clients won't need
to be updated.
See `user_api.helpers.FormDescription` for examples
of the JSON-encoded form description.
Returns:
HttpResponse
"""
form_desc = FormDescription("post", reverse("account_recovery"))
# Translators: This label appears above a field on the password reset
# form meant to hold the user's email address.
email_label = _(u"Secondary email")
# Translators: This example email address is used as a placeholder in
# a field on the password reset form meant to hold the user's email address.
email_placeholder = _(u"username@domain.com")
# Translators: These instructions appear on the password reset form,
# immediately below a field meant to hold the user's email address.
email_instructions = _(
u"Secondary email address you registered with {platform_name} using account settings page"
).format(
platform_name=configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME)
)
form_desc.add_field(
"email",
field_type="email",
label=email_label,
placeholder=email_placeholder,
instructions=email_instructions,
restrictions={
"min_length": accounts.EMAIL_MIN_LENGTH,
"max_length": accounts.EMAIL_MAX_LENGTH,
}
)
return form_desc
def get_login_session_form(request):
"""Return a description of the login form.

View File

@@ -22,7 +22,6 @@ from openedx.core.djangoapps.theming.helpers import is_request_in_themed_site
from openedx.core.djangoapps.user_api.accounts.utils import is_secondary_email_feature_enabled
from openedx.core.djangoapps.user_api.api import (
RegistrationFormFactory,
get_account_recovery_form,
get_login_session_form,
get_password_reset_form,
)
@@ -138,7 +137,6 @@ def login_and_registration_form(request, initial_mode="login"):
'login_form_desc': json.loads(form_descriptions['login']),
'registration_form_desc': json.loads(form_descriptions['registration']),
'password_reset_form_desc': json.loads(form_descriptions['password_reset']),
'account_recovery_form_desc': json.loads(form_descriptions['account_recovery']),
'account_creation_allowed': configuration_helpers.get_value(
'ALLOW_PUBLIC_ACCOUNT_CREATION', settings.FEATURES.get('ALLOW_PUBLIC_ACCOUNT_CREATION', True)),
'is_account_recovery_feature_enabled': is_secondary_email_feature_enabled()
@@ -177,7 +175,6 @@ def _get_form_descriptions(request):
return {
'password_reset': get_password_reset_form().to_json(),
'account_recovery': get_account_recovery_form().to_json(),
'login': get_login_session_form(request).to_json(),
'registration': RegistrationFormFactory().get_registration_form(request).to_json()
}

View File

@@ -17,7 +17,6 @@ from django.contrib.messages.middleware import MessageMiddleware
from django.contrib.sessions.middleware import SessionMiddleware
from django.core import mail
from django.core.files.uploadedfile import SimpleUploadedFile
from django.http import Http404
from django.urls import reverse
from django.test import TestCase
from django.test.client import RequestFactory
@@ -30,7 +29,6 @@ from provider.oauth2.models import AccessToken as dop_access_token
from provider.oauth2.models import RefreshToken as dop_refresh_token
from testfixtures import LogCapture
from waffle.models import Switch
from waffle.testutils import override_switch
from course_modes.models import CourseMode
from openedx.core.djangoapps.user_authn.views.login_form import login_and_registration_form
@@ -261,83 +259,6 @@ class UserAccountUpdateTest(CacheIsolationTestCase, UrlResetMixin):
response = getattr(self.client, method)(url)
self.assertEqual(response.status_code, 405)
@override_switch(ENABLE_SECONDARY_EMAIL_FEATURE_SWITCH, active=False)
def test_404_if_account_recovery_not_enabled(self):
with mock.patch('openedx.core.djangoapps.user_api.accounts.api.request_account_recovery',
side_effect=UserAPIInternalError):
self._recover_account()
self.assertRaises(Http404)
def test_account_recovery_failure(self):
with mock.patch('openedx.core.djangoapps.user_api.accounts.api.request_account_recovery',
side_effect=UserAPIInternalError):
self._recover_account()
self.assertRaises(UserAPIInternalError)
@override_settings(FEATURES=FEATURES_WITH_FAILED_PASSWORD_RESET_EMAIL)
def test_account_recovery_failure_email(self):
"""Test that log message is added when email does not match any in the system."""
# Log the user out
self.client.logout()
with LogCapture(LOGGER_NAME, level=logging.INFO) as logger:
bad_email = 'doesnotexist@example.com'
response = self._recover_account(email=bad_email)
self.assertEqual(response.status_code, 200)
logger.check(
(
LOGGER_NAME,
"WARNING", "Account recovery attempt via invalid secondary email '{email}'.".format(
email=bad_email
)
)
)
@override_settings(FEATURES=FEATURES_WITH_FAILED_PASSWORD_RESET_EMAIL)
def test_account_recovery_failure_not_active(self):
"""Test that log message is added when email does not match any active account recovery records."""
# Log the user out
self.client.logout()
self.account_recovery.is_active = False
self.account_recovery.save()
with LogCapture(LOGGER_NAME, level=logging.INFO) as logger:
response = self._recover_account(email=self.account_recovery.secondary_email)
self.assertEqual(response.status_code, 200)
logger.check(
(
LOGGER_NAME,
"WARNING", "Account recovery attempt via invalid secondary email '{email}'.".format(
email=self.account_recovery.secondary_email
)
)
)
def test_password_change_rate_limited_during_account_recovery(self):
# Log out the user created during test setup, to prevent the view from
# selecting the logged-in user's email address over the email provided
# in the POST data
self.client.logout()
# Make many consecutive bad requests in an attempt to trigger the rate limiter
for __ in xrange(self.INVALID_ATTEMPTS):
self._recover_account(email=self.NEW_EMAIL)
response = self._recover_account(email=self.NEW_EMAIL)
self.assertEqual(response.status_code, 403)
@ddt.data(
('post', 'account_recovery', []),
)
@ddt.unpack
def test_require_http_method_during_account_recovery(self, correct_method, url_name, args):
wrong_methods = {'get', 'put', 'post', 'head', 'options', 'delete'} - {correct_method}
url = reverse(url_name, args=args)
for method in wrong_methods:
response = getattr(self.client, method)(url)
self.assertEqual(response.status_code, 405)
def _change_password(self, email=None):
"""Request to change the user's password. """
data = {}
@@ -347,15 +268,6 @@ class UserAccountUpdateTest(CacheIsolationTestCase, UrlResetMixin):
return self.client.post(path=reverse('password_change_request'), data=data)
def _recover_account(self, email=None):
"""Request to create the user's password. """
data = {}
if email:
data['email'] = email
return self.client.post(path=reverse('account_recovery'), data=data)
def _create_dop_tokens(self, user=None):
"""Create dop access token for given user if user provided else for default user."""
if not user: