zilencer: Add automated signup system for push notifications.

Based on an initial version by Rishi Gupta.

Fixes #7325.
This commit is contained in:
Tim Abbott 2018-05-03 16:40:46 -07:00
parent b1ad7593ba
commit 43098a6f7c
8 changed files with 288 additions and 23 deletions

View File

@ -18,28 +18,28 @@ support forwarding push notifications to a central push notification
forwarding service. You can enable this for your Zulip server as
follows:
1. First, contact support@zulipchat.com with the `zulip_org_id` and
`zulip_org_key` values from your `/etc/zulip/zulip-secrets.conf` file, as
well as a `hostname` and `contact email` address you'd like us to use in case
of any issues (we hope to have a nice web flow available for this soon).
1. If you're running Zulip 1.8.1 or newer, you can run `manage.py
register_server` from `/home/zulip/deployments/current`. This
command will print the registration data it would send to the
mobile push notifications service, ask you to accept the terms of
service, and if you accept, register your server. Otherwise, see
the [legacy signup instructions](#legacy-signup).
2. We'll enable push notifications for your server on our end. Look for a
reply from Zulipchat support within 24 hours.
3. Uncomment the `PUSH_NOTIFICATION_BOUNCER_URL = "https://push.zulipchat.com"`
line in your `/etc/zulip/settings.py` file, and
1. Uncomment the `PUSH_NOTIFICATION_BOUNCER_URL =
'https://push.zulipchat.com'` line in your `/etc/zulip/settings.py`
file (i.e. remove the `#` at the start of the line), and
[restart your Zulip server](../production/maintain-secure-upgrade.html#updating-settings).
Note that if you installed Zulip older than 1.6, you'll need to add
the line (it won't be there to uncomment).
If you installed your Zulip server with a version older than 1.6,
you'll need to add the line (it won't be there to uncomment).
4. If you or your users have already set up the Zulip mobile app,
1. If you or your users have already set up the Zulip mobile app,
you'll each need to log out and log back in again in order to start
getting push notifications.
That should be all you need to do!
Congratulations! You've successful setup the service.
If you'd like to verify the full pipeline, you can do the following.
Please follow the instructions carefully:
If you'd like to verify that everything is working, you can do the
following. Please follow the instructions carefully:
* [Configure mobile push notifications to always be sent][notification-settings]
(normally they're only sent if you're idle, which isn't ideal for
@ -57,9 +57,19 @@ in the Android notification area.
[notification-settings]: https://zulipchat.com/help/configure-mobile-notifications
Note that use of the push notification bouncer is subject to the
[Zulipchat Terms of Service](https://zulipchat.com/terms/). By using push
notifications, you agree to those terms.
## Updating your server's registration
Your server's registration includes the server's hostname and contact
email address (from `EXTERNAL_HOST` and `ZULIP_ADMINISTRATOR` in
`/etc/zulip/settings.py`, aka the `--hostname` and `--email` options
in the installer). You can update your server's registration data by
running `manage.py register_server` again.
If you'd like to rotate your server's API key for this service
(`zulip_org_key`), you need to use `manage.py register_server
--rotate-key` option; it will automatically generate a new
`zulip_org_key` and store that new key in
`/etc/zulip/zulip-secrets.conf`.
## Why this is necessary
@ -77,7 +87,11 @@ notification forwarding service, which allows registered Zulip servers
to send push notifications to the Zulip app indirectly (through the
forwarding service).
## Security and privacy implications
## Security and privacy
Use of the push notification bouncer is subject to the
[Zulipchat Terms of Service](https://zulipchat.com/terms/). By using
push notifications, you agree to those terms.
We've designed this push notification bouncer service with security
and privacy in mind:
@ -110,6 +124,18 @@ and privacy in mind:
If you have any questions about the security model, contact
support@zulipchat.com.
## Legacy signup
Here are the legacy instructions for signing a server up for push
notifications:
1. First, contact support@zulipchat.com with the `zulip_org_id` and
`zulip_org_key` values from your `/etc/zulip/zulip-secrets.conf` file, as
well as a `hostname` and `contact email` address you'd like us to use in case
of any issues (we hope to have a nice web flow available for this soon).
2. We'll enable push notifications for your server on our end. Look for a
reply from Zulipchat support within 24 hours.
## Sending push notifications directly from your server
As we discussed above, it is impossible for a single app in their

View File

@ -0,0 +1,88 @@
from argparse import ArgumentParser
import json
import requests
import subprocess
from typing import Any
from django.conf import settings
from django.core.management.base import CommandError
from django.utils.crypto import get_random_string
from zerver.lib.management import ZulipBaseCommand, check_config
from zilencer.models import RemoteZulipServer
if settings.DEVELOPMENT:
SECRETS_FILENAME = "zproject/dev-secrets.conf"
else:
SECRETS_FILENAME = "/etc/zulip/zulip-secrets.conf"
class Command(ZulipBaseCommand):
help = """Register a remote Zulip server for push notifications."""
def add_arguments(self, parser: ArgumentParser) -> None:
parser.add_argument('--agree_to_terms_of_service',
dest='agree_to_terms_of_service',
action='store_true',
default=False,
help="Agree to the Zulipchat Terms of Service: https://zulipchat.com/terms/.")
parser.add_argument('--rotate-key',
dest="rotate_key",
action='store_true',
default=False,
help="Automatically rotate your server's zulip_org_key")
def handle(self, **options: Any) -> None:
check_config()
if not options['agree_to_terms_of_service'] and not options["rotate_key"]:
raise CommandError(
"You must agree to the Zulipchat Terms of Service: https://zulipchat.com/terms/. Run as:\n"
" python manage.py register_remote_server --agree_to_terms_of_service\n")
if not settings.ZULIP_ORG_ID:
raise CommandError("Missing zulip_org_id; run scripts/setup/generate_secrets.py to generate.")
if not settings.ZULIP_ORG_KEY:
raise CommandError("Missing zulip_org_key; run scripts/setup/generate_secrets.py to generate.")
request = {
"zulip_org_id": settings.ZULIP_ORG_ID,
"zulip_org_key": settings.ZULIP_ORG_KEY,
"hostname": settings.EXTERNAL_HOST,
"contact_email": settings.ZULIP_ADMINISTRATOR}
if options["rotate_key"]:
request["new_org_key"] = get_random_string(64)
print("The following data will be submitted to the push notification service:")
for key in sorted(request.keys()):
print(" %s: %s" % (key, request[key]))
print("")
if not options['agree_to_terms_of_service'] and not options["rotate_key"]:
raise CommandError(
"You must agree to the Terms of Service: https://zulipchat.com/terms/\n"
" python manage.py register_remote_server --agree_to_terms_of_service\n")
registration_url = settings.PUSH_NOTIFICATION_BOUNCER_URL + "/api/v1/remotes/server/register"
try:
response = requests.post(registration_url, params=request)
except Exception:
raise CommandError("Network error connecting to push notifications service (%s)"
% (settings.PUSH_NOTIFICATION_BOUNCER_URL,))
try:
response.raise_for_status()
except Exception:
content_dict = json.loads(response.content.decode("utf-8"))
raise CommandError("Error: " + content_dict['msg'])
if response.json()['created']:
print("You've successfully register for the Mobile Push Notification Service!\n"
"To finish setup for sending push notifications:")
print("- Uncomment PUSH_NOTIFICATION_BOUNCER_URL in /etc/zulip/settings.py (remove the '#')")
print("- Restart the server, using /home/zulip/deployments/current/scripts/restart-server")
print("- Return to the documentation to learn how to test push notifications")
else:
if options["rotate_key"]:
print("Success! Updating %s with the new key..." % (SECRETS_FILENAME,))
subprocess.check_call(["crudini", '--set', SECRETS_FILENAME, "secrets", "zulip_org_key",
request["new_org_key"]])
print("Mobile Push Notification Service registration successfully updated!")

View File

@ -9,12 +9,15 @@ from typing import Any, Dict, List, Optional, Union, SupportsInt, Text
import base64
import gcm
import json
import os
import ujson
import uuid
from django.test import TestCase, override_settings
from django.conf import settings
from django.http import HttpResponse
from django.utils.crypto import get_random_string
from zerver.models import (
PushDeviceToken,
@ -1302,3 +1305,95 @@ class TestPushNotificationsContent(ZulipTestCase):
for test in fixtures:
actual_output = get_mobile_push_content(test["rendered_content"])
self.assertEqual(actual_output, test["expected_output"])
class PushBouncerSignupTest(ZulipTestCase):
def test_push_signup_invalid_host(self) -> None:
zulip_org_id = str(uuid.uuid4())
zulip_org_key = get_random_string(64)
request = dict(
zulip_org_id=zulip_org_id,
zulip_org_key=zulip_org_key,
hostname="invalid-host",
contact_email="server-admin@example.com",
)
result = self.client_post("/api/v1/remotes/server/register", request)
self.assert_json_error(result, "invalid-host is not a valid hostname")
def test_push_signup_invalid_email(self) -> None:
zulip_org_id = str(uuid.uuid4())
zulip_org_key = get_random_string(64)
request = dict(
zulip_org_id=zulip_org_id,
zulip_org_key=zulip_org_key,
hostname="example.com",
contact_email="server-admin",
)
result = self.client_post("/api/v1/remotes/server/register", request)
self.assert_json_error(result, "Enter a valid email address.")
def test_push_signup_success(self) -> None:
zulip_org_id = str(uuid.uuid4())
zulip_org_key = get_random_string(64)
request = dict(
zulip_org_id=zulip_org_id,
zulip_org_key=zulip_org_key,
hostname="example.com",
contact_email="server-admin@example.com",
)
result = self.client_post("/api/v1/remotes/server/register", request)
self.assert_json_success(result)
server = RemoteZulipServer.objects.get(uuid=zulip_org_id)
self.assertEqual(server.hostname, "example.com")
self.assertEqual(server.contact_email, "server-admin@example.com")
# Update our hostname
request = dict(
zulip_org_id=zulip_org_id,
zulip_org_key=zulip_org_key,
hostname="zulip.example.com",
contact_email="server-admin@example.com",
)
result = self.client_post("/api/v1/remotes/server/register", request)
self.assert_json_success(result)
server = RemoteZulipServer.objects.get(uuid=zulip_org_id)
self.assertEqual(server.hostname, "zulip.example.com")
self.assertEqual(server.contact_email, "server-admin@example.com")
# Now test rotating our key
request = dict(
zulip_org_id=zulip_org_id,
zulip_org_key=zulip_org_key,
hostname="example.com",
contact_email="server-admin@example.com",
new_org_key=get_random_string(64),
)
result = self.client_post("/api/v1/remotes/server/register", request)
self.assert_json_success(result)
server = RemoteZulipServer.objects.get(uuid=zulip_org_id)
self.assertEqual(server.hostname, "example.com")
self.assertEqual(server.contact_email, "server-admin@example.com")
zulip_org_key = request["new_org_key"]
self.assertEqual(server.api_key, zulip_org_key)
# Update our hostname
request = dict(
zulip_org_id=zulip_org_id,
zulip_org_key=zulip_org_key,
hostname="zulip.example.com",
contact_email="new-server-admin@example.com",
)
result = self.client_post("/api/v1/remotes/server/register", request)
self.assert_json_success(result)
server = RemoteZulipServer.objects.get(uuid=zulip_org_id)
self.assertEqual(server.hostname, "zulip.example.com")
self.assertEqual(server.contact_email, "new-server-admin@example.com")
# Now test trying to double-create with a new random key fails
request = dict(
zulip_org_id=zulip_org_id,
zulip_org_key=get_random_string(64),
hostname="example.com",
contact_email="server-admin@example.com",
)
result = self.client_post("/api/v1/remotes/server/register", request)
self.assert_json_error(result, "zulip_org_id and zulip_org_key do not match.")

View File

@ -9,10 +9,14 @@ def get_remote_server_by_uuid(uuid: Text) -> 'RemoteZulipServer':
return RemoteZulipServer.objects.get(uuid=uuid)
class RemoteZulipServer(models.Model):
uuid = models.CharField(max_length=36, unique=True) # type: Text
api_key = models.CharField(max_length=64) # type: Text
UUID_LENGTH = 36
API_KEY_LENGTH = 64
HOSTNAME_MAX_LENGTH = 128
hostname = models.CharField(max_length=128) # type: Text
uuid = models.CharField(max_length=UUID_LENGTH, unique=True) # type: Text
api_key = models.CharField(max_length=API_KEY_LENGTH) # type: Text
hostname = models.CharField(max_length=HOSTNAME_MAX_LENGTH) # type: Text
contact_email = models.EmailField(blank=True, null=False) # type: Text
last_updated = models.DateTimeField('last updated', auto_now=True) # type: datetime.datetime

View File

@ -17,6 +17,9 @@ v1_api_and_json_patterns = [
{'POST': 'zilencer.views.unregister_remote_push_device'}),
url('^remotes/push/notify$', rest_dispatch,
{'POST': 'zilencer.views.remote_server_notify_push'}),
# Push signup doesn't use the REST API, since there's no auth.
url('^remotes/server/register$', zilencer.views.register_remote_server),
]
# Make a copy of i18n_urlpatterns so that they appear without prefix for English

View File

@ -1,6 +1,9 @@
from typing import Any, Dict, Optional, Text, Union, cast
from django.core.exceptions import ValidationError
from django.core.validators import validate_email, URLValidator
from django.db import IntegrityError
from django.http import HttpRequest, HttpResponse
from django.utils import timezone
from django.utils.translation import ugettext as _, ugettext as err_
@ -15,7 +18,8 @@ from zerver.lib.push_notifications import send_android_push_notification, \
send_apple_push_notification
from zerver.lib.request import REQ, has_request_variables
from zerver.lib.response import json_error, json_success
from zerver.lib.validator import check_int
from zerver.lib.validator import check_int, check_string, check_url, \
validate_login_email, check_capped_string, check_string_fixed_length
from zerver.models import UserProfile, Realm
from zerver.views.push_notifications import validate_token
from zilencer.lib.stripe import STRIPE_PUBLISHABLE_KEY, count_stripe_cards, \
@ -33,6 +37,49 @@ def validate_bouncer_token_request(entity: Union[UserProfile, RemoteZulipServer]
validate_entity(entity)
validate_token(token, kind)
@csrf_exempt
@require_post
@has_request_variables
def register_remote_server(
request: HttpRequest,
zulip_org_id: str=REQ(str_validator=check_string_fixed_length(RemoteZulipServer.UUID_LENGTH)),
zulip_org_key: str=REQ(str_validator=check_string_fixed_length(RemoteZulipServer.API_KEY_LENGTH)),
hostname: str=REQ(str_validator=check_capped_string(RemoteZulipServer.HOSTNAME_MAX_LENGTH)),
contact_email: str=REQ(str_validator=check_string),
new_org_key: Optional[str]=REQ(str_validator=check_string_fixed_length(
RemoteZulipServer.API_KEY_LENGTH), default=None),
) -> HttpResponse:
# REQ validated the the field lengths, but we still need to
# validate the format of these fields.
try:
# TODO: Ideally we'd not abuse the URL validator this way
url_validator = URLValidator()
url_validator('http://' + hostname)
except ValidationError:
raise JsonableError(_('%s is not a valid hostname') % (hostname,))
try:
validate_email(contact_email)
except ValidationError as e:
raise JsonableError(e.message)
remote_server, created = RemoteZulipServer.objects.get_or_create(
uuid=zulip_org_id,
defaults={'hostname': hostname, 'contact_email': contact_email,
'api_key': zulip_org_key})
if not created:
if remote_server.api_key != zulip_org_key:
raise JsonableError(err_("zulip_org_id and zulip_org_key do not match."))
else:
remote_server.hostname = hostname
remote_server.contact_email = contact_email
if new_org_key is not None:
remote_server.api_key = new_org_key
remote_server.save()
return json_success({'created': created})
@has_request_variables
def register_remote_push_device(request: HttpRequest, entity: Union[UserProfile, RemoteZulipServer],
user_id: int=REQ(), token: bytes=REQ(),

View File

@ -87,3 +87,4 @@ SENDFILE_BACKEND = 'sendfile.backends.development'
# Set this True to send all hotspots in development
ALWAYS_SEND_ALL_HOTSPOTS = False # type: bool
PUSH_NOTIFICATION_BOUNCER_URL = EXTERNAL_URI_SCHEME + EXTERNAL_HOST

View File

@ -158,3 +158,4 @@ SOCIAL_AUTH_GITHUB_SECRET = "secret"
# By default two factor authentication is disabled in tests.
# Explicitly set this to True within tests that must have this on.
TWO_FACTOR_AUTHENTICATION_ENABLED = False
PUSH_NOTIFICATION_BOUNCER_URL = None