diff --git a/cms/envs/common.py b/cms/envs/common.py index 6fc52346cd..5945a1be3b 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -987,7 +987,7 @@ INSTALLED_APPS = [ 'openedx.core.djangoapps.contentserver', 'course_creators', 'openedx.core.djangoapps.external_auth', - 'student', # misleading name due to sharing with lms + 'student.apps.StudentConfig', # misleading name due to sharing with lms 'openedx.core.djangoapps.course_groups', # not used in cms (yet), but tests run 'xblock_config.apps.XBlockConfig', diff --git a/common/djangoapps/student/apps.py b/common/djangoapps/student/apps.py new file mode 100644 index 0000000000..32520dceb4 --- /dev/null +++ b/common/djangoapps/student/apps.py @@ -0,0 +1,20 @@ +""" +Configuration for the ``student`` Django application. +""" +from __future__ import absolute_import + +from django.apps import AppConfig +from django.contrib.auth.signals import user_logged_in + + +class StudentConfig(AppConfig): + """ + Default configuration for the ``student`` application. + """ + name = 'student' + + def ready(self): + from django.contrib.auth.models import update_last_login as django_update_last_login + user_logged_in.disconnect(django_update_last_login) + from .signals.receivers import update_last_login + user_logged_in.connect(update_last_login) diff --git a/common/djangoapps/student/signals/receivers.py b/common/djangoapps/student/signals/receivers.py new file mode 100644 index 0000000000..c3aac7879f --- /dev/null +++ b/common/djangoapps/student/signals/receivers.py @@ -0,0 +1,19 @@ +""" +Signal receivers for the "student" application. +""" +from __future__ import absolute_import + +from django.utils import timezone + +from openedx.core.djangoapps.user_api.config.waffle import PREVENT_AUTH_USER_WRITES, waffle + + +def update_last_login(sender, user, **kwargs): # pylint: disable=unused-argument + """ + Replacement for Django's ``user_logged_in`` signal handler that knows not + to attempt updating the ``last_login`` field when we're trying to avoid + writes to the ``auth_user`` table while running a migration. + """ + if not waffle().is_enabled(PREVENT_AUTH_USER_WRITES): + user.last_login = timezone.now() + user.save(update_fields=['last_login']) diff --git a/common/djangoapps/student/tests/test_activate_account.py b/common/djangoapps/student/tests/test_activate_account.py index 0dea4faa23..30cd2e1a7a 100644 --- a/common/djangoapps/student/tests/test_activate_account.py +++ b/common/djangoapps/student/tests/test_activate_account.py @@ -9,6 +9,7 @@ from mock import patch from edxmako.shortcuts import render_to_string 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 student.models import Registration from student.tests.factories import UserFactory @@ -199,3 +200,14 @@ class TestActivateAccount(TestCase): response = self.client.get(reverse('activate', args=[uuid4().hex]), follow=True) self.assertRedirects(response, login_page_url) self.assertContains(response, 'Your account could not be activated') + + def test_account_activation_prevent_auth_user_writes(self): + login_page_url = "{login_url}?next={redirect_url}".format( + login_url=reverse('signin_user'), + redirect_url=reverse('dashboard'), + ) + with waffle().override(PREVENT_AUTH_USER_WRITES, True): + response = self.client.get(reverse('activate', args=[self.registration.activation_key]), follow=True) + self.assertRedirects(response, login_page_url) + self.assertContains(response, SYSTEM_MAINTENANCE_MSG) + assert not self.user.is_active diff --git a/common/djangoapps/student/tests/test_create_account.py b/common/djangoapps/student/tests/test_create_account.py index 751046ee27..0241e484a2 100644 --- a/common/djangoapps/student/tests/test_create_account.py +++ b/common/djangoapps/student/tests/test_create_account.py @@ -14,9 +14,7 @@ from django.core.urlresolvers import reverse from django.test import TestCase, TransactionTestCase from django.test.client import RequestFactory from django.test.utils import override_settings -from mock import patch -import student from django_comment_common.models import ForumsConfig from notification_prefs import NOTIFICATION_PREF_KEY from openedx.core.djangoapps.external_auth.models import ExternalAuthMap @@ -25,10 +23,11 @@ from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin from openedx.core.djangoapps.user_api.accounts import ( USERNAME_BAD_LENGTH_MSG, USERNAME_INVALID_CHARS_ASCII, USERNAME_INVALID_CHARS_UNICODE ) +from openedx.core.djangoapps.user_api.config.waffle import PREVENT_AUTH_USER_WRITES, waffle from openedx.core.djangoapps.user_api.preferences.api import get_user_preference from student.models import UserAttribute from student.views import REGISTRATION_AFFILIATE_ID, REGISTRATION_UTM_CREATED_AT, REGISTRATION_UTM_PARAMETERS, \ - skip_activation_email + create_account, skip_activation_email from student.tests.factories import UserFactory from third_party_auth.tests import factories as third_party_auth_factory @@ -279,7 +278,7 @@ class TestCreateAccount(SiteMixin, TestCase): with mock.patch('edxmako.request_context.get_current_request', return_value=request): with mock.patch('django.core.mail.send_mail') as mock_send_mail: - student.views.create_account(request) + create_account(request) # check that send_mail is called if bypass_activation_email: @@ -288,7 +287,8 @@ class TestCreateAccount(SiteMixin, TestCase): self.assertTrue(mock_send_mail.called) @unittest.skipUnless(settings.FEATURES.get('AUTH_USE_SHIB'), "AUTH_USE_SHIB not set") - @mock.patch.dict(settings.FEATURES, {'BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH': True, 'AUTOMATIC_AUTH_FOR_TESTING': False}) + @mock.patch.dict(settings.FEATURES, + {'BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH': True, 'AUTOMATIC_AUTH_FOR_TESTING': False}) def test_extauth_bypass_sending_activation_email_with_bypass(self): """ Tests user creation without sending activation email when @@ -297,7 +297,8 @@ class TestCreateAccount(SiteMixin, TestCase): self.base_extauth_bypass_sending_activation_email(True) @unittest.skipUnless(settings.FEATURES.get('AUTH_USE_SHIB'), "AUTH_USE_SHIB not set") - @mock.patch.dict(settings.FEATURES, {'BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH': False, 'AUTOMATIC_AUTH_FOR_TESTING': False}) + @mock.patch.dict(settings.FEATURES, + {'BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH': False, 'AUTOMATIC_AUTH_FOR_TESTING': False}) def test_extauth_bypass_sending_activation_email_without_bypass_1(self): """ Tests user creation without sending activation email when @@ -306,7 +307,8 @@ class TestCreateAccount(SiteMixin, TestCase): self.base_extauth_bypass_sending_activation_email(False) @unittest.skipUnless(settings.FEATURES.get('AUTH_USE_SHIB'), "AUTH_USE_SHIB not set") - @mock.patch.dict(settings.FEATURES, {'BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH': False, 'AUTOMATIC_AUTH_FOR_TESTING': False, 'SKIP_EMAIL_VALIDATION': True}) + @mock.patch.dict(settings.FEATURES, {'BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH': False, + 'AUTOMATIC_AUTH_FOR_TESTING': False, 'SKIP_EMAIL_VALIDATION': True}) def test_extauth_bypass_sending_activation_email_without_bypass_2(self): """ Tests user creation without sending activation email when @@ -395,8 +397,8 @@ class TestCreateAccount(SiteMixin, TestCase): instance = config.return_value instance.utm_cookie_name = utm_cookie_name - self.assertIsNone(self.client.cookies.get(settings.AFFILIATE_COOKIE_NAME)) # pylint: disable=no-member - self.assertIsNone(self.client.cookies.get(utm_cookie_name)) # pylint: disable=no-member + self.assertIsNone(self.client.cookies.get(settings.AFFILIATE_COOKIE_NAME)) + self.assertIsNone(self.client.cookies.get(utm_cookie_name)) user = self.create_account_and_fetch_profile().user self.assertIsNone(UserAttribute.get_user_attribute(user, REGISTRATION_AFFILIATE_ID)) self.assertIsNone(UserAttribute.get_user_attribute(user, REGISTRATION_UTM_PARAMETERS.get('utm_source'))) @@ -449,7 +451,7 @@ class TestCreateAccount(SiteMixin, TestCase): UserAttribute.get_user_attribute(user, REGISTRATION_UTM_CREATED_AT) ) - @patch("openedx.core.djangoapps.site_configuration.helpers.get_value", mock.Mock(return_value=False)) + @mock.patch("openedx.core.djangoapps.site_configuration.helpers.get_value", mock.Mock(return_value=False)) def test_create_account_not_allowed(self): """ Test case to check user creation is forbidden when ALLOW_PUBLIC_ACCOUNT_CREATION feature flag is turned off @@ -457,6 +459,11 @@ class TestCreateAccount(SiteMixin, TestCase): response = self.client.get(self.url) self.assertEqual(response.status_code, 403) + def test_create_account_prevent_auth_user_writes(self): + with waffle().override(PREVENT_AUTH_USER_WRITES, True): + response = self.client.get(self.url) + assert response.status_code == 403 + def test_created_on_site_user_attribute_set(self): profile = self.create_account_and_fetch_profile(host=self.site.domain) self.assertEqual(UserAttribute.get_user_attribute(profile.user, 'created_on_site'), self.site.domain) @@ -883,7 +890,7 @@ class TestUnicodeUsername(TestCase): 'honor_code': 'true', } - @patch.dict(settings.FEATURES, {'ENABLE_UNICODE_USERNAME': False}) + @mock.patch.dict(settings.FEATURES, {'ENABLE_UNICODE_USERNAME': False}) def test_with_feature_disabled(self): """ Ensures backward-compatible defaults. @@ -897,14 +904,14 @@ class TestUnicodeUsername(TestCase): with self.assertRaises(User.DoesNotExist): User.objects.get(email=self.url_params['email']) - @patch.dict(settings.FEATURES, {'ENABLE_UNICODE_USERNAME': True}) + @mock.patch.dict(settings.FEATURES, {'ENABLE_UNICODE_USERNAME': True}) def test_with_feature_enabled(self): response = self.client.post(self.url, self.url_params) self.assertEquals(response.status_code, 200) self.assertTrue(User.objects.get(email=self.url_params['email'])) - @patch.dict(settings.FEATURES, {'ENABLE_UNICODE_USERNAME': True}) + @mock.patch.dict(settings.FEATURES, {'ENABLE_UNICODE_USERNAME': True}) def test_special_chars_with_feature_enabled(self): """ Ensures that special chars are still prevented. diff --git a/common/djangoapps/student/tests/test_email.py b/common/djangoapps/student/tests/test_email.py index d70ddfd2b9..d2d6bba0e8 100644 --- a/common/djangoapps/student/tests/test_email.py +++ b/common/djangoapps/student/tests/test_email.py @@ -2,14 +2,13 @@ import json import unittest -import mock from django.conf import settings from django.contrib.auth.models import User from django.core import mail from django.core.urlresolvers import reverse from django.db import transaction from django.http import HttpResponse -from django.test import override_settings, TestCase, TransactionTestCase +from django.test import override_settings, TransactionTestCase from django.test.client import RequestFactory from mock import Mock, patch from six import text_type @@ -17,6 +16,7 @@ from six import text_type from edxmako.shortcuts import render_to_string from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.theming.tests.test_util import with_comprehensive_theme +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, CacheIsolationMixin from student.models import PendingEmailChange, Registration, UserProfile from student.tests.factories import PendingEmailChangeFactory, RegistrationFactory, UserFactory @@ -145,7 +145,7 @@ class ActivationEmailTests(CacheIsolationTestCase): for fragment in body_fragments: self.assertIn(fragment, msg.body) - @mock.patch('student.tasks.log') + @patch('student.tasks.log') def test_send_email_to_inactive_user(self, mock_log): """ Tests that when an inactive user logs-in using the social auth, system @@ -217,7 +217,7 @@ class ReactivationEmailTests(EmailTestMixin, CacheIsolationTestCase): self.assertReactivateEmailSent(email_user) self.assertFalse(response_data['success']) - def test_reactivation_for_unregistered_user(self, email_user): + def test_reactivation_for_unregistered_user(self, email_user): # pylint: disable=unused-argument """ Test that trying to send a reactivation email to an unregistered user fails without throwing a 500 error. @@ -226,7 +226,7 @@ class ReactivationEmailTests(EmailTestMixin, CacheIsolationTestCase): self.assertFalse(response_data['success']) - def test_reactivation_for_no_user_profile(self, email_user): + def test_reactivation_for_no_user_profile(self, email_user): # pylint: disable=unused-argument """ Test that trying to send a reactivation email to a user without user profile fails without throwing 500 error. @@ -496,6 +496,14 @@ class EmailChangeConfirmationTests(EmailTestMixin, CacheIsolationMixin, Transact ) self.assertEquals(0, PendingEmailChange.objects.count()) + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', "Test only valid in LMS") + def test_prevent_auth_user_writes(self, email_user): # pylint: disable=unused-argument + with waffle().override(PREVENT_AUTH_USER_WRITES, True): + self.check_confirm_email_change('email_change_failed.html', { + 'err_msg': SYSTEM_MAINTENANCE_MSG + }) + self.assertRolledBack() + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', "Test only valid in LMS") @override_settings(MKTG_URLS={'ROOT': 'https://dummy-root', 'CONTACT': '/help/contact-us'}) def test_marketing_contact_link(self, _email_user): diff --git a/common/djangoapps/student/tests/test_login.py b/common/djangoapps/student/tests/test_login.py index 4fe33807fb..a6f5ca3b9c 100644 --- a/common/djangoapps/student/tests/test_login.py +++ b/common/djangoapps/student/tests/test_login.py @@ -18,6 +18,7 @@ from six import text_type from social_django.models import UserSocialAuth from openedx.core.djangoapps.external_auth.models import ExternalAuthMap +from openedx.core.djangoapps.user_api.config.waffle import PREVENT_AUTH_USER_WRITES, waffle from openedx.core.djangolib.testing.utils import CacheIsolationTestCase from openedx.tests.util import expected_redirect_url from student.tests.factories import RegistrationFactory, UserFactory, UserProfileFactory @@ -62,13 +63,15 @@ class LoginTest(CacheIsolationTestCase): self.url = reverse('login') def test_login_success(self): - response, mock_audit_log = self._login_response('test@edx.org', 'test_password', patched_audit_log='student.models.AUDIT_LOG') + response, mock_audit_log = self._login_response('test@edx.org', 'test_password', + patched_audit_log='student.models.AUDIT_LOG') self._assert_response(response, success=True) self._assert_audit_log(mock_audit_log, 'info', [u'Login success', u'test@edx.org']) @patch.dict("django.conf.settings.FEATURES", {'SQUELCH_PII_IN_LOGS': True}) def test_login_success_no_pii(self): - response, mock_audit_log = self._login_response('test@edx.org', 'test_password', patched_audit_log='student.models.AUDIT_LOG') + response, mock_audit_log = self._login_response('test@edx.org', 'test_password', + patched_audit_log='student.models.AUDIT_LOG') self._assert_response(response, success=True) self._assert_audit_log(mock_audit_log, 'info', [u'Login success']) self._assert_not_in_audit_log(mock_audit_log, 'info', [u'test@edx.org']) @@ -78,10 +81,24 @@ class LoginTest(CacheIsolationTestCase): self.user.email = unicode_email self.user.save() - response, mock_audit_log = self._login_response(unicode_email, 'test_password', patched_audit_log='student.models.AUDIT_LOG') + response, mock_audit_log = self._login_response(unicode_email, 'test_password', + patched_audit_log='student.models.AUDIT_LOG') self._assert_response(response, success=True) self._assert_audit_log(mock_audit_log, 'info', [u'Login success', unicode_email]) + def test_last_login_updated(self): + old_last_login = self.user.last_login + self.test_login_success() + self.user.refresh_from_db() + assert self.user.last_login > old_last_login + + def test_login_success_prevent_auth_user_writes(self): + with waffle().override(PREVENT_AUTH_USER_WRITES, True): + old_last_login = self.user.last_login + self.test_login_success() + self.user.refresh_from_db() + assert old_last_login == self.user.last_login + def test_login_fail_no_user_exists(self): nonexistent_email = u'not_a_user@edx.org' response, mock_audit_log = self._login_response( @@ -126,7 +143,8 @@ class LoginTest(CacheIsolationTestCase): ) self._assert_response(response, success=False, value='Email or password is incorrect') - self._assert_audit_log(mock_audit_log, 'warning', [u'Login failed', u'password for', u'test@edx.org', u'invalid']) + self._assert_audit_log(mock_audit_log, 'warning', + [u'Login failed', u'password for', u'test@edx.org', u'invalid']) @patch.dict("django.conf.settings.FEATURES", {'SQUELCH_PII_IN_LOGS': True}) def test_login_fail_wrong_password_no_pii(self): @@ -190,7 +208,8 @@ class LoginTest(CacheIsolationTestCase): 'student.views.login.AUDIT_LOG' ) self._assert_response(response, success=False) - self._assert_audit_log(mock_audit_log, 'warning', [u'Login failed', u'password for', u'test@edx.org', u'invalid']) + self._assert_audit_log(mock_audit_log, 'warning', + [u'Login failed', u'password for', u'test@edx.org', u'invalid']) def test_logout_logging(self): response, _ = self._login_response('test@edx.org', 'test_password') @@ -585,7 +604,7 @@ class LoginOAuthTokenMixin(ThirdPartyOAuthTestMixin): self._setup_provider_response(success=True) response = self.client.post(self.url, {"access_token": "dummy"}) self.assertEqual(response.status_code, 204) - self.assertEqual(int(self.client.session['_auth_user_id']), self.user.id) # pylint: disable=no-member + self.assertEqual(int(self.client.session['_auth_user_id']), self.user.id) def test_invalid_token(self): self._setup_provider_response(success=False) diff --git a/common/djangoapps/student/tests/test_reset_password.py b/common/djangoapps/student/tests/test_reset_password.py index 7175949d1c..f191051be5 100644 --- a/common/djangoapps/student/tests/test_reset_password.py +++ b/common/djangoapps/student/tests/test_reset_password.py @@ -22,6 +22,7 @@ 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 @@ -281,6 +282,18 @@ class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase): 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( diff --git a/common/djangoapps/student/views/management.py b/common/djangoapps/student/views/management.py index 8bd4a345cc..85af8d6cef 100644 --- a/common/djangoapps/student/views/management.py +++ b/common/djangoapps/student/views/management.py @@ -66,7 +66,8 @@ from openedx.core.djangoapps.site_configuration import helpers as configuration_ from openedx.core.djangoapps.theming import helpers as theming_helpers from openedx.core.djangoapps.user_api import accounts as accounts_settings from openedx.core.djangoapps.user_api.preferences import api as preferences_api -from openedx.core.djangolib.markup import HTML +from openedx.core.djangoapps.user_api.config.waffle import PREVENT_AUTH_USER_WRITES, SYSTEM_MAINTENANCE_MSG, waffle +from openedx.core.djangolib.markup import HTML, Text from student.cookies import set_logged_in_cookies from student.forms import AccountCreationForm, PasswordResetFormNoActive, get_registration_extension_form from student.helpers import ( @@ -112,7 +113,7 @@ AUDIT_LOG = logging.getLogger("audit") ReverifyInfo = namedtuple( 'ReverifyInfo', 'course_id course_name course_number date status display' -) # pylint: disable=invalid-name +) SETTING_CHANGE_INITIATED = 'edx.user.settings.change_initiated' # Used as the name of the user attribute for tracking affiliate registrations REGISTRATION_AFFILIATE_ID = 'registration_affiliate_id' @@ -731,7 +732,7 @@ def create_account_with_params(request, params): if hasattr(settings, 'LMS_SEGMENT_KEY') and settings.LMS_SEGMENT_KEY: tracking_context = tracker.get_tracker().resolve_context() identity_args = [ - user.id, # pylint: disable=no-member + user.id, { 'email': user.email, 'username': user.username, @@ -957,6 +958,9 @@ def create_account(request, post_override=None): ): return HttpResponseForbidden(_("Account creation not allowed.")) + if waffle().is_enabled(PREVENT_AUTH_USER_WRITES): + return HttpResponseForbidden(SYSTEM_MAINTENANCE_MSG) + warnings.warn("Please use RegistrationView instead.", DeprecationWarning) try: @@ -1014,7 +1018,26 @@ def activate_account(request, key): extra_tags='account-activation aa-icon' ) else: - if not registration.user.is_active: + if registration.user.is_active: + messages.info( + request, + HTML(_('{html_start}This account has already been activated.{html_end}')).format( + html_start=HTML('

'), + html_end=HTML('

'), + ), + extra_tags='account-activation aa-icon', + ) + elif waffle().is_enabled(PREVENT_AUTH_USER_WRITES): + messages.error( + request, + HTML(u'{html_start}{message}{html_end}').format( + message=Text(SYSTEM_MAINTENANCE_MSG), + html_start=HTML('

'), + html_end=HTML('

'), + ), + extra_tags='account-activation aa-icon', + ) + else: registration.activate() # Success message for logged in users. message = _('{html_start}Success{html_end} You have activated your account.') @@ -1036,15 +1059,6 @@ def activate_account(request, key): ), extra_tags='account-activation aa-icon', ) - else: - messages.info( - request, - HTML(_('{html_start}This account has already been activated.{html_end}')).format( - html_start=HTML('

'), - html_end=HTML('

'), - ), - extra_tags='account-activation aa-icon', - ) # Enroll student in any pending courses he/she may have if auto_enroll flag is set _enroll_user_in_pending_courses(registration.user) @@ -1068,6 +1082,9 @@ def activate_account_studio(request, key): user_logged_in = request.user.is_authenticated() already_active = True if not registration.user.is_active: + if waffle().is_enabled(PREVENT_AUTH_USER_WRITES): + return render_to_response('registration/activation_invalid.html', + {'csrf': csrf(request)['csrf_token']}) registration.activate() already_active = False @@ -1184,8 +1201,6 @@ def validate_password_security_policy(user, password): num_distinct = settings.ADVANCED_SECURITY_CONFIG['MIN_DIFFERENT_STAFF_PASSWORDS_BEFORE_REUSE'] else: num_distinct = settings.ADVANCED_SECURITY_CONFIG['MIN_DIFFERENT_STUDENT_PASSWORDS_BEFORE_REUSE'] - # Because of how ngettext is, splitting the following into shorter lines would be ugly. - # pylint: disable=line-too-long err_msg = ungettext( "You are re-using a password that you have used recently. " "You must have {num} distinct password before reusing a previous password.", @@ -1197,8 +1212,6 @@ def validate_password_security_policy(user, password): # also, check to see if passwords are getting reset too frequent if PasswordHistory.is_password_reset_too_soon(user): num_days = settings.ADVANCED_SECURITY_CONFIG['MIN_TIME_IN_DAYS_BETWEEN_ALLOWED_RESETS'] - # Because of how ngettext is, splitting the following into shorter lines would be ugly. - # pylint: disable=line-too-long err_msg = ungettext( "You are resetting passwords too frequently. Due to security policies, " "{num} day must elapse between password resets.", @@ -1231,6 +1244,18 @@ def password_reset_confirm_wrapper(request, uidb36=None, token=None): request, uidb64=uidb64, token=token, extra_context=platform_name ) + if waffle().is_enabled(PREVENT_AUTH_USER_WRITES): + context = { + 'validlink': False, + 'form': None, + 'title': _('Password reset unsuccessful'), + 'err_msg': SYSTEM_MAINTENANCE_MSG, + } + context.update(platform_name) + return TemplateResponse( + request, 'registration/password_reset_confirm.html', context + ) + if request.method == 'POST': password = request.POST['new_password1'] valid_link = False @@ -1354,7 +1379,7 @@ def do_email_change_request(user, new_email, activation_key=None): ) try: mail.send_mail(subject, message, from_address, [pec.new_email]) - except Exception: # pylint: disable=broad-except + except Exception: log.error(u'Unable to send email activation link to user from "%s"', from_address, exc_info=True) raise ValueError(_('Unable to send email activation link. Please try again later.')) @@ -1378,6 +1403,9 @@ def confirm_email_change(request, key): # pylint: disable=unused-argument User requested a new e-mail. This is called when the activation link is clicked. We confirm with the old e-mail, and update """ + if waffle().is_enabled(PREVENT_AUTH_USER_WRITES): + return render_to_response('email_change_failed.html', {'err_msg': SYSTEM_MAINTENANCE_MSG}) + with transaction.atomic(): try: pec = PendingEmailChange.objects.get(activation_key=key) diff --git a/lms/envs/common.py b/lms/envs/common.py index 1e2fdb0d47..473025cee4 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -2116,7 +2116,7 @@ INSTALLED_APPS = [ # Our courseware 'courseware', - 'student', + 'student.apps.StudentConfig', 'static_template_view', 'staticbook', diff --git a/lms/templates/email_change_failed.html b/lms/templates/email_change_failed.html index aff4fd734f..557925c862 100644 --- a/lms/templates/email_change_failed.html +++ b/lms/templates/email_change_failed.html @@ -8,7 +8,11 @@

${_("E-mail change failed")}


+ % if err_msg is not UNDEFINED: +

${err_msg}

+ % else:

${_("We were unable to send a confirmation email to {email}").format(email=email)}

+ % endif

${_('Go back to the {link_start}home page{link_end}.').format(link_start='', link_end='')}

diff --git a/openedx/core/djangoapps/user_api/accounts/api.py b/openedx/core/djangoapps/user_api/accounts/api.py index 8d491589d4..4adb9acb95 100644 --- a/openedx/core/djangoapps/user_api/accounts/api.py +++ b/openedx/core/djangoapps/user_api/accounts/api.py @@ -11,8 +11,6 @@ from django.core.exceptions import ObjectDoesNotExist from django.conf import settings from django.core.validators import validate_email, ValidationError from django.http import HttpResponseForbidden -from openedx.core.djangoapps.user_api.preferences.api import update_user_preferences -from openedx.core.djangoapps.user_api.errors import PreferenceValidationError, AccountValidationError from six import text_type from student.models import User, UserProfile, Registration @@ -20,15 +18,21 @@ from student import forms as student_forms from student import views as student_views from util.model_utils import emit_setting_changed_event +from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers +from openedx.core.djangoapps.user_api import errors, accounts, forms, helpers +from openedx.core.djangoapps.user_api.config.waffle import PREVENT_AUTH_USER_WRITES, SYSTEM_MAINTENANCE_MSG, waffle +from openedx.core.djangoapps.user_api.errors import ( + AccountUpdateError, + AccountValidationError, + PreferenceValidationError, +) +from openedx.core.djangoapps.user_api.preferences.api import update_user_preferences from openedx.core.lib.api.view_utils import add_serializer_errors from .serializers import ( AccountLegacyProfileSerializer, AccountUserSerializer, UserReadOnlySerializer, _visible_fields # pylint: disable=invalid-name ) -from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers -from openedx.core.djangoapps.user_api import errors, accounts, forms, helpers - # Public access point for this function. visible_fields = _visible_fields @@ -242,21 +246,21 @@ def update_account_settings(requesting_user, update, username=None): except PreferenceValidationError as err: raise AccountValidationError(err.preference_errors) - except AccountValidationError as err: + except (AccountUpdateError, AccountValidationError) as err: raise err except Exception as err: - raise errors.AccountUpdateError( + raise AccountUpdateError( u"Error thrown when saving account updates: '{}'".format(text_type(err)) ) # And try to send the email change request if necessary. if changing_email: if not settings.FEATURES['ALLOW_EMAIL_ADDRESS_CHANGE']: - raise errors.AccountUpdateError(u"Email address changes have been disabled by the site operators.") + raise AccountUpdateError(u"Email address changes have been disabled by the site operators.") try: student_views.do_email_change_request(existing_user, new_email) except ValueError as err: - raise errors.AccountUpdateError( + raise AccountUpdateError( u"Error thrown from do_email_change_request: '{}'".format(text_type(err)), user_message=text_type(err) ) @@ -310,6 +314,9 @@ def create_account(username, password, email): ): return HttpResponseForbidden(_("Account creation not allowed.")) + if waffle().is_enabled(PREVENT_AUTH_USER_WRITES): + raise errors.UserAPIInternalError(SYSTEM_MAINTENANCE_MSG) + # Validate the username, password, and email # This will raise an exception if any of these are not in a valid format. _validate_username(username) @@ -383,6 +390,8 @@ def activate_account(activation_key): errors.UserAPIInternalError: the operation failed due to an unexpected error. """ + if waffle().is_enabled(PREVENT_AUTH_USER_WRITES): + raise errors.UserAPIInternalError(SYSTEM_MAINTENANCE_MSG) try: registration = Registration.objects.get(activation_key=activation_key) except Registration.DoesNotExist: diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_api.py b/openedx/core/djangoapps/user_api/accounts/tests/test_api.py index 95cd6b63f9..65f4e68b7b 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_api.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_api.py @@ -33,6 +33,7 @@ from openedx.core.djangoapps.user_api.accounts.tests.testutils import ( INVALID_USERNAMES, VALID_USERNAMES_UNICODE ) +from openedx.core.djangoapps.user_api.config.waffle import PREVENT_AUTH_USER_WRITES, SYSTEM_MAINTENANCE_MSG, waffle from openedx.core.djangoapps.user_api.errors import ( AccountEmailInvalid, AccountPasswordInvalid, @@ -41,6 +42,7 @@ from openedx.core.djangoapps.user_api.errors import ( AccountUserAlreadyExists, AccountUsernameInvalid, AccountValidationError, + UserAPIInternalError, UserNotAuthorized, UserNotFound ) @@ -408,10 +410,21 @@ class AccountCreationActivationAndPasswordChangeTest(TestCase): def test_create_account_invalid_username(self, invalid_username): create_account(invalid_username, self.PASSWORD, self.EMAIL) + def test_create_account_prevent_auth_user_writes(self): + with pytest.raises(UserAPIInternalError, message=SYSTEM_MAINTENANCE_MSG): + with waffle().override(PREVENT_AUTH_USER_WRITES, True): + create_account(self.USERNAME, self.PASSWORD, self.EMAIL) + @raises(UserNotAuthorized) def test_activate_account_invalid_key(self): activate_account(u'invalid') + def test_activate_account_prevent_auth_user_writes(self): + activation_key = create_account(self.USERNAME, self.PASSWORD, self.EMAIL) + with pytest.raises(UserAPIInternalError, message=SYSTEM_MAINTENANCE_MSG): + with waffle().override(PREVENT_AUTH_USER_WRITES, True): + activate_account(activation_key) + @skip_unless_lms def test_request_password_change(self): # Create and activate an account diff --git a/openedx/core/djangoapps/user_api/config/__init__.py b/openedx/core/djangoapps/user_api/config/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/core/djangoapps/user_api/config/waffle.py b/openedx/core/djangoapps/user_api/config/waffle.py new file mode 100644 index 0000000000..fc1166bd41 --- /dev/null +++ b/openedx/core/djangoapps/user_api/config/waffle.py @@ -0,0 +1,21 @@ +""" +Waffle flags and switches to change user API functionality. +""" +from __future__ import absolute_import + +from django.utils.translation import ugettext_lazy as _ + +from openedx.core.djangoapps.waffle_utils import WaffleSwitchNamespace + +SYSTEM_MAINTENANCE_MSG = _(u'System maintenance in progress. Please try again later.') +WAFFLE_NAMESPACE = u'user_api' + +# Switches +PREVENT_AUTH_USER_WRITES = u'prevent_auth_user_writes' + + +def waffle(): + """ + Returns the namespaced, cached, audited Waffle class for user_api. + """ + return WaffleSwitchNamespace(name=WAFFLE_NAMESPACE, log_prefix=u'UserAPI: ')