Commit ff587c5c by Renzo Lucioni

Add password reset request handling to the account page

The next step in the password reset process (confirmation) continues to be handled by student.views.password_reset_confirm_wrapper, a custom wrapper around Django's password reset confirmation view.
parent 7e52ba87
......@@ -5,8 +5,11 @@ address, but does NOT include user profile information (i.e., demographic
information and preferences).
"""
from django.conf import settings
from django.db import transaction, IntegrityError
from django.core.validators import validate_email, validate_slug, ValidationError
from django.contrib.auth.forms import PasswordResetForm
from user_api.models import User, UserProfile, Registration, PendingEmailChange
from user_api.helpers import intercept_errors
......@@ -300,6 +303,43 @@ def confirm_email_change(activation_key):
return (old_email, new_email)
@intercept_errors(AccountInternalError, ignore_errors=[AccountRequestError])
def request_password_change(email, orig_host, is_secure):
"""Email a single-use link for performing a password reset.
Users must confirm the password change before we update their information.
Args:
email (string): An email address
orig_host (string): An originating host, extracted from a request with get_host
is_secure (Boolean): Whether the request was made with HTTPS
Returns:
None
Raises:
AccountUserNotFound
AccountRequestError
"""
# Binding data to a form requires that the data be passed as a dictionary
# to the Form class constructor.
form = PasswordResetForm({'email': email})
# Validate that an active 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=settings.DEFAULT_FROM_EMAIL,
domain_override=orig_host,
use_https=is_secure
)
else:
# No active user with the provided email address exists.
raise AccountUserNotFound
def _validate_username(username):
"""Validate the username.
......
......@@ -5,4 +5,5 @@ urlpatterns = patterns(
url(r'^$', 'index', name='account_index'),
url(r'^email$', 'email_change_request_handler', name='email_change_request'),
url(r'^email/confirmation/(?P<key>[^/]*)$', 'email_change_confirmation_handler', name='email_change_confirm'),
url(r'^password$', 'password_change_request_handler', name='password_change_request'),
)
""" Views for a student's account information. """
import logging
from django.conf import settings
from django.http import (
QueryDict, HttpResponse,
HttpResponseBadRequest, HttpResponseServerError
HttpResponse, HttpResponseBadRequest,
HttpResponseForbidden
)
from django.core.mail import send_mail
from django_future.csrf import ensure_csrf_cookie
......@@ -14,6 +16,10 @@ from microsite_configuration import microsite
from user_api.api import account as account_api
from user_api.api import profile as profile_api
from util.bad_request_rate_limiter import BadRequestRateLimiter
AUDIT_LOG = logging.getLogger("audit")
@login_required
......@@ -47,18 +53,20 @@ def index(request):
def email_change_request_handler(request):
"""Handle a request to change the user's email address.
Sends an email to the newly specified address containing a link
to a confirmation page.
Args:
request (HttpRequest)
Returns:
HttpResponse: 200 if the confirmation email was sent successfully
HttpResponse: 302 if not logged in (redirect to login page)
HttpResponse: 400 if the format of the new email is incorrect
HttpResponse: 400 if the format of the new email is incorrect, or if
an email change is requested for a user which does not exist
HttpResponse: 401 if the provided password (in the form) is incorrect
HttpResponse: 405 if using an unsupported HTTP method
HttpResponse: 409 if the provided email is already in use
HttpResponse: 500 if the user to which the email change will be applied
does not exist
Example usage:
......@@ -78,12 +86,10 @@ def email_change_request_handler(request):
try:
key = account_api.request_email_change(username, new_email, password)
except account_api.AccountUserNotFound:
return HttpResponseServerError()
except (account_api.AccountEmailInvalid, account_api.AccountUserNotFound):
return HttpResponseBadRequest()
except account_api.AccountEmailAlreadyExists:
return HttpResponse(status=409)
except account_api.AccountEmailInvalid:
return HttpResponseBadRequest()
except account_api.AccountNotAuthorized:
return HttpResponse(status=401)
......@@ -105,7 +111,6 @@ def email_change_request_handler(request):
# Send a confirmation email to the new address containing the activation key
send_mail(subject, message, from_address, [new_email])
# Send a 200 response code to the client to indicate that the email was sent successfully.
return HttpResponse(status=200)
......@@ -122,15 +127,15 @@ def email_change_confirmation_handler(request, key):
Returns:
HttpResponse: 200 if the email change is successful, the activation key
is invalid, the new email is already in use, or the
user to which the email change will be applied does
not exist
is invalid, the new email is already in use, or the
user to which the email change will be applied does
not exist
HttpResponse: 302 if not logged in (redirect to login page)
HttpResponse: 405 if using an unsupported HTTP method
Example usage:
GET /account/email_change_confirm/{key}
GET /account/email/confirmation/{key}
"""
try:
......@@ -179,3 +184,53 @@ def email_change_confirmation_handler(request, key):
'disable_courseware_js': True,
}
)
@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, or if no user with
the provided email exists
HttpResponse: 403 if the client has been rate limited
HttpResponse: 405 if using an unsupported HTTP method
Example usage:
POST /account/password
"""
limiter = BadRequestRateLimiter()
if limiter.is_rate_limit_exceeded(request):
AUDIT_LOG.warning("Password reset rate limit exceeded")
return HttpResponseForbidden()
user = request.user
# Prefer logged-in user's email
email = user.email if user.is_authenticated() else request.POST.get('email')
if email:
try:
account_api.request_password_change(email, request.get_host(), request.is_secure())
except account_api.AccountUserNotFound:
AUDIT_LOG.info("Invalid password reset attempt")
# Increment the rate limit counter
limiter.tick_bad_request_counter(request)
return HttpResponseBadRequest("No active user with the provided email address exists.")
return HttpResponse(status=200)
else:
return HttpResponseBadRequest("No email address provided.")
......@@ -97,6 +97,11 @@ define(['js/student_account/account'],
view.submit(fakeEvent);
};
var requestPasswordChange = function() {
var fakeEvent = {preventDefault: function() {}};
view.click(fakeEvent);
};
var assertAjax = function(url, method, data) {
expect($.ajax).toHaveBeenCalled();
var ajaxArgs = $.ajax.mostRecentCall.args[0];
......@@ -106,31 +111,13 @@ define(['js/student_account/account'],
expect(ajaxArgs.headers.hasOwnProperty("X-CSRFToken")).toBe(true);
};
var assertEmailStatus = function(success, expectedStatus) {
var assertStatus = function(selection, success, errorClass, expectedStatus) {
if (!success) {
expect(view.$emailStatus).toHaveClass("validation-error");
expect(selection).toHaveClass(errorClass);
} else {
expect(view.$emailStatus).not.toHaveClass("validation-error");
expect(selection).not.toHaveClass(errorClass);
}
expect(view.$emailStatus.text()).toEqual(expectedStatus);
};
var assertPasswordStatus = function(success, expectedStatus) {
if (!success) {
expect(view.$passwordStatus).toHaveClass("validation-error");
} else {
expect(view.$passwordStatus).not.toHaveClass("validation-error");
}
expect(view.$passwordStatus.text()).toEqual(expectedStatus);
};
var assertRequestStatus = function(success, expectedStatus) {
if (!success) {
expect(view.$requestStatus).toHaveClass("error");
} else {
expect(view.$requestStatus).not.toHaveClass("error");
}
expect(view.$requestStatus.text()).toEqual(expectedStatus);
expect(selection.text()).toEqual(expectedStatus);
};
beforeEach(function() {
......@@ -139,7 +126,7 @@ define(['js/student_account/account'],
view = new edx.student.account.AccountView().render();
// Stub Ajax cals to return success/failure
// Stub Ajax calls to return success/failure
spyOn($, "ajax").andCallFake(function() {
return $.Deferred(function(defer) {
if (ajaxSuccess) {
......@@ -157,39 +144,57 @@ define(['js/student_account/account'],
email: "bob@example.com",
password: "password"
});
assertRequestStatus(true, "Please check your email to confirm the change");
assertStatus(view.$requestStatus, true, "error", "Please check your email to confirm the change");
});
it("displays email validation errors", function() {
// Invalid email should display an error
requestEmailChange("invalid", "password");
assertEmailStatus(false, "Please enter a valid email address");
assertStatus(view.$emailStatus, false, "validation-error", "Please enter a valid email address");
// Once the error is fixed, the status should return to normal
requestEmailChange("bob@example.com", "password");
assertEmailStatus(true, "");
assertStatus(view.$emailStatus, true, "validation-error", "");
});
it("displays an invalid password error", function() {
// Password cannot be empty
requestEmailChange("bob@example.com", "");
assertPasswordStatus(false, "Please enter a valid password");
assertStatus(view.$passwordStatus, false, "validation-error", "Please enter a valid password");
// Once the error is fixed, the status should return to normal
requestEmailChange("bob@example.com", "password");
assertPasswordStatus(true, "");
assertStatus(view.$passwordStatus, true, "validation-error", "");
});
it("displays server errors", function() {
// Simulate an error from the server
ajaxSuccess = false;
requestEmailChange("bob@example.com", "password");
assertRequestStatus(false, "The data could not be saved.");
assertStatus(view.$requestStatus, false, "error", "The data could not be saved.");
// On retry, it should succeed
ajaxSuccess = true;
requestEmailChange("bob@example.com", "password");
assertRequestStatus(true, "Please check your email to confirm the change");
assertStatus(view.$requestStatus, true, "error", "Please check your email to confirm the change");
});
it("requests a password reset", function() {
requestPasswordChange();
assertAjax("password", "POST", {});
assertStatus(view.$passwordResetStatus, true, "error", "Password reset email sent. Follow the link in the email to change your password.");
});
it("displays an error message if a password reset email could not be sent", function() {
// Simulate an error from the server
ajaxSuccess = false;
requestPasswordChange();
assertStatus(view.$passwordResetStatus, false, "error", "We weren't able to send you a password reset email.");
// Retry, this time simulating success
ajaxSuccess = true;
requestPasswordChange();
assertStatus(view.$passwordResetStatus, true, "error", "Password reset email sent. Follow the link in the email to change your password.");
});
});
}
......
......@@ -71,11 +71,12 @@ var edx = edx || {};
events: {
'submit': 'submit',
'change': 'change'
'change': 'change',
'click #password-reset': 'click'
},
initialize: function() {
_.bindAll(this, 'render', 'submit', 'change', 'clearStatus', 'invalid', 'error', 'sync');
_.bindAll(this, 'render', 'submit', 'change', 'click', 'clearStatus', 'invalid', 'error', 'sync');
this.model = new edx.student.account.AccountModel();
this.model.on('invalid', this.invalid);
this.model.on('error', this.error);
......@@ -89,6 +90,9 @@ var edx = edx || {};
this.$emailStatus = $('#new-email-status', this.$el);
this.$passwordStatus = $('#password-status', this.$el);
this.$requestStatus = $('#request-email-status', this.$el);
this.$passwordReset = $('#password-reset', this.$el);
this.$passwordResetStatus = $('#password-reset-status', this.$el);
return this;
},
......@@ -105,6 +109,31 @@ var edx = edx || {};
});
},
click: function(event) {
event.preventDefault();
this.clearStatus();
self = this;
$.ajax({
url: 'password',
type: 'POST',
data: {},
headers: {
'X-CSRFToken': $.cookie('csrftoken')
}
})
.done(function() {
self.$passwordResetStatus
.addClass('success')
.text(gettext("Password reset email sent. Follow the link in the email to change your password."));
})
.fail(function() {
self.$passwordResetStatus
.addClass('error')
.text(gettext("We weren't able to send you a password reset email."));
});
},
invalid: function(model) {
var errors = model.validationError;
......@@ -145,6 +174,10 @@ var edx = edx || {};
this.$requestStatus
.removeClass('error')
.text("");
this.$passwordResetStatus
.removeClass('error')
.text("");
},
});
......
<form id="email-change-form" method="post">
<label for="new-email"><%- gettext('New Address') %></label>
<label for="new-email"><%- gettext("New Address") %></label>
<input id="new-email" type="text" name="new-email" value="" placeholder="xsy@edx.org" data-validate="required email"/>
<div id="new-email-status" />
<label for="password"><%- gettext('Password') %></label>
<label for="password"><%- gettext("Password") %></label>
<input id="password" type="password" name="password" value="" data-validate="required"/>
<div id="password-status" />
<div class="submit-button">
<input type="submit" id="email-change-submit" value="<%- gettext('Change My Email Address') %>">
<input type="submit" id="email-change-submit" value="<%- gettext("Change My Email Address") %>">
</div>
<div id="request-email-status" />
<div id="password-reset">
<a href="#"><%- gettext("Reset Password") %></a>
</div>
<div id="password-reset-status" />
</form>
......@@ -23,4 +23,4 @@
<p>This is a placeholder for the student's account page.</p>
<div id="account-container" />
<div id="account-container"></div>
......@@ -19,7 +19,7 @@
% endfor
</%block>
<h1>${_("Student Profile")}</h1>
<h1>Student Profile</h1>
<p>This is a placeholder for the student's profile page.</p>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment