diff --git a/common/djangoapps/student/admin.py b/common/djangoapps/student/admin.py index 2ddc58497f79629d3377fbbbb70e0886b881fe37..2b23bef96849c00179e510ac09bdf6e64b0b07b8 100644 --- a/common/djangoapps/student/admin.py +++ b/common/djangoapps/student/admin.py @@ -15,6 +15,7 @@ from openedx.core.djangoapps.waffle_utils import WaffleSwitch from openedx.core.lib.courses import clean_course_id from student import STUDENT_WAFFLE_NAMESPACE from student.models import ( + AccountRecovery, CourseAccessRole, CourseEnrollment, CourseEnrollmentAllowed, @@ -241,6 +242,14 @@ class UserProfileInline(admin.StackedInline): verbose_name_plural = _('User profile') +class AccountRecoveryInline(admin.StackedInline): + """ Inline admin interface for AccountRecovery model. """ + model = AccountRecovery + can_delete = False + verbose_name = _('Account recovery') + verbose_name_plural = _('Account recovery') + + class UserChangeForm(BaseUserChangeForm): """ Override the default UserChangeForm such that the password field @@ -257,7 +266,7 @@ class UserChangeForm(BaseUserChangeForm): class UserAdmin(BaseUserAdmin): """ Admin interface for the User model. """ - inlines = (UserProfileInline,) + inlines = (UserProfileInline, AccountRecoveryInline) form = UserChangeForm def get_readonly_fields(self, request, obj=None): diff --git a/common/djangoapps/student/migrations/0017_accountrecovery.py b/common/djangoapps/student/migrations/0017_accountrecovery.py new file mode 100644 index 0000000000000000000000000000000000000000..e138dd47bdfdfd4cc3b61cbc628119bf828c930b --- /dev/null +++ b/common/djangoapps/student/migrations/0017_accountrecovery.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.16 on 2018-12-10 12:15 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('student', '0016_coursenrollment_course_on_delete_do_nothing'), + ] + + operations = [ + migrations.CreateModel( + name='AccountRecovery', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('secondary_email', models.EmailField(help_text='Secondary email address to recover linked account.', max_length=254, unique=True, verbose_name='Secondary email address')), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='account_recovery', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'auth_accountrecovery', + }, + ), + ] diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 95c90111d3397e23ecb933683ac2040dea678215..2d84e1badee256e5bbbfa83ee23ff5c3010d60f0 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -2879,3 +2879,20 @@ class LogoutViewConfiguration(ConfigurationModel): def __unicode__(self): """Unicode representation of the instance. """ return u'Logout view configuration: {enabled}'.format(enabled=self.enabled) + + +class AccountRecovery(models.Model): + """ + Model for storing information for user's account recovery in case of access loss. + """ + user = models.OneToOneField(User, related_name='account_recovery', on_delete=models.CASCADE) + secondary_email = models.EmailField( + verbose_name=_('Secondary email address'), + help_text=_('Secondary email address to recover linked account.'), + unique=True, + null=False, + blank=False, + ) + + class Meta(object): + db_table = "auth_accountrecovery" diff --git a/common/djangoapps/student/views/management.py b/common/djangoapps/student/views/management.py index 7e9767b5e1be3dcdd3be57bf05dc180992f74111..01fe28741eae3ee5b45b4fb2e2b350250c4153ca 100644 --- a/common/djangoapps/student/views/management.py +++ b/common/djangoapps/student/views/management.py @@ -912,6 +912,33 @@ def validate_new_email(user, new_email): raise ValueError(_('Old email is the same as the new email.')) +def validate_secondary_email(account_recovery, new_email): + """ + Enforce valid email addresses. + """ + from openedx.core.djangoapps.user_api.accounts.api import get_email_validation_error, \ + get_email_existence_validation_error, get_secondary_email_validation_error + + if get_email_validation_error(new_email): + raise ValueError(_('Valid e-mail address required.')) + + if new_email == account_recovery.secondary_email: + raise ValueError(_('Old email is the same as the new email.')) + + # Make sure that secondary email address is not same as user's primary email. + if new_email == account_recovery.user.email: + raise ValueError(_('Cannot be same as your sign in email address.')) + + # Make sure that secondary email address is not same as any of the primary emails. + message = get_email_existence_validation_error(new_email) + if message: + raise ValueError(message) + + message = get_secondary_email_validation_error(new_email) + if message: + raise ValueError(message) + + def do_email_change_request(user, new_email, activation_key=None): """ Given a new email for a user, does some basic verification of the new address and sends an activation message diff --git a/lms/envs/common.py b/lms/envs/common.py index dab7b9a6752c4a1e8653bc7c2b7534803164b880..a68d48b99368bbdf8a14b73a6b5f075d73f6bb40 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -3034,6 +3034,7 @@ ACCOUNT_VISIBILITY_CONFIGURATION = { "account_privacy", "accomplishments_shared", "extended_profile", + "secondary_email", ] } diff --git a/lms/static/js/student_account/models/user_account_model.js b/lms/static/js/student_account/models/user_account_model.js index 44008ed6220221795d69476e9393fd4a7dba2810..0bf577db3aec3e8d9087f5d3cc2699f1f28c958e 100644 --- a/lms/static/js/student_account/models/user_account_model.js +++ b/lms/static/js/student_account/models/user_account_model.js @@ -25,7 +25,8 @@ profile_image: null, accomplishments_shared: false, default_public_account_fields: [], - extended_profile: [] + extended_profile: [], + secondary_email: '' }, parse: function(response) { diff --git a/lms/static/js/student_account/views/account_settings_factory.js b/lms/static/js/student_account/views/account_settings_factory.js index 7d9951758d4ad34261d962bcc80c879c5d76c057..ebef64b1ec1958ffb9ff0a71c5cb4fdf3419fe46 100644 --- a/lms/static/js/student_account/views/account_settings_factory.js +++ b/lms/static/js/student_account/views/account_settings_factory.js @@ -27,14 +27,16 @@ enterpriseReadonlyAccountFields, edxSupportUrl, extendedProfileFields, - displayAccountDeletion + displayAccountDeletion, + isSecondaryEmailFeatureEnabled ) { var $accountSettingsElement, userAccountModel, userPreferencesModel, aboutSectionsData, accountsSectionData, ordersSectionData, accountSettingsView, showAccountSettingsPage, showLoadingError, orderNumber, getUserField, userFields, timeZoneDropdownField, countryDropdownField, - emailFieldView, socialFields, accountDeletionFields, platformData, + emailFieldView, secondaryEmailFieldView, socialFields, accountDeletionFields, platformData, aboutSectionMessageType, aboutSectionMessage, fullnameFieldView, countryFieldView, - fullNameFieldData, emailFieldData, countryFieldData, additionalFields, fieldItem; + fullNameFieldData, emailFieldData, secondaryEmailFieldData, countryFieldData, additionalFields, + fieldItem, emailFieldViewIndex; $accountSettingsElement = $('.wrapper-account-settings'); @@ -82,6 +84,14 @@ }; } + secondaryEmailFieldData = { + model: userAccountModel, + title: gettext('Secondary Email Address'), + valueAttribute: 'secondary_email', + helpMessage: gettext('You may access your account when single-sign on is not available.'), + persistChanges: true + }; + fullNameFieldData = { model: userAccountModel, title: gettext('Full Name'), @@ -233,6 +243,19 @@ } ]; + // Secondary email address + if (isSecondaryEmailFeatureEnabled) { + secondaryEmailFieldView = { + view: new AccountSettingsFieldViews.EmailFieldView(secondaryEmailFieldData) + }; + emailFieldViewIndex = aboutSectionsData[0].fields.indexOf(emailFieldView); + + // Insert secondary email address after email address field. + aboutSectionsData[0].fields.splice( + emailFieldViewIndex + 1, 0, secondaryEmailFieldView + ) + } + // Add the extended profile fields additionalFields = aboutSectionsData[1]; for (var field in extendedProfileFields) { // eslint-disable-line guard-for-in, no-restricted-syntax, vars-on-top, max-len diff --git a/lms/templates/student_account/account_settings.html b/lms/templates/student_account/account_settings.html index ffc83b1f94ba64b9a7d4ba5608fe133e2c2d4440..507f9f8da1a9d0e0b7cee83ea05872fd8ae2e916 100644 --- a/lms/templates/student_account/account_settings.html +++ b/lms/templates/student_account/account_settings.html @@ -10,6 +10,7 @@ from django.utils.translation import ugettext as _ from openedx.core.djangolib.js_utils import dump_js_escaped_json, js_escaped_string from openedx.core.djangolib.markup import HTML from webpack_loader.templatetags.webpack_loader import render_bundle +from openedx.core.djangoapps.user_api.accounts.utils import is_secondary_email_feature_enabled_for_user %> <%inherit file="/main.html" /> @@ -46,6 +47,7 @@ from webpack_loader.templatetags.webpack_loader import render_bundle edxSupportUrl = '${ edx_support_url | n, js_escaped_string }', extendedProfileFields = ${ extended_profile_fields | n, dump_js_escaped_json }, displayAccountDeletion = ${ enable_account_deletion | n, dump_js_escaped_json}; + isSecondaryEmailFeatureEnabled = ${ bool(is_secondary_email_feature_enabled_for_user(user)) | n, dump_js_escaped_json }, AccountSettingsFactory( fieldsData, @@ -65,7 +67,8 @@ from webpack_loader.templatetags.webpack_loader import render_bundle enterpriseReadonlyAccountFields, edxSupportUrl, extendedProfileFields, - displayAccountDeletion + displayAccountDeletion, + isSecondaryEmailFeatureEnabled ); </%static:require_module> diff --git a/openedx/core/djangoapps/user_api/accounts/api.py b/openedx/core/djangoapps/user_api/accounts/api.py index 42c02ba5cdfe36b1c2200a5992cc2bbef45b6de9..1394f77c9fdbeef853e00eab0361a014f7f0fb98 100644 --- a/openedx/core/djangoapps/user_api/accounts/api.py +++ b/openedx/core/djangoapps/user_api/accounts/api.py @@ -14,7 +14,14 @@ from django.http import HttpResponseForbidden from openedx.core.djangoapps.theming.helpers import get_current_request from six import text_type -from student.models import User, UserProfile, Registration, email_exists_or_retired, username_exists_or_retired +from student.models import ( + AccountRecovery, + User, + UserProfile, + Registration, + email_exists_or_retired, + username_exists_or_retired +) from student import forms as student_forms from student import views as student_views from util.model_utils import emit_setting_changed_event @@ -131,6 +138,7 @@ def update_account_settings(requesting_user, update, username=None): username = requesting_user.username existing_user, existing_user_profile = _get_user_and_profile(username) + account_recovery = _get_account_recovery(existing_user) if requesting_user.username != username: raise errors.UserNotAuthorized() @@ -151,6 +159,10 @@ def update_account_settings(requesting_user, update, username=None): changing_full_name = True old_name = existing_user_profile.name + changing_secondary_email = False + if "secondary_email" in update: + changing_secondary_email = True + # Check for fields that are not editable. Marking them read-only causes them to be ignored, but we wish to 400. read_only_fields = set(update.keys()).intersection( AccountUserSerializer.get_read_only_fields() + AccountLegacyProfileSerializer.get_read_only_fields() @@ -188,6 +200,18 @@ def update_account_settings(requesting_user, update, username=None): # This is so that this endpoint cannot be used to determine if an email is valid or not. changing_email = new_email and not email_exists_or_retired(new_email) + if changing_secondary_email: + try: + student_views.validate_secondary_email(account_recovery, update["secondary_email"]) + except ValueError as err: + field_errors["secondary_email"] = { + "developer_message": u"Error thrown from validate_secondary_email: '{}'".format(text_type(err)), + "user_message": text_type(err) + } + else: + account_recovery.secondary_email = update["secondary_email"] + account_recovery.save() + # If the user asked to change full name, validate it if changing_full_name: try: @@ -485,6 +509,19 @@ def get_email_validation_error(email): return _validate(_validate_email, errors.AccountEmailInvalid, email) +def get_secondary_email_validation_error(email): + """ + Get the built-in validation error message for when the email is invalid in some way. + + Arguments: + email (str): The proposed email (unicode). + Returns: + (str): Validation error message. + + """ + return _validate(_validate_secondary_email_doesnt_exist, errors.AccountEmailAlreadyExists, email) + + def get_confirm_email_validation_error(confirm_email, email): """Get the built-in validation error message for when the confirmation email is invalid in some way. @@ -560,6 +597,18 @@ def _get_user_and_profile(username): return existing_user, existing_user_profile +def _get_account_recovery(user): + """ + helper method to return the account recovery object based on user. + """ + try: + account_recovery = user.account_recovery + except ObjectDoesNotExist: + account_recovery = AccountRecovery(user=user) + + return account_recovery + + def _validate(validation_func, err, *args): """Generic validation function that returns default on no errors, but the message associated with the err class @@ -709,6 +758,25 @@ def _validate_email_doesnt_exist(email): raise errors.AccountEmailAlreadyExists(_(accounts.EMAIL_CONFLICT_MSG).format(email_address=email)) +def _validate_secondary_email_doesnt_exist(email): + """ + Validate that the email is not associated as a secondary email of an existing user. + + Arguments: + email (unicode): The proposed email. + + Returns: + None + + Raises: + errors.AccountEmailAlreadyExists: Raised if given email address is already associated as another + user's secondary email. + """ + if email is not None and AccountRecovery.objects.filter(secondary_email=email).exists(): + # pylint: disable=no-member + raise errors.AccountEmailAlreadyExists(accounts.EMAIL_CONFLICT_MSG.format(email_address=email)) + + def _validate_password_works_with_username(password, username=None): """Run validation checks on whether the password and username go well together. diff --git a/openedx/core/djangoapps/user_api/accounts/serializers.py b/openedx/core/djangoapps/user_api/accounts/serializers.py index fa884b00cbfe232f574b331fee9f5eb767a77310..e51b2e1149747f37f21986eb0afc3f1944388ff3 100644 --- a/openedx/core/djangoapps/user_api/accounts/serializers.py +++ b/openedx/core/djangoapps/user_api/accounts/serializers.py @@ -14,6 +14,7 @@ from six import text_type from lms.djangoapps.badges.utils import badges_enabled from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.user_api import errors +from openedx.core.djangoapps.user_api.accounts.utils import is_secondary_email_feature_enabled_for_user from openedx.core.djangoapps.user_api.models import ( RetirementState, UserPreference, @@ -81,7 +82,7 @@ class UserReadOnlySerializer(serializers.Serializer): def to_representation(self, user): """ - Overwrite to_native to handle custom logic since we are serializing two models as one here + Overwrite to_native to handle custom logic since we are serializing three models as one here :param user: User object :return: Dict serialized account """ @@ -91,6 +92,11 @@ class UserReadOnlySerializer(serializers.Serializer): user_profile = None LOGGER.warning("user profile for the user [%s] does not exist", user.username) + try: + account_recovery = user.account_recovery + except ObjectDoesNotExist: + account_recovery = None + accomplishments_shared = badges_enabled() data = { @@ -150,6 +156,14 @@ class UserReadOnlySerializer(serializers.Serializer): } ) + if account_recovery: + if is_secondary_email_feature_enabled_for_user(user): + data.update( + { + "secondary_email": account_recovery.secondary_email, + } + ) + if self.custom_fields: fields = self.custom_fields elif user_profile: 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 48e54f483d11385b9027fced7f7d510c7ad29cb7..d31933b56a8e78cbecf530dc54bb92ccf67cc63c 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_api.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_api.py @@ -357,6 +357,7 @@ class AccountSettingsOnCreationTest(TestCase): 'account_privacy': PRIVATE_VISIBILITY, 'accomplishments_shared': False, 'extended_profile': [], + 'secondary_email': None }) def test_normalize_password(self): diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_views.py b/openedx/core/djangoapps/user_api/accounts/tests/test_views.py index d4d480ac7d5f6f27ba15f4096c9f6a5018ff6310..92b2b7ad6c525def5a21e32e464cd7bff61f63e0 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_views.py @@ -243,7 +243,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase): Verify that all account fields are returned (even those that are not shareable). """ data = response.data - self.assertEqual(19, len(data)) + self.assertEqual(20, len(data)) self.assertEqual(self.user.username, data["username"]) self.assertEqual(self.user.first_name + " " + self.user.last_name, data["name"]) self.assertEqual("US", data["country"]) @@ -301,7 +301,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase): """ self.different_client.login(username=self.different_user.username, password=TEST_PASSWORD) self.create_mock_profile(self.user) - with self.assertNumQueries(21): + with self.assertNumQueries(22): response = self.send_get(self.different_client) self._verify_full_shareable_account_response(response, account_privacy=ALL_USERS_VISIBILITY) @@ -316,7 +316,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase): """ self.different_client.login(username=self.different_user.username, password=TEST_PASSWORD) self.create_mock_profile(self.user) - with self.assertNumQueries(21): + with self.assertNumQueries(22): response = self.send_get(self.different_client) self._verify_private_account_response(response, account_privacy=PRIVATE_VISIBILITY) @@ -372,7 +372,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase): with self.assertNumQueries(queries): response = self.send_get(self.client) data = response.data - self.assertEqual(19, len(data)) + self.assertEqual(20, len(data)) self.assertEqual(self.user.username, data["username"]) self.assertEqual(self.user.first_name + " " + self.user.last_name, data["name"]) for empty_field in ("year_of_birth", "level_of_education", "mailing_address", "bio"): @@ -391,12 +391,12 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase): self.assertEqual(False, data["accomplishments_shared"]) self.client.login(username=self.user.username, password=TEST_PASSWORD) - verify_get_own_information(19) + verify_get_own_information(20) # Now make sure that the user can get the same information, even if not active self.user.is_active = False self.user.save() - verify_get_own_information(13) + verify_get_own_information(14) def test_get_account_empty_string(self): """ @@ -410,7 +410,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase): legacy_profile.save() self.client.login(username=self.user.username, password=TEST_PASSWORD) - with self.assertNumQueries(19): + with self.assertNumQueries(20): response = self.send_get(self.client) for empty_field in ("level_of_education", "gender", "country", "bio"): self.assertIsNone(response.data[empty_field]) @@ -782,7 +782,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase): response = self.send_get(client) if has_full_access: data = response.data - self.assertEqual(19, len(data)) + self.assertEqual(20, len(data)) self.assertEqual(self.user.username, data["username"]) self.assertEqual(self.user.first_name + " " + self.user.last_name, data["name"]) self.assertEqual(self.user.email, data["email"]) diff --git a/openedx/core/djangoapps/user_api/accounts/utils.py b/openedx/core/djangoapps/user_api/accounts/utils.py index 1d25a23caabddcccc038f528b05e067d01b6111c..a2e18f0aef9f17be8edfc8c61b778234b7e0de5e 100644 --- a/openedx/core/djangoapps/user_api/accounts/utils.py +++ b/openedx/core/djangoapps/user_api/accounts/utils.py @@ -8,6 +8,7 @@ import re import string from urlparse import urlparse +import waffle from django.conf import settings from django.utils.translation import ugettext as _ from six import text_type @@ -19,6 +20,8 @@ from openedx.core.djangoapps.theming.helpers import get_config_value_from_site_o from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import ItemNotFoundError +ENABLE_SECONDARY_EMAIL_FEATURE_SWITCH = 'enable_secondary_email_feature' + def validate_social_link(platform_name, new_social_link): """ @@ -189,3 +192,25 @@ def generate_password(length=12, chars=string.letters + string.digits): password += choice(string.letters) password += ''.join([choice(chars) for _i in xrange(length - 2)]) return password + + +def is_secondary_email_feature_enabled(): + """ + Checks to see if the django-waffle switch for enabling the secondary email feature is active + + Returns: + Boolean value representing switch status + """ + return waffle.switch_is_active(ENABLE_SECONDARY_EMAIL_FEATURE_SWITCH) + + +def is_secondary_email_feature_enabled_for_user(user): + """ + Checks to see if secondary email feature is enabled for the given user. + + Returns: + Boolean value representing the status of secondary email feature. + """ + # import is placed here to avoid cyclic import. + from openedx.features.enterprise_support.utils import is_enterprise_learner + return is_secondary_email_feature_enabled() and is_enterprise_learner(user) diff --git a/openedx/features/enterprise_support/utils.py b/openedx/features/enterprise_support/utils.py index 0846de4220c824cb2e6e23fadf8a6d9b1f8ce509..5202ea53e5437070c66684d1ca42e4d72eb06551 100644 --- a/openedx/features/enterprise_support/utils.py +++ b/openedx/features/enterprise_support/utils.py @@ -9,6 +9,7 @@ from django.utils.translation import ugettext as _ import third_party_auth from third_party_auth import pipeline +from enterprise.models import EnterpriseCustomerUser from openedx.core.djangoapps.user_authn.cookies import standard_cookie_settings from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers @@ -257,3 +258,16 @@ def get_enterprise_learner_generic_name(request): if enterprise_customer and enterprise_customer['replace_sensitive_sso_username'] else '' ) + + +def is_enterprise_learner(user): + """ + Check if the given user belongs to an enterprise. + + Arguments: + user (User): Django User object. + + Returns: + (bool): True if given user is an enterprise learner. + """ + return EnterpriseCustomerUser.objects.filter(user_id=user.id).exists()