change-email: Implement confirmation flow.

This adds to Zulip support for a user changing their own email
address.

It's backed by a huge amount of work by Steve Howell on making email
changes actually work from a UI perspective.

Fixes #734.
This commit is contained in:
Umair Khan 2017-01-20 16:27:38 +05:00 committed by Tim Abbott
parent 1929cc5190
commit 5bf83f9e0a
19 changed files with 484 additions and 8 deletions

View File

@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.4 on 2017-01-17 09:16
from __future__ import unicode_literals
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('confirmation', '0002_realmcreationkey'),
]
operations = [
migrations.CreateModel(
name='EmailChangeConfirmation',
fields=[
],
options={
'proxy': True,
},
bases=('confirmation.confirmation',),
),
]

View File

@ -19,7 +19,7 @@ from django.utils.timezone import now
from confirmation.util import get_status_field
from zerver.lib.utils import generate_random_token
from zerver.models import PreregistrationUser
from zerver.models import PreregistrationUser, EmailChangeStatus
from typing import Optional, Union, Any, Text
B16_RE = re.compile('^[a-f0-9]{40}$')
@ -59,7 +59,7 @@ def generate_realm_creation_url():
class ConfirmationManager(models.Manager):
def confirm(self, confirmation_key):
# type: (str) -> Union[bool, PreregistrationUser]
# type: (str) -> Union[bool, PreregistrationUser, EmailChangeStatus]
if B16_RE.search(confirmation_key):
try:
confirmation = self.get(confirmation_key=confirmation_key)
@ -140,6 +140,20 @@ class ConfirmationManager(models.Manager):
send_mail(subject, body, settings.DEFAULT_FROM_EMAIL, [email_address], html_message=html_content)
return self.create(content_object=obj, date_sent=now(), confirmation_key=confirmation_key)
class EmailChangeConfirmationManager(ConfirmationManager):
def get_activation_url(self, key, host=None):
# type: (Text, Optional[str]) -> Text
if host is None:
# This will raise exception if the key doesn't exist.
host = self.get(confirmation_key=key).content_object.realm.host
return u'%s%s%s' % (settings.EXTERNAL_URI_SCHEME,
host,
reverse('zerver.views.user_settings.confirm_email_change',
kwargs={'confirmation_key': key}))
def get_link_validity_in_days(self):
# type: () -> int
return settings.EMAIL_CHANGE_CONFIRMATION_DAYS
class Confirmation(models.Model):
content_type = models.ForeignKey(ContentType)
@ -158,6 +172,12 @@ class Confirmation(models.Model):
# type: () -> Text
return _('confirmation email for %s') % (self.content_object,)
class EmailChangeConfirmation(Confirmation):
class Meta(object):
proxy = True
objects = EmailChangeConfirmationManager()
class RealmCreationKey(models.Model):
creation_key = models.CharField(_('activation key'), max_length=40)
date_created = models.DateTimeField(_('created'), default=now)

View File

@ -517,12 +517,44 @@ function _setup_page() {
});
});
$('#change_email_button').on('click', function (e) {
e.preventDefault();
e.stopPropagation();
$('#change_email_modal').modal('hide');
var data = {};
data.email = $('.email_change_container').find("input[name='email']").val();
channel.patch({
url: '/json/settings/change',
data: data,
success: function (data) {
if ('account_email' in data) {
settings_change_success(data.account_email);
} else {
settings_change_error(i18n.t("Error changing settings: No new data supplied."));
}
},
error: function (xhr) {
settings_change_error("Error changing settings", xhr);
},
});
});
$('#default_language').on('click', function (e) {
e.preventDefault();
e.stopPropagation();
$('#default_language_modal').show().attr('aria-hidden', false);
});
$('#change_email').on('click', function (e) {
e.preventDefault();
e.stopPropagation();
$('#change_email_modal').modal('show');
var email = $('#email_value').text();
$('.email_change_container').find("input[name='email']").val(email);
});
$("#user_deactivate_account_button").on('click', function (e) {
e.preventDefault();
e.stopPropagation();

View File

@ -4,6 +4,31 @@
{{t "Your account" }}
</div>
<div class="account-settings-form">
<form class="email-change-form">
<p for="change_email" class="inline-block title">
{{t "Email" }}: <span id='email_value'>{{page_params.email}}</span>
<a id="change_email" href="#change_email" title="{{t 'Change email' }}">[Change]</a>
</p>
<div id="change_email_modal" class="modal hide" tabindex="-1" role="dialog"
aria-labelledby="change_email_modal_label" aria-hidden="true">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
<h3 id="change_email_modal_label">{{t "Change email" }}</h3>
</div>
<div class="modal-body">
<div class="input-group email_change_container">
<label for="email">{{t "Email" }}</label>
<input type="text" name="email" value="{{ page_params.email }}" />
</div>
</div>
<div class="modal-footer">
<button id='change_email_button' class="btn btn-success" data-dismiss="modal" aria-hidden="true">{{t "Change" }}</button>
<button class="btn btn-primary" data-dismiss="modal" aria-hidden="true">{{t "Close" }}</button>
</div>
</div>
</form>
<form action="/json/settings/change" method="post"
class="form-horizontal your-account-settings">

View File

@ -0,0 +1,34 @@
{% extends "zerver/portico.html" %}
{% block portico_content %}
<div class="pitch">
<hr/>
{% if confirmed %}
<p>
This confirms that the email address for your Zulip account has changed
from {{old_email}} to {{ new_email }}.
</p>
{% else %}
<p class="lead">Whoops, something's not right. We couldn't find your confirmation ID!</p>
{% if verbose_support_offers %}
<p>Make sure you copied the link correctly in to your browser. If you're
still encountering this page, its probably our fault. We're sorry.</p>
<p>Anyway, shoot us a line at
<a href="mailto:{{ support_email }}">{{ support_email }}</a>
and we'll get this resolved shortly.
</p>
{% else %}
<p>Make sure you copied the link correctly in to your browser.</p>
<p>If you're still having problems, please contact your Zulip administrator at
<a href="mailto:{{ support_email }}">{{ support_email }}</a>.
</p>
{% endif %}
{% endif %}
</div>
{% endblock %}

View File

@ -0,0 +1,42 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>Zulip</title>
</head>
<body>
<table width="80%" style="align:center; max-width:800px" align="center">
<tr>
<td style="font-size:16px; font-family:Helvetica;">
<p>Hi!
</p>
<p>
We received a request to change the email address for the Zulip
account on {{ realm.uri }} from {{ old_email }} to {{ new_email }}.
If you would like to confirm this change, please click this link:
<br />
<a href="{{ activate_url }}" style="color:#08c">{{ activate_url }}</a>
</p>
<p>
{% if verbose_support_offers %}
Feel free to give us a shout at
<a href="mailto:{{ support_email }}" style="color:#08c">{{ support_email }}</a>
if you have any questions or you did not request this change.
{% else %}
If you did not request this change, please contact the administrator
of this Zulip server at
<a href="mailto:{{ support_email }}" style="color:#08c">{{ support_email }}</a>.
{% endif %}
</p>
<p>
Cheers,<br />
The Zulip Team
</p>
</td>
</tr>
</table>
</body>
</html>

View File

@ -0,0 +1 @@
[Zulip] Confirm your new email address for {{ realm.name }}

View File

@ -0,0 +1,19 @@
Hi!
We received a request to change the email address for the Zulip account on
{{ realm.uri }} from {{ old_email }} to {{ new_email }}. If you would like
to confirm this change, please click this link:
{{ activate_url }}
{% if verbose_support_offers %}
Feel free to give us a shout at <{{ support_email }}> if you have any
questions or you did not request this change.
{% else %}
If you did not request this change, please contact the administrator
of this Zulip server at <{{ support_email }}>.
{% endif %}
Cheers,
The Zulip Team

View File

@ -0,0 +1,9 @@
Hi,
We just wanted to let you know that the email associated with your Zulip account
was recently changed to {{ new_email }}. If you did not request this change,
please contact us immediately at <{{ support_email }}>.
Best,
The Zulip Team

View File

@ -0,0 +1 @@
[Zulip] Email address changed for {{ realm.name }}

View File

@ -375,6 +375,8 @@ def build_custom_checkers(by_lang):
'return json_error(data=error_data, msg=ret_error)'),
('zerver/views/streams.py', 'return json_error(property_conversion)'),
('zerver/views/streams.py', 'return json_error(e.error, data=result, status=404)'),
# error and skipped are already internationalized
('zerver/views/user_settings.py', 'return json_error(error or skipped)'),
# We can't do anything about this.
('zerver/views/realm_filters.py', 'return json_error(e.messages[0], data={"errors": dict(e)})'),
]),

View File

@ -37,7 +37,7 @@ from zerver.models import Realm, RealmEmoji, Stream, UserProfile, UserActivity,
realm_filters_for_realm, RealmFilter, receives_offline_notifications, \
ScheduledJob, get_owned_bot_dicts, \
get_old_unclaimed_attachments, get_cross_realm_emails, receives_online_notifications, \
Reaction
Reaction, EmailChangeStatus
from zerver.lib.alert_words import alert_words_in_realm
from zerver.lib.avatar import avatar_url
@ -50,7 +50,7 @@ from importlib import import_module
from django.core.mail import EmailMessage
from django.utils.timezone import now
from confirmation.models import Confirmation
from confirmation.models import Confirmation, EmailChangeConfirmation
import six
from six.moves import filter
from six.moves import map
@ -708,6 +708,30 @@ def do_change_user_email(user_profile, new_email):
'old_email': old_email,
'new_email': new_email})
def do_start_email_change_process(user_profile, new_email):
# type: (UserProfile, Text) -> None
old_email = user_profile.email
user_profile.email = new_email
context = {'support_email': settings.ZULIP_ADMINISTRATOR,
'verbose_support_offers': settings.VERBOSE_SUPPORT_OFFERS,
'realm': user_profile.realm,
'old_email': old_email,
'new_email': new_email,
}
with transaction.atomic():
obj = EmailChangeStatus.objects.create(new_email=new_email,
old_email=old_email,
user_profile=user_profile,
realm=user_profile.realm)
EmailChangeConfirmation.objects.send_confirmation(
obj, new_email,
additional_context=context,
host=user_profile.realm.host,
)
def compute_irc_user_fullname(email):
# type: (NonBinaryStr) -> NonBinaryStr
return email.split("@")[0] + " (IRC)"

View File

@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.5 on 2017-02-23 05:37
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 = [
('zerver', '0052_auto_fix_realmalias_realm_nullable'),
]
operations = [
migrations.CreateModel(
name='EmailChangeStatus',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('new_email', models.EmailField(max_length=254)),
('old_email', models.EmailField(max_length=254)),
('updated_at', models.DateTimeField(auto_now=True)),
('status', models.IntegerField(default=0)),
('realm', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='zerver.Realm')),
('user_profile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@ -678,6 +678,18 @@ class PreregistrationUser(models.Model):
realm = models.ForeignKey(Realm, null=True) # type: Optional[Realm]
class EmailChangeStatus(models.Model):
new_email = models.EmailField() # type: Text
old_email = models.EmailField() # type: Text
updated_at = models.DateTimeField(auto_now=True) # type: datetime.datetime
user_profile = models.ForeignKey(UserProfile) # type: UserProfile
# status: whether an object has been confirmed.
# if confirmed, set to confirmation.settings.STATUS_ACTIVE
status = models.IntegerField(default=0) # type: int
realm = models.ForeignKey(Realm) # type: Realm
class PushDeviceToken(models.Model):
APNS = 1
GCM = 2

View File

@ -0,0 +1,140 @@
# -*- coding: utf-8 -*-
from __future__ import absolute_import
import datetime
from typing import Any
import django
import mock
from django.conf import settings
from django.core import mail
from django.http import HttpResponse
from django.urls import reverse
from django.utils.timezone import now
from confirmation.models import EmailChangeConfirmation, generate_key
from zerver.lib.actions import do_start_email_change_process
from zerver.lib.test_classes import (
ZulipTestCase,
)
from zerver.models import get_user_profile_by_email, EmailChangeStatus, Realm
class EmailChangeTestCase(ZulipTestCase):
def test_confirm_email_change_with_non_existent_key(self):
# type: () -> None
self.login('hamlet@zulip.com')
key = generate_key()
with self.assertRaises(EmailChangeConfirmation.DoesNotExist):
url = EmailChangeConfirmation.objects.get_activation_url(key)
url = EmailChangeConfirmation.objects.get_activation_url(
key, 'testserver')
response = self.client_get(url)
self.assertEqual(response.status_code, 200)
self.assertIn("Whoops", response.content.decode('utf8'))
def test_confirm_email_change_with_invalid_key(self):
# type: () -> None
self.login('hamlet@zulip.com')
key = 'invalid key'
with self.assertRaises(EmailChangeConfirmation.DoesNotExist):
url = EmailChangeConfirmation.objects.get_activation_url(key)
url = EmailChangeConfirmation.objects.get_activation_url(
key, 'testserver')
response = self.client_get(url)
self.assertEqual(response.status_code, 200)
self.assertIn("Whoops", response.content.decode('utf8'))
def test_confirm_email_change_when_time_exceeded(self):
# type: () -> None
old_email = 'hamlet@zulip.com'
new_email = 'hamlet-new@zulip.com'
user_profile = get_user_profile_by_email(old_email)
obj = EmailChangeStatus.objects.create(new_email=new_email,
old_email=old_email,
user_profile=user_profile,
realm=user_profile.realm)
key = generate_key()
date_sent = now() - datetime.timedelta(days=2)
EmailChangeConfirmation.objects.create(content_object=obj,
date_sent=date_sent,
confirmation_key=key)
url = EmailChangeConfirmation.objects.get_activation_url(key)
response = self.client_get(url)
self.assertEqual(response.status_code, 200)
self.assertIn("Whoops", response.content.decode('utf8'))
def test_confirm_email_change(self):
# type: () -> None
old_email = 'hamlet@zulip.com'
new_email = 'hamlet-new@zulip.com'
user_profile = get_user_profile_by_email(old_email)
obj = EmailChangeStatus.objects.create(new_email=new_email,
old_email=old_email,
user_profile=user_profile,
realm=user_profile.realm)
key = generate_key()
EmailChangeConfirmation.objects.create(content_object=obj,
date_sent=now(),
confirmation_key=key)
url = EmailChangeConfirmation.objects.get_activation_url(key)
response = self.client_get(url)
self.assertEqual(response.status_code, 200)
self.assertIn("This confirms that the email address for your Zulip",
response.content.decode('utf8'))
user_profile = get_user_profile_by_email(new_email)
self.assertTrue(bool(user_profile))
obj.refresh_from_db()
self.assertEqual(obj.status, 1)
def test_start_email_change_process(self):
# type: () -> None
user_profile = get_user_profile_by_email('hamlet@zulip.com')
do_start_email_change_process(user_profile, 'hamlet-new@zulip.com')
self.assertEqual(EmailChangeStatus.objects.count(), 1)
def test_end_to_end_flow(self):
# type: () -> None
data = {'email': 'hamlet-new@zulip.com'}
email = 'hamlet@zulip.com'
self.login(email)
url = '/json/settings/change'
self.assertEqual(len(mail.outbox), 0)
result = self.client_post(url, data)
self.assertEqual(len(mail.outbox), 1)
self.assertIn('We have sent you an email', result.content.decode('utf8'))
email_message = mail.outbox[0]
self.assertEqual(
email_message.subject,
'[Zulip] Confirm your new email address for Zulip Dev'
)
body = email_message.body
self.assertIn('We received a request to change the email', body)
activation_url = [s for s in body.split('\n') if s][4]
response = self.client_get(activation_url)
self.assertEqual(response.status_code, 200)
self.assertIn("This confirms that the email address",
response.content.decode('utf8'))
def test_post_invalid_email(self):
# type: () -> None
data = {'email': 'hamlet-new'}
email = 'hamlet@zulip.com'
self.login(email)
url = '/json/settings/change'
result = self.client_post(url, data)
self.assertIn('Invalid address', result.content.decode('utf8'))
def test_post_same_email(self):
# type: () -> None
data = {'email': 'hamlet@zulip.com'}
email = 'hamlet@zulip.com'
self.login(email)
url = '/json/settings/change'
result = self.client_post(url, data)
self.assertEqual('success', result.json()['result'])
self.assertEqual('', result.json()['msg'])

View File

@ -84,6 +84,10 @@ class TemplateTestCase(ZulipTestCase):
'confirmation/mituser_confirmation_email_subject.txt',
'confirmation/mituser_invite_email_body.txt',
'confirmation/mituser_invite_email_subject.txt',
'confirmation/emailchangestatus_confirmation_email.subject',
'confirmation/emailchangestatus_confirmation_email.html',
'confirmation/emailchangestatus_confirmation_email.txt',
'confirmation/notify_change_in_email_subject.txt',
'corporate/mit.html',
'corporate/privacy.html',
'corporate/zephyr.html',

View File

@ -5,7 +5,11 @@ from typing import Text
from django.utils.translation import ugettext as _
from django.conf import settings
from django.contrib.auth import authenticate, update_session_auth_hash
from django.core.mail import send_mail
from django.http import HttpRequest, HttpResponse
from django.shortcuts import redirect, render
from django.template.loader import render_to_string
from django.urls import reverse
from zerver.decorator import authenticated_json_post_view, has_request_variables, REQ
from zerver.lib.actions import do_change_password, \
@ -17,7 +21,8 @@ from zerver.lib.actions import do_change_password, \
do_change_enable_stream_desktop_notifications, do_change_enable_stream_sounds, \
do_regenerate_api_key, do_change_avatar_fields, do_change_twenty_four_hour_time, \
do_change_left_side_userlist, do_change_default_language, \
do_change_pm_content_in_desktop_notifications
do_change_pm_content_in_desktop_notifications, validate_email, \
do_change_user_email, do_start_email_change_process
from zerver.lib.avatar import avatar_url
from zerver.lib.i18n import get_available_language_codes
from zerver.lib.response import json_success, json_error
@ -25,7 +30,43 @@ from zerver.lib.upload import upload_avatar_image
from zerver.lib.validator import check_bool, check_string
from zerver.lib.request import JsonableError
from zerver.lib.users import check_change_full_name
from zerver.models import UserProfile, Realm, name_changes_disabled
from zerver.models import UserProfile, Realm, name_changes_disabled, \
EmailChangeStatus
from confirmation.models import EmailChangeConfirmation
def confirm_email_change(request, confirmation_key):
# type: (HttpRequest, str) -> HttpResponse
confirmation_key = confirmation_key.lower()
obj = EmailChangeConfirmation.objects.confirm(confirmation_key)
confirmed = False
new_email = old_email = None # type: Text
if obj:
confirmed = True
assert isinstance(obj, EmailChangeStatus)
new_email = obj.new_email
old_email = obj.old_email
do_change_user_email(obj.user_profile, obj.new_email)
context = {'support_email': settings.ZULIP_ADMINISTRATOR,
'verbose_support_offers': settings.VERBOSE_SUPPORT_OFFERS,
'realm': obj.realm,
'new_email': new_email,
}
subject = render_to_string(
'confirmation/notify_change_in_email_subject.txt', context)
body = render_to_string(
'confirmation/notify_change_in_email_body.txt', context)
send_mail(subject, body, settings.DEFAULT_FROM_EMAIL, [old_email])
ctx = {
'confirmed': confirmed,
'support_email': settings.ZULIP_ADMINISTRATOR,
'verbose_support_offers': settings.VERBOSE_SUPPORT_OFFERS,
'new_email': new_email,
'old_email': old_email,
}
return render(request, 'confirmation/confirm_email_change.html', context=ctx)
@has_request_variables
def json_change_ui_settings(request, user_profile,
@ -53,11 +94,12 @@ def json_change_ui_settings(request, user_profile,
@has_request_variables
def json_change_settings(request, user_profile,
full_name=REQ(default=""),
email=REQ(default=""),
old_password=REQ(default=""),
new_password=REQ(default=""),
confirm_password=REQ(default="")):
# type: (HttpRequest, UserProfile, Text, Text, Text, Text) -> HttpResponse
if not (full_name or new_password):
# type: (HttpRequest, UserProfile, Text, Text, Text, Text, Text) -> HttpResponse
if not (full_name or new_password or email):
return json_error(_("No new data supplied"))
if new_password != "" or confirm_password != "":
@ -82,6 +124,16 @@ def json_change_settings(request, user_profile,
request.session.save()
result = {}
new_email = email.strip()
if user_profile.email != email and new_email != '':
error, skipped = validate_email(user_profile, new_email)
if error or skipped:
return json_error(error or skipped)
do_start_email_change_process(user_profile, new_email)
result['account_email'] = _('We have sent you an email on your '
'new email address for confirmation.')
if user_profile.full_name != full_name and full_name.strip() != "":
if name_changes_disabled(user_profile.realm):
# Failingly silently is fine -- they can't do it through the UI, so

View File

@ -99,6 +99,7 @@ DEFAULT_SETTINGS = {'TWITTER_CONSUMER_KEY': '',
'TWITTER_CONSUMER_SECRET': '',
'TWITTER_ACCESS_TOKEN_KEY': '',
'TWITTER_ACCESS_TOKEN_SECRET': '',
'EMAIL_CHANGE_CONFIRMATION_DAYS': 1,
'EMAIL_GATEWAY_PATTERN': '',
'EMAIL_GATEWAY_EXAMPLE': '',
'EMAIL_GATEWAY_BOT': None,

View File

@ -25,6 +25,7 @@ import zerver.views.zephyr
import zerver.views.users
import zerver.views.unsubscribe
import zerver.views.integrations
import zerver.views.user_settings
import confirmation.views
from zerver.lib.rest import rest_dispatch
@ -103,6 +104,10 @@ i18n_urls = [
name='zerver.views.registration.accounts_register'),
url(r'^accounts/do_confirm/(?P<confirmation_key>[\w]+)', confirmation.views.confirm, name='confirmation.views.confirm'),
url(r'^accounts/confirm_new_email/(?P<confirmation_key>[\w]+)',
zerver.views.user_settings.confirm_email_change,
name='zerver.views.user_settings.confirm_email_change'),
# Email unsubscription endpoint. Allows for unsubscribing from various types of emails,
# including the welcome emails (day 1 & 2), missed PMs, etc.
url(r'^accounts/unsubscribe/(?P<type>[\w]+)/(?P<token>[\w]+)',