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)
......@@ -130,7 +135,7 @@ def email_change_confirmation_handler(request, key):
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