Verification by email of recovery address.
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.16 on 2018-12-21 10:40
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import openedx.core.djangolib.model_mixins
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('student', '0018_remove_password_history'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='PendingSecondaryEmailChange',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('new_secondary_email', models.CharField(blank=True, db_index=True, max_length=255)),
|
||||
('activation_key', models.CharField(db_index=True, max_length=32, unique=True, verbose_name=b'activation key')),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
bases=(openedx.core.djangolib.model_mixins.DeletableByUserValue, models.Model),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='accountrecovery',
|
||||
name='is_active',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -785,6 +785,15 @@ class PendingEmailChange(DeletableByUserValue, models.Model):
|
||||
return self.activation_key
|
||||
|
||||
|
||||
class PendingSecondaryEmailChange(DeletableByUserValue, models.Model):
|
||||
"""
|
||||
This model keeps track of pending requested changes to a user's secondary email address.
|
||||
"""
|
||||
user = models.OneToOneField(User, unique=True, db_index=True, on_delete=models.CASCADE)
|
||||
new_secondary_email = models.CharField(blank=True, max_length=255, db_index=True)
|
||||
activation_key = models.CharField(('activation key'), max_length=32, unique=True, db_index=True)
|
||||
|
||||
|
||||
EVENT_NAME_ENROLLMENT_ACTIVATED = 'edx.course.enrollment.activated'
|
||||
EVENT_NAME_ENROLLMENT_DEACTIVATED = 'edx.course.enrollment.deactivated'
|
||||
EVENT_NAME_ENROLLMENT_MODE_CHANGED = 'edx.course.enrollment.mode_changed'
|
||||
@@ -2692,6 +2701,7 @@ class AccountRecovery(models.Model):
|
||||
null=False,
|
||||
blank=False,
|
||||
)
|
||||
is_active = models.BooleanField(default=False)
|
||||
|
||||
class Meta(object):
|
||||
db_table = "auth_accountrecovery"
|
||||
|
||||
@@ -575,3 +575,112 @@ class EmailChangeConfirmationTests(EmailTestMixin, CacheIsolationMixin, Transact
|
||||
confirm_email_change(self.request, self.key)
|
||||
|
||||
mock_rollback.assert_called_with()
|
||||
|
||||
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', "Test only valid in LMS")
|
||||
class SecondaryEmailChangeRequestTests(EventTestMixin, EmailTemplateTagMixin, CacheIsolationTestCase):
|
||||
"""
|
||||
Test changing a user's email address
|
||||
"""
|
||||
|
||||
def setUp(self, tracker='student.views.management.tracker'):
|
||||
super(SecondaryEmailChangeRequestTests, self).setUp(tracker)
|
||||
self.user = UserFactory.create()
|
||||
self.new_secondary_email = 'new.secondary.email@edx.org'
|
||||
self.req_factory = RequestFactory()
|
||||
self.request = self.req_factory.post('unused_url', data={
|
||||
'password': 'test',
|
||||
'new_email': self.new_secondary_email
|
||||
})
|
||||
self.request.user = self.user
|
||||
self.user.email_user = Mock()
|
||||
|
||||
def do_email_validation(self, email):
|
||||
"""
|
||||
Executes validate_new_secondary_email, returning any resulting error message.
|
||||
"""
|
||||
try:
|
||||
validate_new_email(self.request.user, email)
|
||||
except ValueError as err:
|
||||
return text_type(err)
|
||||
|
||||
def do_secondary_email_change(self, user, email, activation_key=None):
|
||||
"""
|
||||
Executes do_secondary_email_change_request, returning any resulting error message.
|
||||
"""
|
||||
with patch('crum.get_current_request', return_value=self.fake_request):
|
||||
do_email_change_request(
|
||||
user=user,
|
||||
new_email=email,
|
||||
activation_key=activation_key,
|
||||
secondary_email_change_request=True
|
||||
)
|
||||
|
||||
def assertFailedRequest(self, response_data, expected_error):
|
||||
"""
|
||||
Assert that `response_data` indicates a failed request that returns `expected_error`
|
||||
"""
|
||||
self.assertFalse(response_data['success'])
|
||||
self.assertEquals(expected_error, response_data['error'])
|
||||
self.assertFalse(self.user.email_user.called)
|
||||
|
||||
def test_invalid_emails(self):
|
||||
"""
|
||||
Assert the expected error message from the email validation method for an invalid
|
||||
(improperly formatted) email address.
|
||||
"""
|
||||
for email in ('bad_email', 'bad_email@', '@bad_email'):
|
||||
self.assertEqual(self.do_email_validation(email), 'Valid e-mail address required.')
|
||||
|
||||
@patch('django.core.mail.send_mail')
|
||||
def test_email_failure(self, send_mail):
|
||||
"""
|
||||
Test the return value if sending the email for the user to click fails.
|
||||
"""
|
||||
send_mail.side_effect = [Exception, None]
|
||||
with self.assertRaisesRegexp(ValueError, 'Unable to send email activation link. Please try again later.'):
|
||||
self.do_secondary_email_change(self.user, "valid@email.com")
|
||||
|
||||
self.assert_no_events_were_emitted()
|
||||
|
||||
def test_email_success(self):
|
||||
"""
|
||||
Test email was sent if no errors encountered.
|
||||
"""
|
||||
new_email = "valid@example.com"
|
||||
registration_key = "test-registration-key"
|
||||
|
||||
self.do_secondary_email_change(self.user, new_email, registration_key)
|
||||
|
||||
self._assert_email(
|
||||
subject=u'Request to change édX account secondary e-mail',
|
||||
body_fragments=[
|
||||
u'We received a request to change the secondary e-mail associated with',
|
||||
u'your édX account to {new_email}.'.format(
|
||||
new_email=new_email,
|
||||
),
|
||||
u'If this is correct, please confirm your new secondary e-mail address by visiting:',
|
||||
u'http://edx.org/activate_secondary_email/{key}'.format(key=registration_key),
|
||||
u'If you didn\'t request this, you don\'t need to do anything;',
|
||||
u'you won\'t receive any more email from us.',
|
||||
u'Please do not reply to this e-mail; if you require assistance,',
|
||||
u'check the help section of the édX web site.',
|
||||
],
|
||||
)
|
||||
|
||||
def _assert_email(self, subject, body_fragments):
|
||||
"""
|
||||
Verify that the email was sent.
|
||||
"""
|
||||
assert len(mail.outbox) == 1
|
||||
assert len(body_fragments) > 1, 'Should provide at least two body fragments'
|
||||
|
||||
message = mail.outbox[0]
|
||||
text = message.body
|
||||
html = message.alternatives[0][0]
|
||||
|
||||
assert message.subject == subject
|
||||
|
||||
for body in text, html:
|
||||
for fragment in body_fragments:
|
||||
assert fragment in body
|
||||
|
||||
@@ -33,6 +33,13 @@ urlpatterns = [
|
||||
url(r'^course_run/{}/refund_status$'.format(settings.COURSE_ID_PATTERN),
|
||||
views.course_run_refund_status,
|
||||
name="course_run_refund_status"),
|
||||
|
||||
url(
|
||||
r'^activate_secondary_email/(?P<key>[^/]*)$',
|
||||
views.activate_secondary_email,
|
||||
name='activate_secondary_email'
|
||||
),
|
||||
|
||||
]
|
||||
|
||||
# password reset django views (see above for password reset views)
|
||||
|
||||
@@ -76,6 +76,7 @@ from student.models import (
|
||||
AccountRecovery,
|
||||
CourseEnrollment,
|
||||
PendingEmailChange,
|
||||
PendingSecondaryEmailChange,
|
||||
Registration,
|
||||
RegistrationCookieConfiguration,
|
||||
UserAttribute,
|
||||
@@ -984,26 +985,35 @@ def validate_secondary_email(account_recovery, new_email):
|
||||
raise ValueError(message)
|
||||
|
||||
|
||||
def do_email_change_request(user, new_email, activation_key=None):
|
||||
def do_email_change_request(user, new_email, activation_key=None, secondary_email_change_request=False):
|
||||
"""
|
||||
Given a new email for a user, does some basic verification of the new address and sends an activation message
|
||||
to the new address. If any issues are encountered with verification or sending the message, a ValueError will
|
||||
be thrown.
|
||||
"""
|
||||
pec_list = PendingEmailChange.objects.filter(user=user)
|
||||
if len(pec_list) == 0:
|
||||
pec = PendingEmailChange()
|
||||
pec.user = user
|
||||
else:
|
||||
pec = pec_list[0]
|
||||
|
||||
# if activation_key is not passing as an argument, generate a random key
|
||||
if not activation_key:
|
||||
activation_key = uuid.uuid4().hex
|
||||
|
||||
pec.new_email = new_email
|
||||
pec.activation_key = activation_key
|
||||
pec.save()
|
||||
confirm_link = reverse('confirm_email_change', kwargs={'key': activation_key, })
|
||||
|
||||
if secondary_email_change_request:
|
||||
PendingSecondaryEmailChange.objects.update_or_create(
|
||||
user=user,
|
||||
defaults={
|
||||
'new_secondary_email': new_email,
|
||||
'activation_key': activation_key,
|
||||
}
|
||||
)
|
||||
confirm_link = reverse('activate_secondary_email', kwargs={'key': activation_key})
|
||||
else:
|
||||
PendingEmailChange.objects.update_or_create(
|
||||
user=user,
|
||||
defaults={
|
||||
'new_email': new_email,
|
||||
'activation_key': activation_key,
|
||||
}
|
||||
)
|
||||
|
||||
use_https = theming_helpers.get_current_request().is_secure()
|
||||
|
||||
@@ -1011,18 +1021,17 @@ def do_email_change_request(user, new_email, activation_key=None):
|
||||
message_context = get_base_template_context(site)
|
||||
message_context.update({
|
||||
'old_email': user.email,
|
||||
'new_email': pec.new_email,
|
||||
'new_email': new_email,
|
||||
'is_secondary_email_change_request': secondary_email_change_request,
|
||||
'confirm_link': '{protocol}://{site}{link}'.format(
|
||||
protocol='https' if use_https else 'http',
|
||||
site=configuration_helpers.get_value('SITE_NAME', settings.SITE_NAME),
|
||||
link=reverse('confirm_email_change', kwargs={
|
||||
'key': pec.activation_key,
|
||||
}),
|
||||
link=confirm_link,
|
||||
),
|
||||
})
|
||||
|
||||
msg = EmailChange().personalize(
|
||||
recipient=Recipient(user.username, pec.new_email),
|
||||
recipient=Recipient(user.username, new_email),
|
||||
language=preferences_api.get_user_preference(user, LANGUAGE_KEY),
|
||||
user_context=message_context,
|
||||
)
|
||||
@@ -1034,18 +1043,42 @@ def do_email_change_request(user, new_email, activation_key=None):
|
||||
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.'))
|
||||
|
||||
# When the email address change is complete, a "edx.user.settings.changed" event will be emitted.
|
||||
# But because changing the email address is multi-step, we also emit an event here so that we can
|
||||
# track where the request was initiated.
|
||||
tracker.emit(
|
||||
SETTING_CHANGE_INITIATED,
|
||||
{
|
||||
"setting": "email",
|
||||
"old": message_context['old_email'],
|
||||
"new": message_context['new_email'],
|
||||
"user_id": user.id,
|
||||
}
|
||||
)
|
||||
if not secondary_email_change_request:
|
||||
# When the email address change is complete, a "edx.user.settings.changed" event will be emitted.
|
||||
# But because changing the email address is multi-step, we also emit an event here so that we can
|
||||
# track where the request was initiated.
|
||||
tracker.emit(
|
||||
SETTING_CHANGE_INITIATED,
|
||||
{
|
||||
"setting": "email",
|
||||
"old": message_context['old_email'],
|
||||
"new": message_context['new_email'],
|
||||
"user_id": user.id,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
def activate_secondary_email(request, key): # pylint: disable=unused-argument
|
||||
"""
|
||||
This is called when the activation link is clicked. We activate the secondary email
|
||||
for the requested user.
|
||||
"""
|
||||
try:
|
||||
pending_secondary_email_change = PendingSecondaryEmailChange.objects.get(activation_key=key)
|
||||
except PendingSecondaryEmailChange.DoesNotExist:
|
||||
return render_to_response("invalid_email_key.html", {})
|
||||
|
||||
try:
|
||||
account_recovery_obj = AccountRecovery.objects.get(user_id=pending_secondary_email_change.user)
|
||||
except AccountRecovery.DoesNotExist:
|
||||
return render_to_response("secondary_email_change_failed.html", {
|
||||
'secondary_email': pending_secondary_email_change.new_secondary_email
|
||||
})
|
||||
|
||||
account_recovery_obj.is_active = True
|
||||
account_recovery_obj.save()
|
||||
return render_to_response("secondary_email_change_successful.html")
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
|
||||
Reference in New Issue
Block a user