auth: Add an organization reactivation flow with admin confirmation.

This adds a web flow and management command for reactivating a Zulip
organization, with confirmation from one of the organization
administrators.

Further work is needed to make the emails nicer (ideally, we'd send
one email with all the admins on the `To` line, but the `send_email`
library doesn't support that).

Fixes #10783.

With significant tweaks to the email text by tabbott.
This commit is contained in:
Raymond Akornor
2018-11-12 13:15:49 +00:00
committed by Tim Abbott
parent 10e8e2acac
commit d00b889402
13 changed files with 191 additions and 3 deletions

View File

@@ -70,8 +70,11 @@ def create_confirmation_link(obj: ContentType, host: str,
confirmation_type: int, confirmation_type: int,
url_args: Optional[Dict[str, str]]=None) -> str: url_args: Optional[Dict[str, str]]=None) -> str:
key = generate_key() key = generate_key()
realm = None
if hasattr(obj, 'realm'):
realm = obj.realm
Confirmation.objects.create(content_object=obj, date_sent=timezone_now(), confirmation_key=key, Confirmation.objects.create(content_object=obj, date_sent=timezone_now(), confirmation_key=key,
realm=obj.realm, type=confirmation_type) realm=realm, type=confirmation_type)
return confirmation_url(key, host, confirmation_type, url_args) return confirmation_url(key, host, confirmation_type, url_args)
def confirmation_url(confirmation_key: str, host: str, def confirmation_url(confirmation_key: str, host: str,
@@ -99,6 +102,7 @@ class Confirmation(models.Model):
SERVER_REGISTRATION = 5 SERVER_REGISTRATION = 5
MULTIUSE_INVITE = 6 MULTIUSE_INVITE = 6
REALM_CREATION = 7 REALM_CREATION = 7
REALM_REACTIVATION = 8
type = models.PositiveSmallIntegerField() # type: int type = models.PositiveSmallIntegerField() # type: int
def __str__(self) -> str: def __str__(self) -> str:
@@ -121,6 +125,7 @@ _properties = {
'zerver.views.registration.accounts_home_from_multiuse_invite', 'zerver.views.registration.accounts_home_from_multiuse_invite',
validity_in_days=settings.INVITATION_LINK_VALIDITY_DAYS), validity_in_days=settings.INVITATION_LINK_VALIDITY_DAYS),
Confirmation.REALM_CREATION: ConfirmationType('check_prereg_key_and_redirect'), Confirmation.REALM_CREATION: ConfirmationType('check_prereg_key_and_redirect'),
Confirmation.REALM_REACTIVATION: ConfirmationType('zerver.views.realm.realm_reactivation'),
} }
def one_click_unsubscribe_link(user_profile: UserProfile, email_type: str) -> str: def one_click_unsubscribe_link(user_profile: UserProfile, email_type: str) -> str:

View File

@@ -0,0 +1,38 @@
{% extends "zerver/emails/compiled/email_base_default.html" %}
{% block illustration %}
<img src="{{ email_images_base_uri }}/registration_confirmation.png"/>
{% endblock %}
{% block content %}
<p>
{% trans %}
Dear former administrators of {{ realm_name }},
{% endtrans %}
</p>
<p>
{% trans %}
One of your administrators requested reactivation of the
previously deactivated Zulip organization hosted at {{ realm_uri }}. If you'd
like to do confirm that request and reactivate the organization, please click here:
{% endtrans %}
</p>
<a class="button" href="{{ confirmation_url }}">{{ _('Reactivate organization') }}</a>
<p>
{% trans %}
If the request was in error, you can take no action and this link
will expire in 24 hours.
{% endtrans %}
</p>
<p>
{% trans %}
Feel free to give us a shout at
<a href="mailto:{{ support_email }}">{{ support_email }}</a>,
if you have any questions.
{% endtrans %}
</p>
<p>
{{ _("Cheers,") }}<br />
{{ _("Team Zulip") }}
</p>
{% endblock %}

View File

@@ -0,0 +1 @@
Reactivate your Zulip organization

View File

@@ -0,0 +1,21 @@
{% trans %}Dear former administrators of {{ realm_name }},{% endtrans %}
{% trans %}
One of your administrators requested reactivation of the previously
deactivated Zulip organization hosted at
{{ realm_uri }}.
If you'd like to do confirm that request and reactivate the
organization, please click here:
{% endtrans %}
{{ _('To reactivate organization, please click here:') }}
<{{ confirmation_url }}>
{% trans %}
If the request was in error, you can take no action and this link
will expire in 24 hours.
{% endtrans %}
{% trans %}Feel free to give us a shout at {{ support_email }},if you have any questions.{% endtrans %}
{{ _("Cheers,") }}<br />
{{ _("Team Zulip") }}

View File

@@ -0,0 +1,14 @@
{% extends "zerver/portico_signup.html" %}
{% block portico_content %}
<div class="app portico-page">
<div class="app-main portico-page-container center-block flex full-page account-creation new-style">
<div class="inline-block">
<div class="get-started">
<h1>{{ _("Your organization has been successfully reactivated.") }}</h1>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,14 @@
{% extends "zerver/portico_signup.html" %}
{% block portico_content %}
<div class="app portico-page">
<div class="app-main portico-page-container center-block flex full-page account-creation new-style">
<div class="inline-block">
<div class="get-started">
<h1>{{ _("The organization reactivation link has expired or is not valid.") }}</h1>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -11,4 +11,4 @@ LATEST_RELEASE_ANNOUNCEMENT = "https://blog.zulip.org/2018/11/07/zulip-1-9-relea
# Typically, adding a dependency only requires a minor version bump, and # Typically, adding a dependency only requires a minor version bump, and
# removing a dependency requires a major version bump. # removing a dependency requires a major version bump.
PROVISION_VERSION = '26.14' PROVISION_VERSION = '26.15'

View File

@@ -5140,3 +5140,15 @@ def missing_any_realm_internal_bots() -> bool:
.annotate(Count('id'))) .annotate(Count('id')))
realm_count = Realm.objects.count() realm_count = Realm.objects.count()
return any(bot_counts.get(email, 0) < realm_count for email in bot_emails) return any(bot_counts.get(email, 0) < realm_count for email in bot_emails)
def do_send_realm_reactivation_email(realm: Realm) -> None:
confirmation_url = create_confirmation_link(realm, realm.host, Confirmation.REALM_REACTIVATION)
admins = realm.get_admin_users()
context = {'confirmation_url': confirmation_url,
'realm_uri': realm.uri,
'realm_name': realm.name}
for admin in admins:
send_email(
'zerver/emails/realm_reactivation', to_email=admin.email,
from_address=FromAddress.tokenized_no_reply_address(),
from_name="Zulip Account Security", context=context)

View File

@@ -0,0 +1,23 @@
from argparse import ArgumentParser
from zerver.lib.management import ZulipBaseCommand, CommandError
from zerver.lib.send_email import send_email, FromAddress
from zerver.lib.actions import do_send_realm_reactivation_email
from typing import Any
class Command(ZulipBaseCommand):
help = """Sends realm reactivation email to admins"""
def add_arguments(self, parser: ArgumentParser) -> None:
self.add_realm_args(parser, True)
def handle(self, *args: Any, **options: str) -> None:
realm = self.get_realm(options)
assert realm is not None
if not realm.deactivated:
raise CommandError("The realm %s is already active." % (realm.name,))
print('Sending email to admins')
do_send_realm_reactivation_email(realm)
print('Done!')

View File

@@ -11,7 +11,7 @@ from typing import List, Dict, Any, Optional
from django.conf import settings from django.conf import settings
from django.core.management import call_command from django.core.management import call_command
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from zerver.lib.actions import do_create_user from zerver.lib.actions import do_create_user, do_deactivate_realm, do_reactivate_realm
from zerver.lib.management import ZulipBaseCommand, CommandError, check_config from zerver.lib.management import ZulipBaseCommand, CommandError, check_config
from zerver.lib.test_classes import ZulipTestCase from zerver.lib.test_classes import ZulipTestCase
from zerver.lib.test_helpers import stdout_suppressed from zerver.lib.test_helpers import stdout_suppressed
@@ -287,3 +287,11 @@ class TestPasswordRestEmail(ZulipTestCase):
tokenized_no_reply_email = parseaddr(from_email)[1] tokenized_no_reply_email = parseaddr(from_email)[1]
self.assertTrue(re.search(self.TOKENIZED_NOREPLY_REGEX, tokenized_no_reply_email)) self.assertTrue(re.search(self.TOKENIZED_NOREPLY_REGEX, tokenized_no_reply_email))
self.assertIn("Psst. Word on the street is that you", outbox[0].body) self.assertIn("Psst. Word on the street is that you", outbox[0].body)
class TestRealmReactivationEmail(ZulipTestCase):
COMMAND_NAME = "send_realm_reactivation_email"
def test_if_realm_not_deactivated(self) -> None:
realm = get_realm('zulip')
with self.assertRaisesRegex(CommandError, "The realm %s is already active." % (realm.name,)):
call_command(self.COMMAND_NAME, "--realm=zulip")

View File

@@ -3,6 +3,7 @@ import datetime
import ujson import ujson
import re import re
import mock import mock
from email.utils import parseaddr
from django.conf import settings from django.conf import settings
from django.http import HttpResponse from django.http import HttpResponse
@@ -20,8 +21,10 @@ from zerver.lib.actions import (
do_scrub_realm, do_scrub_realm,
create_stream_if_needed, create_stream_if_needed,
do_change_plan_type, do_change_plan_type,
do_send_realm_reactivation_email
) )
from confirmation.models import create_confirmation_link, Confirmation
from zerver.lib.send_email import send_future_email from zerver.lib.send_email import send_future_email
from zerver.lib.test_classes import ZulipTestCase from zerver.lib.test_classes import ZulipTestCase
from zerver.lib.test_helpers import tornado_redirected_to_list from zerver.lib.test_helpers import tornado_redirected_to_list
@@ -195,6 +198,39 @@ class RealmTest(ZulipTestCase):
do_deactivate_realm(realm) do_deactivate_realm(realm)
self.assertTrue(realm.deactivated) self.assertTrue(realm.deactivated)
def test_realm_reactivation_link(self) -> None:
realm = get_realm('zulip')
do_deactivate_realm(realm)
self.assertTrue(realm.deactivated)
confirmation_url = create_confirmation_link(realm, realm.host, Confirmation.REALM_REACTIVATION)
response = self.client_get(confirmation_url)
self.assert_in_success_response(['Your organization has been successfully reactivated'], response)
realm = get_realm('zulip')
self.assertFalse(realm.deactivated)
def test_do_send_realm_reactivation_email(self) -> None:
realm = get_realm('zulip')
do_send_realm_reactivation_email(realm)
from django.core.mail import outbox
self.assertEqual(len(outbox), 1)
from_email = outbox[0].from_email
tokenized_no_reply_email = parseaddr(from_email)[1]
self.assertIn("Zulip Account Security", from_email)
self.assertTrue(re.search(self.TOKENIZED_NOREPLY_REGEX, tokenized_no_reply_email))
self.assertIn('Reactivate your Zulip organization', outbox[0].subject)
self.assertIn('To reactivate organization, please click here:', outbox[0].body)
admins = realm.get_admin_users()
confirmation_url = self.get_confirmation_url_from_outbox(admins[0].email)
response = self.client_get(confirmation_url)
self.assert_in_success_response(['Your organization has been successfully reactivated'], response)
realm = get_realm('zulip')
self.assertFalse(realm.deactivated)
def test_realm_reactivation_with_random_link(self) -> None:
random_link = "/reactivate/5e89081eb13984e0f3b130bf7a4121d153f1614b"
response = self.client_get(random_link)
self.assert_in_success_response(['The organization reactivation link has expired or is not valid.'], response)
def test_change_notifications_stream(self) -> None: def test_change_notifications_stream(self) -> None:
# We need an admin user. # We need an admin user.
email = 'iago@zulip.com' email = 'iago@zulip.com'

View File

@@ -1,6 +1,7 @@
from typing import Any, Dict, Optional, List from typing import Any, Dict, Optional, List
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.shortcuts import render
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
@@ -15,6 +16,7 @@ from zerver.lib.actions import (
do_set_realm_signup_notifications_stream, do_set_realm_signup_notifications_stream,
do_set_realm_property, do_set_realm_property,
do_deactivate_realm, do_deactivate_realm,
do_reactivate_realm,
) )
from zerver.lib.i18n import get_available_language_codes from zerver.lib.i18n import get_available_language_codes
from zerver.lib.request import has_request_variables, REQ, JsonableError from zerver.lib.request import has_request_variables, REQ, JsonableError
@@ -24,6 +26,7 @@ from zerver.lib.streams import access_stream_by_id
from zerver.lib.domains import validate_domain from zerver.lib.domains import validate_domain
from zerver.models import Realm, UserProfile from zerver.models import Realm, UserProfile
from zerver.forms import check_subdomain_available as check_subdomain from zerver.forms import check_subdomain_available as check_subdomain
from confirmation.models import get_object_from_key, Confirmation, ConfirmationKeyException
@require_realm_admin @require_realm_admin
@has_request_variables @has_request_variables
@@ -169,3 +172,12 @@ def check_subdomain_available(request: HttpRequest, subdomain: str) -> HttpRespo
return json_success({"msg": "available"}) return json_success({"msg": "available"})
except ValidationError as e: except ValidationError as e:
return json_success({"msg": e.message}) return json_success({"msg": e.message})
def realm_reactivation(request: HttpRequest, confirmation_key: str) -> HttpResponse:
try:
realm = get_object_from_key(confirmation_key, Confirmation.REALM_REACTIVATION)
except ConfirmationKeyException:
return render(request, 'zerver/realm_reactivation_link_error.html')
do_reactivate_realm(realm)
context = {"realm": realm}
return render(request, 'zerver/realm_reactivation.html', context)

View File

@@ -458,6 +458,10 @@ i18n_urls = [
url(r'^new/(?P<creation_key>[\w]+)$', url(r'^new/(?P<creation_key>[\w]+)$',
zerver.views.registration.create_realm, name='zerver.views.create_realm'), zerver.views.registration.create_realm, name='zerver.views.create_realm'),
# Realm Reactivation
url(r'^reactivate/(?P<confirmation_key>[\w]+)', zerver.views.realm.realm_reactivation,
name='zerver.views.realm.realm_reactivation'),
# Global public streams (Zulip's way of doing archives) # Global public streams (Zulip's way of doing archives)
url(r'^archive/streams/(?P<stream_id>\d+)/topics/(?P<topic_name>[^/]+)$', url(r'^archive/streams/(?P<stream_id>\d+)/topics/(?P<topic_name>[^/]+)$',
zerver.views.archive.archive, zerver.views.archive.archive,