diff --git a/common/djangoapps/user_api/api/account.py b/common/djangoapps/user_api/api/account.py index 4281326860678aafc93137faf1f96e88b7efbc5e..277e1bcb38cfdc1a97d718f565d7c1ec7e8c4300 100644 --- a/common/djangoapps/user_api/api/account.py +++ b/common/djangoapps/user_api/api/account.py @@ -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. diff --git a/common/djangoapps/user_api/tests/test_account_api.py b/common/djangoapps/user_api/tests/test_account_api.py index 1bcc1bbce8c3373d69f4a54ec6fe23e1ff920b37..2acfbd09cfe1e5d765d398dcd59a3e40fa876f69 100644 --- a/common/djangoapps/user_api/tests/test_account_api.py +++ b/common/djangoapps/user_api/tests/test_account_api.py @@ -1,12 +1,17 @@ # -*- 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 diff --git a/lms/djangoapps/student_account/test/test_views.py b/lms/djangoapps/student_account/test/test_views.py index 0b2447c3ea42909d6ef2e16475e2eac6db75667a..f3dc650b056f4200708544015ac77db24f3b7ea4 100644 --- a/lms/djangoapps/student_account/test/test_views.py +++ b/lms/djangoapps/student_account/test/test_views.py @@ -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) diff --git a/lms/djangoapps/student_account/urls.py b/lms/djangoapps/student_account/urls.py index bb5a0d5690d8c987bd17eee7baf8e8c4d73eb0ee..7ffeae55207aee65c30b70876abee701cf142e6a 100644 --- a/lms/djangoapps/student_account/urls.py +++ b/lms/djangoapps/student_account/urls.py @@ -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'), ) diff --git a/lms/djangoapps/student_account/views.py b/lms/djangoapps/student_account/views.py index b20e19d0519c3b9a8e1856d51a03cf2cfd8de4a1..bb6d60cb366007a32c170a2098100683217cda1b 100644 --- a/lms/djangoapps/student_account/views.py +++ b/lms/djangoapps/student_account/views.py @@ -1,9 +1,11 @@ """ 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.") diff --git a/lms/static/js/spec/student_account/account.js b/lms/static/js/spec/student_account/account.js index 596107f4154a5b638484206b36886fa44ab75546..3262192baf4f04a379b03a07998e6adeeb05d2df 100644 --- a/lms/static/js/spec/student_account/account.js +++ b/lms/static/js/spec/student_account/account.js @@ -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."); }); }); } diff --git a/lms/static/js/student_account/account.js b/lms/static/js/student_account/account.js index dda4fba60f9d8ac632197ba8a1d0b581ce221240..21906d9706b771e3c3e511e90a5068bec7fe3a52 100644 --- a/lms/static/js/student_account/account.js +++ b/lms/static/js/student_account/account.js @@ -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(""); }, }); diff --git a/lms/templates/student_account/account.underscore b/lms/templates/student_account/account.underscore index 9a6b91628f9b6944833b6602b15130ec74ad7965..7ba538434f957f6b3fd72db42f3451ff5afa0767 100644 --- a/lms/templates/student_account/account.underscore +++ b/lms/templates/student_account/account.underscore @@ -1,14 +1,19 @@ <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> diff --git a/lms/templates/student_account/index.html b/lms/templates/student_account/index.html index 21ac4f4dda840555cd38dbdb85e7e6abd96624d7..0b9ed36aa577ae75859fdade8ed11e801ca105d1 100644 --- a/lms/templates/student_account/index.html +++ b/lms/templates/student_account/index.html @@ -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> diff --git a/lms/templates/student_profile/index.html b/lms/templates/student_profile/index.html index aa46c69fcd354799f346c5bbaefdab030c0ba314..5566a46a1d8cd4c6c29cb64268815749763808d3 100644 --- a/lms/templates/student_profile/index.html +++ b/lms/templates/student_profile/index.html @@ -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>