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.
......
# -*- coding: utf-8 -*-
""" Tests for the account API. """
import unittest
import re
from unittest import skipUnless
from nose.tools import raises
from mock import patch
import ddt
from dateutil.parser import parse as parse_datetime
from django.conf import settings
from django.core import mail
from django.test import TestCase
from django.conf import settings
from user_api.api import account as account_api
from user_api.models import UserProfile
......@@ -14,43 +19,46 @@ from user_api.models import UserProfile
@ddt.ddt
class AccountApiTest(TestCase):
USERNAME = u"frank-underwood"
PASSWORD = u"ṕáśśẃőŕd"
EMAIL = u"frank+underwood@example.com"
USERNAME = u'frank-underwood'
PASSWORD = u'ṕáśśẃőŕd'
EMAIL = u'frank+underwood@example.com'
ORIG_HOST = 'example.com'
IS_SECURE = False
INVALID_USERNAMES = [
None,
u"",
u"a",
u"a" * (account_api.USERNAME_MAX_LENGTH + 1),
u"invalid_symbol_@",
u"invalid-unicode_fŕáńḱ",
u'',
u'a',
u'a' * (account_api.USERNAME_MAX_LENGTH + 1),
u'invalid_symbol_@',
u'invalid-unicode_fŕáńḱ',
]
INVALID_EMAILS = [
None,
u"",
u"a",
"no_domain",
"no+domain",
"@",
"@domain.com",
"test@no_extension",
u"fŕáńḱ@example.com",
u"frank@éxáḿṕĺé.ćőḿ",
u'',
u'a',
'no_domain',
'no+domain',
'@',
'@domain.com',
'test@no_extension',
u'fŕáńḱ@example.com',
u'frank@éxáḿṕĺé.ćőḿ',
# Long email -- subtract the length of the @domain
# except for one character (so we exceed the max length limit)
u"{user}@example.com".format(
u'{user}@example.com'.format(
user=(u'e' * (account_api.EMAIL_MAX_LENGTH - 11))
)
]
INVALID_PASSWORDS = [
None,
u"",
u"a",
u"a" * (account_api.PASSWORD_MAX_LENGTH + 1)
u'',
u'a',
u'a' * (account_api.PASSWORD_MAX_LENGTH + 1)
]
def test_activate_account(self):
......@@ -72,7 +80,7 @@ class AccountApiTest(TestCase):
# Request an email change
account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
activation_key = account_api.request_email_change(
self.USERNAME, u"new+email@example.com", self.PASSWORD
self.USERNAME, u'new+email@example.com', self.PASSWORD
)
# Verify that the email has not yet changed
......@@ -82,24 +90,23 @@ class AccountApiTest(TestCase):
# Confirm the change, using the activation code
old_email, new_email = account_api.confirm_email_change(activation_key)
self.assertEqual(old_email, self.EMAIL)
self.assertEqual(new_email, u"new+email@example.com")
self.assertEqual(new_email, u'new+email@example.com')
# Verify that the email is changed
account = account_api.account_info(self.USERNAME)
self.assertEqual(account['email'], u"new+email@example.com")
self.assertEqual(account['email'], u'new+email@example.com')
def test_confirm_email_change_repeat(self):
account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
activation_key = account_api.request_email_change(
self.USERNAME, u"new+email@example.com", self.PASSWORD
self.USERNAME, u'new+email@example.com', self.PASSWORD
)
# Confirm the change once
account_api.confirm_email_change(activation_key)
# Confirm the change again
# The activation code should be single-use
# so this should raise an error.
# Confirm the change again. The activation code should be
# single-use, so this should raise an error.
with self.assertRaises(account_api.AccountNotAuthorized):
account_api.confirm_email_change(activation_key)
......@@ -110,11 +117,11 @@ class AccountApiTest(TestCase):
# Email uniqueness constraints were introduced in a database migration,
# which we disable in the unit tests to improve the speed of the test suite.
@unittest.skipUnless(settings.SOUTH_TESTS_MIGRATE, "South migrations required")
@skipUnless(settings.SOUTH_TESTS_MIGRATE, "South migrations required")
def test_create_account_duplicate_email(self):
account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
with self.assertRaises(account_api.AccountUserAlreadyExists):
account_api.create_account("different_user", self.PASSWORD, self.EMAIL)
account_api.create_account('different_user', self.PASSWORD, self.EMAIL)
def test_username_too_long(self):
long_username = 'e' * (account_api.USERNAME_MAX_LENGTH + 1)
......@@ -122,7 +129,7 @@ class AccountApiTest(TestCase):
account_api.create_account(long_username, self.PASSWORD, self.EMAIL)
def test_account_info_no_user(self):
self.assertIs(account_api.account_info("does_not_exist"), None)
self.assertIs(account_api.account_info('does_not_exist'), None)
@raises(account_api.AccountEmailInvalid)
@ddt.data(*INVALID_EMAILS)
......@@ -146,11 +153,11 @@ class AccountApiTest(TestCase):
@raises(account_api.AccountNotAuthorized)
def test_activate_account_invalid_key(self):
account_api.activate_account(u"invalid")
account_api.activate_account(u'invalid')
@raises(account_api.AccountUserNotFound)
def test_request_email_change_no_user(self):
account_api.request_email_change(u"no_such_user", self.EMAIL, self.PASSWORD)
account_api.request_email_change(u'no_such_user', self.EMAIL, self.PASSWORD)
@ddt.data(*INVALID_EMAILS)
def test_request_email_change_invalid_email(self, invalid_email):
......@@ -165,22 +172,22 @@ class AccountApiTest(TestCase):
# Create two accounts, both activated
activation_key = account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
account_api.activate_account(activation_key)
activation_key = account_api.create_account(u"another_user", u"password", u"another+user@example.com")
activation_key = account_api.create_account(u'another_user', u'password', u'another+user@example.com')
account_api.activate_account(activation_key)
# Try to change the first user's email to the same as the second user's
with self.assertRaises(account_api.AccountEmailAlreadyExists):
account_api.request_email_change(self.USERNAME, u"another+user@example.com", self.PASSWORD)
account_api.request_email_change(self.USERNAME, u'another+user@example.com', self.PASSWORD)
def test_request_email_change_duplicates_unactivated_account(self):
# Create two accounts, but the second account is inactive
activation_key = account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
account_api.activate_account(activation_key)
account_api.create_account(u"another_user", u"password", u"another+user@example.com")
account_api.create_account(u'another_user', u'password', u'another+user@example.com')
# Try to change the first user's email to the same as the second user's
# Since the second user has not yet activated, this should succeed.
account_api.request_email_change(self.USERNAME, u"another+user@example.com", self.PASSWORD)
account_api.request_email_change(self.USERNAME, u'another+user@example.com', self.PASSWORD)
def test_request_email_change_same_address(self):
# Create and activate the account
......@@ -196,14 +203,14 @@ class AccountApiTest(TestCase):
# Use the wrong password
with self.assertRaises(account_api.AccountNotAuthorized):
account_api.request_email_change(self.USERNAME, u"new+email@example.com", u"wrong password")
account_api.request_email_change(self.USERNAME, u'new+email@example.com', u'wrong password')
def test_confirm_email_change_invalid_activation_key(self):
account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
account_api.request_email_change(self.USERNAME, u"new+email@example.com", self.PASSWORD)
account_api.request_email_change(self.USERNAME, u'new+email@example.com', self.PASSWORD)
with self.assertRaises(account_api.AccountNotAuthorized):
account_api.confirm_email_change(u"invalid")
account_api.confirm_email_change(u'invalid')
def test_confirm_email_change_no_request_pending(self):
account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
......@@ -213,11 +220,11 @@ class AccountApiTest(TestCase):
# Request a change
activation_key = account_api.request_email_change(
self.USERNAME, u"new+email@example.com", self.PASSWORD
self.USERNAME, u'new+email@example.com', self.PASSWORD
)
# Another use takes the email before we confirm the change
account_api.create_account(u"other_user", u"password", u"new+email@example.com")
account_api.create_account(u'other_user', u'password', u'new+email@example.com')
# When we try to confirm our change, we get an error because the email is taken
with self.assertRaises(account_api.AccountEmailAlreadyExists):
......@@ -229,7 +236,7 @@ class AccountApiTest(TestCase):
def test_confirm_email_no_user_profile(self):
account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
activation_key = account_api.request_email_change(
self.USERNAME, u"new+email@example.com", self.PASSWORD
self.USERNAME, u'new+email@example.com', self.PASSWORD
)
# This should never happen, but just in case...
......@@ -243,7 +250,7 @@ class AccountApiTest(TestCase):
# Change the email once
activation_key = account_api.request_email_change(
self.USERNAME, u"new+email@example.com", self.PASSWORD
self.USERNAME, u'new+email@example.com', self.PASSWORD
)
account_api.confirm_email_change(activation_key)
......@@ -256,7 +263,7 @@ class AccountApiTest(TestCase):
# Change the email again
activation_key = account_api.request_email_change(
self.USERNAME, u"another_new+email@example.com", self.PASSWORD
self.USERNAME, u'another_new+email@example.com', self.PASSWORD
)
account_api.confirm_email_change(activation_key)
......@@ -264,9 +271,39 @@ class AccountApiTest(TestCase):
meta = UserProfile.objects.get(user__username=self.USERNAME).get_meta()
self.assertEqual(len(meta['old_emails']), 2)
email, timestamp = meta['old_emails'][1]
self.assertEqual(email, "new+email@example.com")
self.assertEqual(email, 'new+email@example.com')
self._assert_is_datetime(timestamp)
@skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in LMS')
def test_request_password_change(self):
# Create and activate an account
activation_key = account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
account_api.activate_account(activation_key)
# Request a password change
account_api.request_password_change(self.EMAIL, self.ORIG_HOST, self.IS_SECURE)
# Verify that one email message has been sent
self.assertEqual(len(mail.outbox), 1)
# Verify that the body of the message contains something that looks
# like an activation link
email_body = mail.outbox[0].body
result = re.search('(?P<url>https?://[^\s]+)', email_body)
self.assertIsNot(result, None)
@raises(account_api.AccountUserNotFound)
@ddt.data(True, False)
def test_request_password_change_invalid_user(self, create_inactive_account):
if create_inactive_account:
# Create an account, but do not activate it
account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
account_api.request_password_change(self.EMAIL, self.ORIG_HOST, self.IS_SECURE)
# Verify that no email messages have been sent
self.assertEqual(len(mail.outbox), 0)
def _assert_is_datetime(self, timestamp):
if not timestamp:
return False
......
......@@ -2,7 +2,9 @@
""" Tests for student account views. """
import re
from unittest import skipUnless
from urllib import urlencode
from mock import patch
import ddt
from django.test import TestCase
......@@ -13,6 +15,7 @@ from django.core import mail
from util.testing import UrlResetMixin
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
@ddt.ddt
......@@ -21,10 +24,13 @@ class StudentAccountViewTest(UrlResetMixin, TestCase):
USERNAME = u"heisenberg"
ALTERNATE_USERNAME = u"walt"
PASSWORD = u"ḅḷüëṡḳÿ"
OLD_PASSWORD = u"ḅḷüëṡḳÿ"
NEW_PASSWORD = u"🄱🄸🄶🄱🄻🅄🄴"
OLD_EMAIL = u"walter@graymattertech.com"
NEW_EMAIL = u"walt@savewalterwhite.com"
INVALID_ATTEMPTS = 100
INVALID_EMAILS = [
None,
u"",
......@@ -49,19 +55,19 @@ class StudentAccountViewTest(UrlResetMixin, TestCase):
super(StudentAccountViewTest, self).setUp()
# Create/activate a new account
activation_key = account_api.create_account(self.USERNAME, self.PASSWORD, self.OLD_EMAIL)
activation_key = account_api.create_account(self.USERNAME, self.OLD_PASSWORD, self.OLD_EMAIL)
account_api.activate_account(activation_key)
# Login
result = self.client.login(username=self.USERNAME, password=self.PASSWORD)
result = self.client.login(username=self.USERNAME, password=self.OLD_PASSWORD)
self.assertTrue(result)
def test_index(self):
response = self.client.get(reverse('account_index'))
self.assertContains(response, "Student Account")
def test_change_email(self):
response = self._change_email(self.NEW_EMAIL, self.PASSWORD)
def test_email_change(self):
response = self._change_email(self.NEW_EMAIL, self.OLD_PASSWORD)
self.assertEquals(response.status_code, 200)
# Verify that the email associated with the account remains unchanged
......@@ -73,8 +79,8 @@ class StudentAccountViewTest(UrlResetMixin, TestCase):
self._assert_email(
mail.outbox[0],
[self.NEW_EMAIL],
u'Email Change Request',
u'There was recently a request to change the email address'
u"Email Change Request",
u"There was recently a request to change the email address"
)
# Retrieve the activation key from the email
......@@ -96,48 +102,48 @@ class StudentAccountViewTest(UrlResetMixin, TestCase):
self._assert_email(
mail.outbox[1],
[self.OLD_EMAIL, self.NEW_EMAIL],
u'Email Change Successful',
u'You successfully changed the email address'
u"Email Change Successful",
u"You successfully changed the email address"
)
def test_email_change_wrong_password(self):
response = self._change_email(self.NEW_EMAIL, "wrong password")
self.assertEqual(response.status_code, 401)
def test_email_change_request_internal_error(self):
def test_email_change_request_no_user(self):
# Patch account API to raise an internal error when an email change is requested
with patch('student_account.views.account_api.request_email_change') as mock_call:
mock_call.side_effect = account_api.AccountUserNotFound
response = self._change_email(self.NEW_EMAIL, self.PASSWORD)
response = self._change_email(self.NEW_EMAIL, self.OLD_PASSWORD)
self.assertEquals(response.status_code, 500)
self.assertEquals(response.status_code, 400)
def test_email_change_request_email_taken_by_active_account(self):
# Create/activate a second user with the new email
activation_key = account_api.create_account(self.ALTERNATE_USERNAME, self.PASSWORD, self.NEW_EMAIL)
activation_key = account_api.create_account(self.ALTERNATE_USERNAME, self.OLD_PASSWORD, self.NEW_EMAIL)
account_api.activate_account(activation_key)
# Request to change the original user's email to the email now used by the second user
response = self._change_email(self.NEW_EMAIL, self.PASSWORD)
response = self._change_email(self.NEW_EMAIL, self.OLD_PASSWORD)
self.assertEquals(response.status_code, 409)
def test_email_change_request_email_taken_by_inactive_account(self):
# Create a second user with the new email, but don't active them
account_api.create_account(self.ALTERNATE_USERNAME, self.PASSWORD, self.NEW_EMAIL)
account_api.create_account(self.ALTERNATE_USERNAME, self.OLD_PASSWORD, self.NEW_EMAIL)
# Request to change the original user's email to the email used by the inactive user
response = self._change_email(self.NEW_EMAIL, self.PASSWORD)
response = self._change_email(self.NEW_EMAIL, self.OLD_PASSWORD)
self.assertEquals(response.status_code, 200)
@ddt.data(*INVALID_EMAILS)
def test_email_change_request_email_invalid(self, invalid_email):
# Request to change the user's email to an invalid address
response = self._change_email(invalid_email, self.PASSWORD)
response = self._change_email(invalid_email, self.OLD_PASSWORD)
self.assertEquals(response.status_code, 400)
def test_email_change_confirmation(self):
# Get an email change activation key
activation_key = account_api.request_email_change(self.USERNAME, self.NEW_EMAIL, self.PASSWORD)
activation_key = account_api.request_email_change(self.USERNAME, self.NEW_EMAIL, self.OLD_PASSWORD)
# Follow the link sent in the confirmation email
response = self.client.get(reverse('email_change_confirm', kwargs={'key': activation_key}))
......@@ -158,10 +164,10 @@ class StudentAccountViewTest(UrlResetMixin, TestCase):
def test_email_change_confirmation_email_already_exists(self):
# Get an email change activation key
email_activation_key = account_api.request_email_change(self.USERNAME, self.NEW_EMAIL, self.PASSWORD)
email_activation_key = account_api.request_email_change(self.USERNAME, self.NEW_EMAIL, self.OLD_PASSWORD)
# Create/activate a second user with the new email
account_activation_key = account_api.create_account(self.ALTERNATE_USERNAME, self.PASSWORD, self.NEW_EMAIL)
account_activation_key = account_api.create_account(self.ALTERNATE_USERNAME, self.OLD_PASSWORD, self.NEW_EMAIL)
account_api.activate_account(account_activation_key)
# Follow the link sent to the original user
......@@ -174,7 +180,7 @@ class StudentAccountViewTest(UrlResetMixin, TestCase):
def test_email_change_confirmation_internal_error(self):
# Get an email change activation key
activation_key = account_api.request_email_change(self.USERNAME, self.NEW_EMAIL, self.PASSWORD)
activation_key = account_api.request_email_change(self.USERNAME, self.NEW_EMAIL, self.OLD_PASSWORD)
# Patch account API to return an internal error
with patch('student_account.views.account_api.confirm_email_change') as mock_call:
......@@ -183,14 +189,120 @@ class StudentAccountViewTest(UrlResetMixin, TestCase):
self.assertContains(response, "Something went wrong")
def test_change_email_request_missing_email_param(self):
response = self._change_email(None, self.PASSWORD)
def test_email_change_request_missing_email_param(self):
response = self._change_email(None, self.OLD_PASSWORD)
self.assertEqual(response.status_code, 400)
def test_change_email_request_missing_password_param(self):
def test_email_change_request_missing_password_param(self):
response = self._change_email(self.OLD_EMAIL, None)
self.assertEqual(response.status_code, 400)
@skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in LMS')
def test_password_change(self):
# Request a password change while logged in, simulating
# use of the password reset link from the account page
response = self._change_password()
self.assertEqual(response.status_code, 200)
# Check that an email was sent
self.assertEqual(len(mail.outbox), 1)
# Retrieve the activation link from the email body
email_body = mail.outbox[0].body
result = re.search('(?P<url>https?://[^\s]+)', email_body)
self.assertIsNot(result, None)
activation_link = result.group('url')
# Visit the activation link
response = self.client.get(activation_link)
self.assertEqual(response.status_code, 200)
# Submit a new password and follow the redirect to the success page
response = self.client.post(
activation_link,
# These keys are from the form on the current password reset confirmation page.
{'new_password1': self.NEW_PASSWORD, 'new_password2': self.NEW_PASSWORD},
follow=True
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Your password has been set.")
# Log the user out to clear session data
self.client.logout()
# Verify that the new password can be used to log in
result = self.client.login(username=self.USERNAME, password=self.NEW_PASSWORD)
self.assertTrue(result)
# Try reusing the activation link to change the password again
response = self.client.post(
activation_link,
{'new_password1': self.OLD_PASSWORD, 'new_password2': self.OLD_PASSWORD},
follow=True
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "The password reset link was invalid, possibly because the link has already been used.")
self.client.logout()
# Verify that the old password cannot be used to log in
result = self.client.login(username=self.USERNAME, password=self.OLD_PASSWORD)
self.assertFalse(result)
# Verify that the new password continues to be valid
result = self.client.login(username=self.USERNAME, password=self.NEW_PASSWORD)
self.assertTrue(result)
@ddt.data(True, False)
def test_password_change_logged_out(self, send_email):
# Log the user out
self.client.logout()
# Request a password change while logged out, simulating
# use of the password reset link from the login page
if send_email:
response = self._change_password(email=self.OLD_EMAIL)
self.assertEqual(response.status_code, 200)
else:
# Don't send an email in the POST data, simulating
# its (potentially accidental) omission in the POST
# data sent from the login page
response = self._change_password()
self.assertEqual(response.status_code, 400)
def test_password_change_inactive_user(self):
# Log out the user created during test setup
self.client.logout()
# Create a second user, but do not activate it
account_api.create_account(self.ALTERNATE_USERNAME, self.OLD_PASSWORD, self.NEW_EMAIL)
# Send the view the email address tied to the inactive user
response = self._change_password(email=self.NEW_EMAIL)
self.assertEqual(response.status_code, 400)
def test_password_change_no_user(self):
# Log out the user created during test setup
self.client.logout()
# Send the view an email address not tied to any user
response = self._change_password(email=self.NEW_EMAIL)
self.assertEqual(response.status_code, 400)
def test_password_change_rate_limited(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 attempt in xrange(self.INVALID_ATTEMPTS):
self._change_password(email=self.NEW_EMAIL)
response = self._change_password(email=self.NEW_EMAIL)
self.assertEqual(response.status_code, 403)
@ddt.data(
('get', 'account_index', []),
('post', 'email_change_request', []),
......@@ -210,7 +322,8 @@ class StudentAccountViewTest(UrlResetMixin, TestCase):
@ddt.data(
('get', 'account_index', []),
('post', 'email_change_request', []),
('get', 'email_change_confirm', [123])
('get', 'email_change_confirm', [123]),
('post', 'password_change_request', []),
)
@ddt.unpack
def test_require_http_method(self, correct_method, url_name, args):
......@@ -238,3 +351,12 @@ class StudentAccountViewTest(UrlResetMixin, TestCase):
data['password'] = password.encode('utf-8')
return self.client.post(path=reverse('email_change_request'), data=data)
def _change_password(self, email=None):
"""Request to change the user's password. """
data = {}
if email:
data['email'] = email
return self.client.post(path=reverse('password_change_request'), data=data)
......@@ -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