""" Test the various password reset flows """ import json import re import unittest import ddt from django.conf import settings from django.contrib.auth.hashers import UNUSABLE_PASSWORD_PREFIX from django.contrib.auth.models import User from django.contrib.auth.tokens import default_token_generator from django.core.cache import cache from django.core.urlresolvers import reverse from django.test.client import RequestFactory from django.test.utils import override_settings from django.utils.http import int_to_base36 from edx_oauth2_provider.tests.factories import AccessTokenFactory, ClientFactory, RefreshTokenFactory from mock import Mock, patch from oauth2_provider import models as dot_models from provider.oauth2 import models as dop_models from openedx.core.djangoapps.oauth_dispatch.tests import factories as dot_factories from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.user_api.config.waffle import PREVENT_AUTH_USER_WRITES, SYSTEM_MAINTENANCE_MSG, waffle from openedx.core.djangolib.testing.utils import CacheIsolationTestCase from student.tests.factories import UserFactory from student.tests.test_email import mock_render_to_string from student.views import SETTING_CHANGE_INITIATED, password_reset, password_reset_confirm_wrapper from util.testing import EventTestMixin from .test_configuration_overrides import fake_get_value @unittest.skipUnless( settings.ROOT_URLCONF == "lms.urls", "reset password tests should only run in LMS" ) @ddt.ddt class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase): """ Tests that clicking reset password sends email, and doesn't activate the user """ request_factory = RequestFactory() ENABLED_CACHES = ['default'] def setUp(self): super(ResetPasswordTests, self).setUp('student.views.management.tracker') self.user = UserFactory.create() self.user.is_active = False self.user.save() self.token = default_token_generator.make_token(self.user) self.uidb36 = int_to_base36(self.user.id) self.user_bad_passwd = UserFactory.create() self.user_bad_passwd.is_active = False self.user_bad_passwd.password = UNUSABLE_PASSWORD_PREFIX self.user_bad_passwd.save() @patch('student.views.management.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) def test_user_bad_password_reset(self): """Tests password reset behavior for user with password marked UNUSABLE_PASSWORD_PREFIX""" bad_pwd_req = self.request_factory.post('/password_reset/', {'email': self.user_bad_passwd.email}) bad_pwd_resp = password_reset(bad_pwd_req) # If they've got an unusable password, we return a successful response code self.assertEquals(bad_pwd_resp.status_code, 200) obj = json.loads(bad_pwd_resp.content) self.assertEquals(obj, { 'success': True, 'value': "('registration/password_reset_done.html', [])", }) self.assert_no_events_were_emitted() @patch('student.views.management.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) def test_nonexist_email_password_reset(self): """Now test the exception cases with of reset_password called with invalid email.""" bad_email_req = self.request_factory.post('/password_reset/', {'email': self.user.email + "makeItFail"}) bad_email_resp = password_reset(bad_email_req) # Note: even if the email is bad, we return a successful response code # This prevents someone potentially trying to "brute-force" find out which # emails are and aren't registered with edX self.assertEquals(bad_email_resp.status_code, 200) obj = json.loads(bad_email_resp.content) self.assertEquals(obj, { 'success': True, 'value': "('registration/password_reset_done.html', [])", }) self.assert_no_events_were_emitted() @patch('student.views.management.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) def test_password_reset_ratelimited(self): """ Try (and fail) resetting password 30 times in a row on an non-existant email address """ cache.clear() for i in xrange(30): good_req = self.request_factory.post('/password_reset/', { 'email': 'thisdoesnotexist{0}@foo.com'.format(i) }) good_resp = password_reset(good_req) self.assertEquals(good_resp.status_code, 200) # then the rate limiter should kick in and give a HttpForbidden response bad_req = self.request_factory.post('/password_reset/', {'email': 'thisdoesnotexist@foo.com'}) bad_resp = password_reset(bad_req) self.assertEquals(bad_resp.status_code, 403) self.assert_no_events_were_emitted() cache.clear() @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', "Test only valid in LMS") @patch('django.core.mail.send_mail') @patch('student.views.management.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) def test_reset_password_email(self, send_email): """Tests contents of reset password email, and that user is not active""" good_req = self.request_factory.post('/password_reset/', {'email': self.user.email}) good_req.user = self.user dop_client = ClientFactory() dop_access_token = AccessTokenFactory(user=self.user, client=dop_client) RefreshTokenFactory(user=self.user, client=dop_client, access_token=dop_access_token) dot_application = dot_factories.ApplicationFactory(user=self.user) dot_access_token = dot_factories.AccessTokenFactory(user=self.user, application=dot_application) dot_factories.RefreshTokenFactory(user=self.user, application=dot_application, access_token=dot_access_token) good_resp = password_reset(good_req) self.assertEquals(good_resp.status_code, 200) self.assertFalse(dop_models.AccessToken.objects.filter(user=self.user).exists()) self.assertFalse(dop_models.RefreshToken.objects.filter(user=self.user).exists()) self.assertFalse(dot_models.AccessToken.objects.filter(user=self.user).exists()) self.assertFalse(dot_models.RefreshToken.objects.filter(user=self.user).exists()) obj = json.loads(good_resp.content) self.assertEquals(obj, { 'success': True, 'value': "('registration/password_reset_done.html', [])", }) (subject, msg, from_addr, to_addrs) = send_email.call_args[0] self.assertIn("Password reset", subject) self.assertIn("You're receiving this e-mail because you requested a password reset", msg) self.assertEquals(from_addr, configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL)) self.assertEquals(len(to_addrs), 1) self.assertIn(self.user.email, to_addrs) self.assert_event_emitted( SETTING_CHANGE_INITIATED, user_id=self.user.id, setting=u'password', old=None, new=None, ) #test that the user is not active self.user = User.objects.get(pk=self.user.pk) self.assertFalse(self.user.is_active) re.search(r'password_reset_confirm/(?P[0-9A-Za-z]+)-(?P.+)/', msg).groupdict() @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', "Test only valid in LMS") @patch('django.core.mail.send_mail') @ddt.data((False, 'http://'), (True, 'https://')) @ddt.unpack def test_reset_password_email_https(self, is_secure, protocol, send_email): """ Tests that the right url protocol is included in the reset password link """ req = self.request_factory.post( '/password_reset/', {'email': self.user.email} ) req.is_secure = Mock(return_value=is_secure) req.user = self.user password_reset(req) _, msg, _, _ = send_email.call_args[0] expected_msg = "Please go to the following page and choose a new password:\n\n" + protocol self.assertIn(expected_msg, msg) self.assert_event_emitted( SETTING_CHANGE_INITIATED, user_id=self.user.id, setting=u'password', old=None, new=None ) @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', "Test only valid in LMS") @patch('django.core.mail.send_mail') @ddt.data(('Crazy Awesome Site', 'Crazy Awesome Site'), ('edX', 'edX')) @ddt.unpack def test_reset_password_email_site(self, site_name, platform_name, send_email): """ Tests that the right url domain and platform name is included in the reset password email """ with patch("django.conf.settings.PLATFORM_NAME", platform_name): with patch("django.conf.settings.SITE_NAME", site_name): req = self.request_factory.post( '/password_reset/', {'email': self.user.email} ) req.user = self.user password_reset(req) _, msg, _, _ = send_email.call_args[0] reset_msg = "you requested a password reset for your user account at {}" reset_msg = reset_msg.format(site_name) self.assertIn(reset_msg, msg) sign_off = "The {} Team".format(platform_name) self.assertIn(sign_off, msg) self.assert_event_emitted( SETTING_CHANGE_INITIATED, user_id=self.user.id, setting=u'password', old=None, new=None ) @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', "Test only valid in LMS") @patch("openedx.core.djangoapps.site_configuration.helpers.get_value", fake_get_value) @patch('django.core.mail.send_mail') def test_reset_password_email_configuration_override(self, send_email): """ Tests that the right url domain and platform name is included in the reset password email """ req = self.request_factory.post( '/password_reset/', {'email': self.user.email} ) req.get_host = Mock(return_value=None) req.user = self.user password_reset(req) _, msg, from_addr, _ = send_email.call_args[0] reset_msg = "you requested a password reset for your user account at {}".format(fake_get_value('platform_name')) self.assertIn(reset_msg, msg) self.assert_event_emitted( SETTING_CHANGE_INITIATED, user_id=self.user.id, setting=u'password', old=None, new=None ) self.assertEqual(from_addr, "no-reply@fakeuniversity.com") @ddt.data( ('invalidUid', 'invalid_token'), (None, 'invalid_token'), ('invalidUid', None), ) @ddt.unpack def test_reset_password_bad_token(self, uidb36, token): """Tests bad token and uidb36 in password reset""" if uidb36 is None: uidb36 = self.uidb36 if token is None: token = self.token bad_request = self.request_factory.get( reverse( "password_reset_confirm", kwargs={"uidb36": uidb36, "token": token} ) ) password_reset_confirm_wrapper(bad_request, uidb36, token) self.user = User.objects.get(pk=self.user.pk) self.assertFalse(self.user.is_active) def test_reset_password_good_token(self): """Tests good token and uidb36 in password reset""" url = reverse( "password_reset_confirm", kwargs={"uidb36": self.uidb36, "token": self.token} ) good_reset_req = self.request_factory.get(url) password_reset_confirm_wrapper(good_reset_req, self.uidb36, self.token) self.user = User.objects.get(pk=self.user.pk) self.assertTrue(self.user.is_active) def test_password_reset_fail(self): """Tests that if we provide mismatched passwords, user is not marked as active.""" self.assertFalse(self.user.is_active) url = reverse( 'password_reset_confirm', kwargs={'uidb36': self.uidb36, 'token': self.token} ) request_params = {'new_password1': 'password1', 'new_password2': 'password2'} confirm_request = self.request_factory.post(url, data=request_params) # Make a password reset request with mismatching passwords. resp = password_reset_confirm_wrapper(confirm_request, self.uidb36, self.token) # Verify the response status code is: 200 with password reset fail and also verify that # the user is not marked as active. self.assertEqual(resp.status_code, 200) self.assertFalse(User.objects.get(pk=self.user.pk).is_active) def test_password_reset_prevent_auth_user_writes(self): with waffle().override(PREVENT_AUTH_USER_WRITES, True): url = reverse( "password_reset_confirm", kwargs={"uidb36": self.uidb36, "token": self.token} ) for request in [self.request_factory.get(url), self.request_factory.post(url)]: response = password_reset_confirm_wrapper(request, self.uidb36, self.token) assert response.context_data['err_msg'] == SYSTEM_MAINTENANCE_MSG self.user.refresh_from_db() assert not self.user.is_active @override_settings(PASSWORD_MIN_LENGTH=2) @override_settings(PASSWORD_MAX_LENGTH=10) @ddt.data( { 'password': '1', 'error_message': 'Password: Invalid Length (must be 2 characters or more)', }, { 'password': '01234567891', 'error_message': 'Password: Invalid Length (must be 10 characters or fewer)' } ) def test_password_reset_with_invalid_length(self, password_dict): """Tests that if we provide password characters less then PASSWORD_MIN_LENGTH, or more than PASSWORD_MAX_LENGTH, password reset will fail with error message. """ url = reverse( 'password_reset_confirm', kwargs={'uidb36': self.uidb36, 'token': self.token} ) request_params = {'new_password1': password_dict['password'], 'new_password2': password_dict['password']} confirm_request = self.request_factory.post(url, data=request_params) # Make a password reset request with minimum/maximum passwords characters. response = password_reset_confirm_wrapper(confirm_request, self.uidb36, self.token) self.assertEqual(response.context_data['err_msg'], password_dict['error_message']) @patch('student.views.management.password_reset_confirm') @patch("openedx.core.djangoapps.site_configuration.helpers.get_value", fake_get_value) def test_reset_password_good_token_configuration_override(self, reset_confirm): """Tests password reset confirmation page for site configuration override.""" url = reverse( "password_reset_confirm", kwargs={"uidb36": self.uidb36, "token": self.token} ) good_reset_req = self.request_factory.get(url) password_reset_confirm_wrapper(good_reset_req, self.uidb36, self.token) confirm_kwargs = reset_confirm.call_args[1] self.assertEquals(confirm_kwargs['extra_context']['platform_name'], 'Fake University') self.user = User.objects.get(pk=self.user.pk) self.assertTrue(self.user.is_active) @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', "Test only valid in LMS") @patch('django.core.mail.send_mail') @ddt.data('Crazy Awesome Site', 'edX') def test_reset_password_email_subject(self, platform_name, send_email): """ Tests that the right platform name is included in the reset password email subject """ with patch("django.conf.settings.PLATFORM_NAME", platform_name): req = self.request_factory.post( '/password_reset/', {'email': self.user.email} ) req.user = self.user password_reset(req) subj, _, _, _ = send_email.call_args[0] self.assertIn(platform_name, subj)