Merge pull request #17561 from edx/jmbowman/PLAT-1976

PLAT-1976 Add waffle switch to block auth_user write attempts
This commit is contained in:
Jeremy Bowman
2018-02-28 17:16:56 -05:00
committed by GitHub
15 changed files with 226 additions and 53 deletions

View File

@@ -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',

View File

@@ -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)

View File

@@ -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'])

View File

@@ -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

View File

@@ -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.

View File

@@ -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):

View File

@@ -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)

View File

@@ -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(

View File

@@ -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('<p class="message-title">'),
html_end=HTML('</p>'),
),
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('<p class="message-title">'),
html_end=HTML('</p>'),
),
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('<p class="message-title">'),
html_end=HTML('</p>'),
),
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)

View File

@@ -2116,7 +2116,7 @@ INSTALLED_APPS = [
# Our courseware
'courseware',
'student',
'student.apps.StudentConfig',
'static_template_view',
'staticbook',

View File

@@ -8,7 +8,11 @@
<h1 class="invalid">${_("E-mail change failed")}</h1>
<hr class="horizontal-divider">
% if err_msg is not UNDEFINED:
<p>${err_msg}</p>
% else:
<p>${_("We were unable to send a confirmation email to {email}").format(email=email)}</p>
% endif
<p>${_('Go back to the {link_start}home page{link_end}.').format(link_start='<a href="/">', link_end='</a>')}</p>
</section>

View File

@@ -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:

View File

@@ -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

View File

@@ -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: ')