compose: Rewrite Zoom video call integration to use OAuth.

This reimplements our Zoom video call integration to use an OAuth
application.  In addition to providing a cleaner setup experience,
especially on zulipchat.com where the server administrators can have
done the app registration already, it also fixes the limitation of the
previous integration that it could only have one call active at a time
when set up with typical Zoom API keys.

Fixes #11672.

Co-authored-by: Marco Burstein <marco@marco.how>
Co-authored-by: Tim Abbott <tabbott@zulipchat.com>
Signed-off-by: Anders Kaseorg <andersk@mit.edu>
This commit is contained in:
Anders Kaseorg 2019-11-16 00:26:28 -08:00 committed by Tim Abbott
parent 7a53da7526
commit 4d04fa3118
36 changed files with 538 additions and 284 deletions

View File

@ -1,15 +1,15 @@
{% extends "!layout.html" %}
{% block document %}
<!---
{#
# This allows us to insert a warning that appears only on the development
# version e.g. to say that something is likely to have changed.
# For more info see: https://www.sphinx-doc.org/en/master/templating.html
-->
{% if (pagename == "production/management-commands" or pagename == "production/email-gateway" or pagename == "production/upgrade-or-modify") and release.endswith('+git') %}
<!--
# email-gateway.html page doesn't exist in the stable documentation yet.
#}
{% if pagename in ["production/management-commands", "production/email-gateway", "production/upgrade-or-modify", "production/zoom-configuration"] and release.endswith('+git') %}
{#
# This page doesn't exist in the stable documentation yet.
# This temporary workaround prevents CircleCI failure and should be removed after the next release.
-->
#}
<div class="admonition warning">
<p class="first admonition-title">Warning</p>
<p class="last">You are reading a <strong>development version</strong> of the Zulip documentation. These instructions may not correspond to the latest Zulip Server release.

View File

@ -21,3 +21,4 @@ Zulip in Production
email
deployment
email-gateway
zoom-configuration

View File

@ -100,6 +100,7 @@ Some popular settings in `/etc/zulip/settings.py` include:
tweets.
* The [email gateway](../production/email-gateway.md), which lets
users send emails into Zulip.
* The [Zoom video call integration](zoom-configuration.md).
## Zulip announcement list

View File

@ -0,0 +1,36 @@
# Zoom Video Calling OAuth Configuration
To use the [Zoom](https://zoom.us) integration on a self-hosted
installation, you'll need to register a custom Zoom Application as
follows:
1. Visit the [Zoom Marketplace](https://marketplace.zoom.us/develop/create).
1. Create a new application, choosing **OAuth** as the app type.
We recommend using a name like "ExampleCorp Zulip".
1. Select *account-level app* for the authentication type, disable
the option to publish the app in the Marketplace, and click **Create**.
1. Inside of the Zoom app management page, set the Redirect URL to
`https://zulip.example.com/calls/zoom/complete` (replacing
`zulip.example.com` by your main Zulip hostname).
1. Set the "Scopes" to `meeting:write:admin`.
You can then configure your Zulip server to use that Zoom application
as follows:
1. In `/etc/zulip/zulip-secrets.conf`, set `video_zoom_client_secret`
to be your app's "Client Secret".
1. In `/etc/zulip/settings.py`, set `VIDEO_ZOOM_CLIENT_ID` to your
app's "Client ID".
1. Restart the Zulip server with
`/home/zulip/deployments/current/scripts/restart-server`.
This enables Zoom support in your Zulip server. Finally, [configure
Zoom as the video call
provider](https://zulipchat.com/help/start-a-call) in the Zulip
organization(s) where you want to use it.

View File

@ -72,6 +72,7 @@ zrequire('compose_pm_pill');
zrequire('echo');
zrequire('compose');
zrequire('upload');
zrequire('server_events_dispatch');
people.small_avatar_url_for_person = function () {
return 'http://example.com/example.png';
@ -1458,13 +1459,18 @@ run_test('on_events', () => {
page_params.realm_video_chat_provider =
page_params.realm_available_video_chat_providers.zoom.id;
page_params.realm_zoom_user_id = 'example@example.com';
page_params.realm_zoom_api_key = 'abc';
page_params.realm_zoom_api_secret = 'abc';
channel.get = function (options) {
assert(options.url === '/json/calls/create');
options.success({ zoom_url: 'example.zoom.com' });
window.open = function (url) {
assert(url.endsWith('/calls/zoom/register'));
server_events_dispatch.dispatch_normal_event({
type: "has_zoom_token",
value: true,
});
};
channel.post = function (payload) {
assert.equal(payload.url, '/json/calls/zoom/create');
payload.success({ url: 'example.zoom.com' });
};
handler(ev);

View File

@ -181,3 +181,7 @@ django-cookies-samesite
# For server-side enforcement of password strength
zxcvbn
# Needed for sending HTTP requests
requests[security]
requests-oauthlib

View File

@ -818,11 +818,11 @@ regex==2020.4.4 \
requests-oauthlib==1.3.0 \
--hash=sha256:7f71572defaecd16372f9006f33c2ec8c077c3cfa6f5911a9a90202beb513f3d \
--hash=sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a \
# via python-twitter, social-auth-core
# via -r requirements/common.in, python-twitter, social-auth-core
requests[security]==2.23.0 \
--hash=sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee \
--hash=sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6 \
# via docker, hypchat, matrix-client, moto, premailer, pyoembed, python-digitalocean, python-gcm, python-twitter, requests-oauthlib, responses, social-auth-core, sphinx, stripe, twilio, zulip
# via -r requirements/common.in, docker, hypchat, matrix-client, moto, premailer, pyoembed, python-digitalocean, python-gcm, python-twitter, requests-oauthlib, responses, social-auth-core, sphinx, stripe, twilio, zulip
responses==0.10.12 \
--hash=sha256:0474ce3c897fbcc1aef286117c93499882d5c440f06a805947e4b1cb5ab3d474 \
--hash=sha256:f83613479a021e233e82d52ffb3e2e0e2836d24b0cc88a0fa31978789f78d0e5 \

View File

@ -582,11 +582,11 @@ regex==2020.4.4 \
requests-oauthlib==1.3.0 \
--hash=sha256:7f71572defaecd16372f9006f33c2ec8c077c3cfa6f5911a9a90202beb513f3d \
--hash=sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a \
# via python-twitter, social-auth-core
# via -r requirements/common.in, python-twitter, social-auth-core
requests[security]==2.23.0 \
--hash=sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee \
--hash=sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6 \
# via hypchat, matrix-client, premailer, pyoembed, python-gcm, python-twitter, requests-oauthlib, social-auth-core, stripe, twilio, zulip
# via -r requirements/common.in, hypchat, matrix-client, premailer, pyoembed, python-gcm, python-twitter, requests-oauthlib, social-auth-core, stripe, twilio, zulip
s3transfer==0.3.3 \
--hash=sha256:2482b4259524933a022d59da830f51bd746db62f047d6eb213f2f8855dcb8a13 \
--hash=sha256:921a37e2aefc64145e7b73d50c71bb4f26f46e4c9f414dc648c6245ff92cf7db \

View File

@ -168,6 +168,17 @@ exports.abort_xhr = function () {
uppy.cancelAll();
};
exports.zoom_token_callbacks = new Map();
exports.zoom_xhrs = new Map();
exports.abort_zoom = function (edit_message_id) {
const key = edit_message_id || "";
exports.zoom_token_callbacks.delete(key);
if (exports.zoom_xhrs.has(key)) {
exports.zoom_xhrs.get(key).abort();
}
};
exports.empty_topic_placeholder = function () {
return i18n.t("(no topic)");
};
@ -1078,13 +1089,45 @@ exports.initialize = function () {
if (page_params.realm_video_chat_provider === available_providers.google_hangouts.id) {
video_call_link = "https://hangouts.google.com/hangouts/_/" + page_params.realm_google_hangouts_domain + "/" + video_call_id;
insert_video_call_url(video_call_link, target_textarea);
} else if (page_params.realm_video_chat_provider === available_providers.zoom.id) {
channel.get({
url: '/json/calls/create',
success: function (response) {
insert_video_call_url(response.zoom_url, target_textarea);
} else if (available_providers.zoom
&& page_params.realm_video_chat_provider === available_providers.zoom.id) {
exports.abort_zoom(edit_message_id);
const key = edit_message_id || "";
const make_zoom_call = () => {
exports.zoom_xhrs.set(key, channel.post({
url: "/json/calls/zoom/create",
success(res) {
exports.zoom_xhrs.delete(key);
insert_video_call_url(res.url, target_textarea);
},
});
error(xhr, status) {
exports.zoom_xhrs.delete(key);
if (status === "error" &&
xhr.responseJSON &&
xhr.responseJSON.code === "INVALID_ZOOM_TOKEN") {
page_params.has_zoom_token = false;
}
if (status !== "abort") {
ui_report.generic_embed_error(i18n.t("Failed to create video call."));
}
},
}));
};
if (page_params.has_zoom_token) {
make_zoom_call();
} else {
exports.zoom_token_callbacks.set(key, make_zoom_call);
window.open(
window.location.protocol +
"//" +
window.location.host +
"/calls/zoom/register",
"_blank",
"width=800,height=500,noopener,noreferrer"
);
}
} else {
video_call_link = page_params.jitsi_server_url + "/" + video_call_id;
insert_video_call_url(video_call_link, target_textarea);

View File

@ -246,6 +246,7 @@ exports.cancel = function () {
clear_box();
notifications.clear_compose_notifications();
compose.abort_xhr();
compose.abort_zoom(undefined);
compose_state.set_message_type(false);
compose_pm_pill.clear();
$(document).trigger($.Event('compose_canceled.zulip'));

View File

@ -522,6 +522,8 @@ exports.end_message_row_edit = function (row) {
currently_editing_messages.delete(message.id);
current_msg_list.hide_edit_message(row);
compose.abort_zoom(message.id);
}
condense.show_message_expander(row);
row.find(".message_reactions").show();

View File

@ -38,6 +38,16 @@ exports.dispatch_normal_event = function dispatch_normal_event(event) {
break;
}
case 'has_zoom_token':
page_params.has_zoom_token = event.value;
if (event.value) {
for (const callback of compose.zoom_token_callbacks.values()) {
callback();
}
compose.zoom_token_callbacks.clear();
}
break;
case 'hotspots':
hotspots.load_new(event.hotspots);
page_params.hotspots = page_params.hotspots ?
@ -121,9 +131,6 @@ exports.dispatch_normal_event = function dispatch_normal_event(event) {
emails_restricted_to_domains: noop,
video_chat_provider: compose.update_video_chat_button_display,
waiting_period_threshold: noop,
zoom_user_id: noop,
zoom_api_key: noop,
zoom_api_secret: noop,
};
if (event.op === 'update' && Object.prototype.hasOwnProperty.call(realm_settings, event.property)) {
page_params['realm_' + event.property] = event.value;

View File

@ -212,17 +212,9 @@ function set_video_chat_provider_dropdown() {
$("#id_realm_video_chat_provider").val(chat_provider_id);
if (chat_provider_id === available_providers.google_hangouts.id) {
$("#google_hangouts_domain").show();
$(".zoom_credentials").hide();
$("#id_realm_google_hangouts_domain").val(page_params.realm_google_hangouts_domain);
} else if (chat_provider_id === available_providers.zoom.id) {
$("#google_hangouts_domain").hide();
$(".zoom_credentials").show();
$("#id_realm_zoom_user_id").val(page_params.realm_zoom_user_id);
$("#id_realm_zoom_api_key").val(page_params.realm_zoom_api_key);
$("#id_realm_zoom_api_secret").val(page_params.realm_zoom_api_secret);
} else {
$("#google_hangouts_domain").hide();
$(".zoom_credentials").hide();
}
}
@ -845,13 +837,8 @@ exports.build_page = function () {
if (video_chat_provider_id === available_providers.google_hangouts.id) {
$("#google_hangouts_domain").show();
$(".zoom_credentials").hide();
} else if (video_chat_provider_id === available_providers.zoom.id) {
$("#google_hangouts_domain").hide();
$(".zoom_credentials").show();
} else {
$("#google_hangouts_domain").hide();
$(".zoom_credentials").hide();
}
});

View File

@ -624,7 +624,6 @@ input[type=checkbox] {
}
#google_hangouts_domain,
#zoom_help_text,
.organization-settings-parent div:first-of-type {
margin-top: 10px;
}

View File

@ -196,36 +196,6 @@
class="admin-realm-google-hangouts-domain setting-widget prop-element"
data-setting-widget-type="string"/>
</div>
<div id="zoom_help_text" class="zoom_credentials">
<p>
Note: Zoom support is experimental. In particular, Zulip currently supports having
only one active Zoom meeting at a time.
</p>
</div>
<div id="zoom_user_id" class="zoom_credentials">
<label>{{t 'Zoom user ID or email address (required)' }}:</label>
<input type="text" id="id_realm_zoom_user_id"
name="realm_zoom_user_id"
autocomplete="off"
class="admin-realm-zoom-field setting-widget prop-element"
data-setting-widget-type="string"/>
</div>
<div id="zoom_api_key" class="zoom_credentials">
<label>{{t 'Zoom API key (required)' }}:</label>
<input type="text" id="id_realm_zoom_api_key"
name="realm_zoom_api_key"
autocomplete="off"
class="admin-realm-zoom-field setting-widget prop-element"
data-setting-widget-type="string"/>
</div>
<div id="zoom_api_secret" class="zoom_credentials">
<label>{{t 'Zoom API secret (required if changed)' }}:</label>
<input type="text" id="id_realm_zoom_api_secret"
name="realm_zoom_api_secret"
autocomplete="off"
class="admin-realm-zoom-field setting-widget prop-element"
data-setting-widget-type="string"/>
</div>
</div>
{{> dropdown_list_widget

View File

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Callback complete</title>
<script>
window.close();
// Why doesnt this work in Firefox? See
// https://bugzilla.mozilla.org/show_bug.cgi?id=1353466
</script>
</head>
<body>
<p>You may now close this window.</p>
</body>
</html>

View File

@ -25,8 +25,8 @@ By default, Zulip integrates with
source video conferencing solution. Organization administrators can also
change the organization's video chat provider.
Note that both the Google Hangouts and Zoom integrations require paid
accounts with their respective providers.
Note that the Google Hangouts integration requires a paid Google G
Suite account.
### Change your organization's video chat provider
@ -48,13 +48,18 @@ accounts with their respective providers.
1. Under **Other settings** set **Video chat provider** to **Zoom**.
1. Enter your Zoom email address, API key, and API secret.
1. Click **Save changes**.
!!! warn ""
**Note**: Zoom support is experimental. In particular, Zulip currently
supports having only one active Zoom meeting at a time.
Any user who creates a video call link using the instructions above
will be prompted to link a Zoom account with their Zulip account.
If you would like to unlink Zoom from your Zulip account:
1. Log in to the [Zoom App Marketplace](https://marketplace.zoom.us/).
1. Click **Manage****Installed Apps**.
1. Click the **Uninstall** button next to the Zulip app.
{tab|jitsi-on-premise}

View File

@ -44,4 +44,4 @@ API_FEATURE_LEVEL = 8
# historical commits sharing the same major version, in which case a
# minor version bump suffices.
PROVISION_VERSION = '83.1'
PROVISION_VERSION = '83.2'

View File

@ -153,7 +153,6 @@ from zerver.lib.sessions import delete_user_sessions
from zerver.lib.upload import claim_attachment, delete_message_image, \
upload_emoji_image, delete_avatar_image, \
delete_export_tarball
from zerver.lib.video_calls import request_zoom_video_call_url
from zerver.tornado.event_queue import send_event
from zerver.lib.types import ProfileFieldData
from zerver.lib.streams import access_stream_for_send_message, subscribed_to_stream, check_stream_name, \
@ -604,9 +603,6 @@ def do_set_realm_property(realm: Realm, name: str, value: Any) -> None:
setattr(realm, name, value)
realm.save(update_fields=[name])
if name == 'zoom_api_secret':
# Send '' as the value through the API for the API secret
value = ''
event = dict(
type='realm',
op='update',
@ -5747,18 +5743,13 @@ def do_send_realm_reactivation_email(realm: Realm) -> None:
from_name=FromAddress.security_email_from_name(language=language),
language=language, context=context)
def get_zoom_video_call_url(realm: Realm) -> str:
response = request_zoom_video_call_url(
realm.zoom_user_id,
realm.zoom_api_key,
realm.zoom_api_secret
def do_set_zoom_token(user: UserProfile, token: Optional[Dict[str, object]]) -> None:
user.zoom_token = token
user.save(update_fields=["zoom_token"])
send_event(
user.realm, dict(type="has_zoom_token", value=token is not None), [user.id]
)
if response is None:
return ''
return response['join_url']
def notify_realm_export(user_profile: UserProfile) -> None:
# In the future, we may want to send this event to all realm admins.
event = dict(type='realm_export',

View File

@ -138,10 +138,6 @@ def fetch_initial_state_data(user_profile: UserProfile,
for property_name in Realm.property_types:
state['realm_' + property_name] = getattr(realm, property_name)
# Don't send the zoom API secret to clients.
if state.get('realm_zoom_api_secret'):
state['realm_zoom_api_secret'] = ''
# Most state is handled via the property_types framework;
# these manual entries are for those realm settings that don't
# fit into that framework.
@ -325,6 +321,9 @@ def fetch_initial_state_data(user_profile: UserProfile,
if want('user_status'):
state['user_status'] = get_user_info_dict(realm_id=realm.id)
if want('video_calls'):
state['has_zoom_token'] = user_profile.zoom_token is not None
return state
def apply_events(state: Dict[str, Any], events: Iterable[Dict[str, Any]],
@ -811,6 +810,8 @@ def apply_event(state: Dict[str, Any],
user_status.pop(user_id, None)
state['user_status'] = user_status
elif event['type'] == 'has_zoom_token':
state['has_zoom_token'] = event['value']
else:
raise AssertionError("Unexpected event type %s" % (event['type'],))

View File

@ -46,6 +46,7 @@ class ErrorCode(AbstractEnum):
INVALID_MARKDOWN_INCLUDE_STATEMENT = ()
REQUEST_CONFUSING_VAR = ()
INVALID_API_KEY = ()
INVALID_ZOOM_TOKEN = ()
class JsonableError(Exception):
'''A standardized error format we can turn into a nice JSON HTTP response.

View File

@ -1,26 +0,0 @@
import requests
import jwt
from typing import Any, Dict, Optional
import time
def request_zoom_video_call_url(user_id: str, api_key: str, api_secret: str) -> Optional[Dict[str, Any]]:
encodedToken = jwt.encode({
'iss': api_key,
'exp': int(round(time.time() * 1000)) + 5000
}, api_secret, algorithm='HS256').decode('utf-8')
response = requests.post(
'https://api.zoom.us/v2/users/' + user_id + '/meetings',
headers = {
'Authorization': 'Bearer ' + encodedToken,
'content-type': 'application/json'
},
json = {}
)
try:
response.raise_for_status()
except Exception:
return None
return response.json()

View File

@ -0,0 +1,19 @@
# Generated by Django 1.11.25 on 2019-11-06 22:40
import django.contrib.postgres.fields.jsonb
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('zerver', '0280_userprofile_presence_enabled'),
]
operations = [
migrations.AddField(
model_name='userprofile',
name='zoom_token',
field=django.contrib.postgres.fields.jsonb.JSONField(default=None, null=True),
),
]

View File

@ -36,6 +36,7 @@ from zerver.lib.types import Validator, ExtendedValidator, \
ExtendedFieldElement, UserFieldElement, FieldElement, \
DisplayRecipientT
from zerver.lib.exceptions import JsonableError
from django.contrib.postgres.fields import JSONField
from bitfield import BitField
from bitfield.types import BitHandler
@ -321,11 +322,12 @@ class Realm(models.Model):
'name': "Google Hangouts",
'id': 2
},
'zoom': {
}
if settings.VIDEO_ZOOM_CLIENT_ID is not None and settings.VIDEO_ZOOM_CLIENT_SECRET is not None:
VIDEO_CHAT_PROVIDERS['zoom'] = {
'name': "Zoom",
'id': 3
}
}
video_chat_provider = models.PositiveSmallIntegerField(default=VIDEO_CHAT_PROVIDERS['jitsi_meet']['id'])
google_hangouts_domain = models.TextField(default="")
zoom_user_id = models.TextField(default="")
@ -350,9 +352,6 @@ class Realm(models.Model):
email_address_visibility=int,
email_changes_disabled=bool,
google_hangouts_domain=str,
zoom_user_id=str,
zoom_api_key=str,
zoom_api_secret=str,
invite_required=bool,
invite_by_admins_only=bool,
inline_image_preview=bool,
@ -1026,6 +1025,8 @@ class UserProfile(AbstractBaseUser, PermissionsMixin):
# completed.
onboarding_steps: str = models.TextField(default='[]')
zoom_token: Optional[object] = JSONField(default=None, null=True)
objects: UserManager = UserManager()
# Define the types of the various automatically managed properties

View File

@ -1,7 +1,7 @@
from typing import Any, Optional
from django.conf import settings
from django.contrib.auth.signals import user_logged_in
from django.contrib.auth.signals import user_logged_in, user_logged_out
from django.dispatch import receiver
from django.utils.timezone import \
get_current_timezone_name as timezone_get_current_timezone_name
@ -9,6 +9,7 @@ from django.utils.timezone import now as timezone_now
from django.utils.translation import ugettext as _
from confirmation.models import one_click_unsubscribe_link
from zerver.lib.actions import do_set_zoom_token
from zerver.lib.queue import queue_json_publish
from zerver.lib.send_email import FromAddress
from zerver.models import UserProfile
@ -99,3 +100,11 @@ def email_on_new_login(sender: Any, user: UserProfile, request: Any, **kwargs: A
'from_address': FromAddress.NOREPLY,
'context': context}
queue_json_publish("email_senders", email_dict)
@receiver(user_logged_out)
def clear_zoom_token_on_logout(
sender: object, *, user: Optional[UserProfile], **kwargs: object
) -> None:
if user is not None and user.zoom_token is not None:
do_set_zoom_token(user, None)

View File

@ -1,63 +1,158 @@
import json
from unittest import mock
import responses
from zerver.lib.test_classes import ZulipTestCase
from typing import Dict
class TestVideoCall(ZulipTestCase):
def setUp(self) -> None:
super().setUp()
user_profile = self.example_user('hamlet')
self.login_user(user_profile)
self.user = self.example_user("hamlet")
self.login_user(self.user)
def test_create_video_call_success(self) -> None:
with mock.patch('zerver.lib.actions.request_zoom_video_call_url', return_value={'join_url': 'example.com'}):
result = self.client_get("/json/calls/create")
self.assert_json_success(result)
self.assertEqual(200, result.status_code)
content = result.json()
self.assertEqual(content['zoom_url'], 'example.com')
def test_register_video_request_no_settings(self) -> None:
with self.settings(VIDEO_ZOOM_CLIENT_ID=None):
response = self.client_get("/calls/zoom/register")
self.assert_json_error(
response, "Zoom credentials have not been configured"
)
def test_create_video_call_failure(self) -> None:
with mock.patch('zerver.lib.actions.request_zoom_video_call_url', return_value=None):
result = self.client_get("/json/calls/create")
self.assert_json_success(result)
self.assertEqual(200, result.status_code)
content = result.json()
self.assertEqual(content['zoom_url'], '')
def test_register_video_request(self) -> None:
response = self.client_get("/calls/zoom/register")
self.assertEqual(response.status_code, 302)
@responses.activate
def test_create_video_request_success(self) -> None:
class MockResponse:
def __init__(self) -> None:
self.status_code = 200
responses.add(
responses.POST,
"https://zoom.us/oauth/token",
json={"access_token": "oldtoken", "expires_in": -60},
)
def json(self) -> Dict[str, str]:
return {"join_url": "example.com"}
response = self.client_get(
"/calls/zoom/complete",
{"code": "code", "state": '{"realm":"zulip","sid":""}'},
)
self.assertEqual(response.status_code, 200)
def raise_for_status(self) -> None:
return None
responses.replace(
responses.POST,
"https://zoom.us/oauth/token",
json={"access_token": "newtoken", "expires_in": 60},
)
with mock.patch('requests.post', return_value=MockResponse()):
result = self.client_get("/json/calls/create")
self.assert_json_success(result)
responses.add(
responses.POST,
"https://api.zoom.us/v2/users/me/meetings",
json={"join_url": "example.com"},
)
def test_create_video_request_http_error(self) -> None:
class MockResponse:
def __init__(self) -> None:
self.status_code = 401
response = self.client_post("/json/calls/zoom/create")
self.assertEqual(
responses.calls[-1].request.url, "https://api.zoom.us/v2/users/me/meetings"
)
self.assertEqual(
responses.calls[-1].request.headers["Authorization"], "Bearer newtoken"
)
json = self.assert_json_success(response)
self.assertEqual(json["url"], "example.com")
def raise_for_status(self) -> None:
raise Exception("Invalid request!")
self.logout()
self.login_user(self.user)
with mock.patch('requests.post', return_value=MockResponse()):
result = self.client_get("/json/calls/create")
self.assert_json_success(result)
result_dict = json.loads(result.content.decode('utf-8'))
response = self.client_post("/json/calls/zoom/create")
self.assert_json_error(response, "Invalid Zoom access token")
# TODO: Arguably this is the wrong result for errors, but
# in any case we should test it.
self.assertEqual(result_dict['zoom_url'], '')
def test_create_video_realm_redirect(self) -> None:
response = self.client_get(
"/calls/zoom/complete",
{"code": "code", "state": '{"realm":"zephyr","sid":"somesid"}'},
)
self.assertEqual(response.status_code, 302)
self.assertIn("http://zephyr.testserver/", response.url)
self.assertIn("somesid", response.url)
def test_create_video_request(self) -> None:
with mock.patch('requests.post'):
result = self.client_get("/json/calls/create")
self.assert_json_success(result)
def test_create_video_sid_error(self) -> None:
response = self.client_get(
"/calls/zoom/complete",
{"code": "code", "state": '{"realm":"zulip","sid":"bad"}'},
)
self.assert_json_error(response, "Invalid Zoom session identifier")
@responses.activate
def test_create_video_credential_error(self) -> None:
responses.add(responses.POST, "https://zoom.us/oauth/token", status=400)
response = self.client_get(
"/calls/zoom/complete",
{"code": "code", "state": '{"realm":"zulip","sid":""}'},
)
self.assert_json_error(response, "Invalid Zoom credentials")
@responses.activate
def test_create_video_refresh_error(self) -> None:
responses.add(
responses.POST,
"https://zoom.us/oauth/token",
json={"access_token": "token", "expires_in": -60},
)
response = self.client_get(
"/calls/zoom/complete",
{"code": "code", "state": '{"realm":"zulip","sid":""}'},
)
self.assertEqual(response.status_code, 200)
responses.replace(responses.POST, "https://zoom.us/oauth/token", status=400)
response = self.client_post("/json/calls/zoom/create")
self.assert_json_error(response, "Invalid Zoom access token")
@responses.activate
def test_create_video_request_error(self) -> None:
responses.add(
responses.POST,
"https://zoom.us/oauth/token",
json={"access_token": "token"},
)
responses.add(
responses.POST, "https://api.zoom.us/v2/users/me/meetings", status=400
)
response = self.client_get(
"/calls/zoom/complete",
{"code": "code", "state": '{"realm":"zulip","sid":""}'},
)
self.assertEqual(response.status_code, 200)
response = self.client_post("/json/calls/zoom/create")
self.assert_json_error(response, "Failed to create Zoom call")
responses.replace(
responses.POST, "https://api.zoom.us/v2/users/me/meetings", status=401
)
response = self.client_post("/json/calls/zoom/create")
self.assert_json_error(response, "Invalid Zoom access token")
@responses.activate
def test_deauthorize_zoom_user(self) -> None:
responses.add(responses.POST, "https://api.zoom.us/oauth/data/compliance")
response = self.client_post(
"/calls/zoom/deauthorize",
"""\
{
"event": "app_deauthorized",
"payload": {
"user_data_retention": "false",
"account_id": "EabCDEFghiLHMA",
"user_id": "z9jkdsfsdfjhdkfjQ",
"signature": "827edc3452044f0bc86bdd5684afb7d1e6becfa1a767f24df1b287853cf73000",
"deauthorization_time": "2019-06-17T13:52:28.632Z",
"client_id": "ADZ9k9bTWmGUoUbECUKU_a"
}
}
""",
content_type="application/json",
)
self.assert_json_success(response)

View File

@ -82,6 +82,7 @@ from zerver.lib.actions import (
do_set_user_display_setting,
do_set_realm_notifications_stream,
do_set_realm_signup_notifications_stream,
do_set_zoom_token,
do_unmute_topic,
do_update_embedded_data,
do_update_message,
@ -1628,9 +1629,6 @@ class EventsRegisterTest(ZulipTestCase):
Realm.VIDEO_CHAT_PROVIDERS['google_hangouts']['id']
],
google_hangouts_domain=["zulip.com", "zulip.org"],
zoom_api_secret=["abc", "xyz"],
zoom_api_key=["abc", "xyz"],
zoom_user_id=["example@example.com", "example@example.org"],
default_code_block_language=['python', 'javascript'],
)
@ -1661,8 +1659,6 @@ class EventsRegisterTest(ZulipTestCase):
do_set_realm_property(self.user_profile.realm, name, vals[0])
for val in vals[1:]:
state_change_expected = True
if name == "zoom_api_secret":
state_change_expected = False
events = self.do_test(
lambda: do_set_realm_property(self.user_profile.realm, name, val),
state_change_expected=state_change_expected)
@ -2940,6 +2936,25 @@ class EventsRegisterTest(ZulipTestCase):
error = failed_schema_checker('events[1]', events[1])
self.assert_on_error(error)
def test_has_zoom_token(self) -> None:
schema_checker = self.check_events_dict([
('type', equals('has_zoom_token')),
('value', equals(True)),
])
events = self.do_test(
lambda: do_set_zoom_token(self.user_profile, {'access_token': 'token'})
)
error = schema_checker('events[0]', events[0])
self.assert_on_error(error)
schema_checker = self.check_events_dict([
('type', equals('has_zoom_token')),
('value', equals(False)),
])
events = self.do_test(lambda: do_set_zoom_token(self.user_profile, None))
error = schema_checker('events[0]', events[0])
self.assert_on_error(error)
class FetchInitialStateDataTest(ZulipTestCase):
# Non-admin users don't have access to all bots
def test_realm_bots_non_admin(self) -> None:
@ -3744,6 +3759,7 @@ class FetchQueriesTest(ZulipTestCase):
update_global_notifications=0,
update_message_flags=5,
user_status=1,
video_calls=0,
)
wanted_event_types = {

View File

@ -89,6 +89,7 @@ class HomeTest(ZulipTestCase):
"full_name",
"furthest_read_time",
"has_mobile_devices",
"has_zoom_token",
"have_initial_messages",
"high_contrast_mode",
"hotspots",
@ -192,9 +193,6 @@ class HomeTest(ZulipTestCase):
"realm_users",
"realm_video_chat_provider",
"realm_waiting_period_threshold",
"realm_zoom_api_key",
"realm_zoom_api_secret",
"realm_zoom_user_id",
"recent_private_conversations",
"root_domain_uri",
"save_stacktraces",

View File

@ -255,7 +255,7 @@ class OpenAPIArgumentsTest(ZulipTestCase):
# Used for failed approach with dead Android app.
'/fetch_google_client_id',
# API for video calls we're planning to remove/replace.
'/calls/create',
'/calls/zoom/create',
#### Documented endpoints not properly detected by tooling.
# E.g. '/user_groups/<user_group_id>' in urls.py but fails the

View File

@ -572,68 +572,7 @@ class RealmTest(ZulipTestCase):
req = {"video_chat_provider": ujson.dumps(Realm.VIDEO_CHAT_PROVIDERS['zoom']['id'])}
result = self.client_patch('/json/realm', req)
self.assert_json_error(result, "User ID cannot be empty")
req = {
"video_chat_provider": ujson.dumps(Realm.VIDEO_CHAT_PROVIDERS['zoom']['id']),
"zoom_user_id": ujson.dumps("example@example.com")
}
result = self.client_patch('/json/realm', req)
self.assert_json_error(result, "API key cannot be empty")
req = {
"video_chat_provider": ujson.dumps(Realm.VIDEO_CHAT_PROVIDERS['zoom']['id']),
"zoom_user_id": ujson.dumps("example@example.com"),
"zoom_api_key": ujson.dumps("abc")
}
result = self.client_patch('/json/realm', req)
self.assert_json_error(result, "API secret cannot be empty")
with mock.patch("zerver.views.realm.request_zoom_video_call_url", return_value=None):
req = {
"video_chat_provider": ujson.dumps(Realm.VIDEO_CHAT_PROVIDERS['zoom']['id']),
"zoom_user_id": ujson.dumps("example@example.com"),
"zoom_api_key": ujson.dumps("abc"),
"zoom_api_secret": ujson.dumps("abc"),
}
result = self.client_patch('/json/realm', req)
self.assert_json_error(result, "Invalid credentials for the Zoom API.")
with mock.patch("zerver.views.realm.request_zoom_video_call_url",
return_value={'join_url': 'example.com'}) as mock_validation:
req = {
"video_chat_provider": ujson.dumps(Realm.VIDEO_CHAT_PROVIDERS['zoom']['id']),
"zoom_user_id": ujson.dumps("example@example.com"),
"zoom_api_key": ujson.dumps("abc"),
"zoom_api_secret": ujson.dumps("abc"),
}
result = self.client_patch('/json/realm', req)
self.assert_json_success(result)
mock_validation.assert_called_once()
with mock.patch("zerver.views.realm.request_zoom_video_call_url",
return_value={'join_url': 'example.com'}) as mock_validation:
req = {
"video_chat_provider": ujson.dumps(Realm.VIDEO_CHAT_PROVIDERS['zoom']['id']),
"zoom_user_id": ujson.dumps("example@example.com"),
"zoom_api_key": ujson.dumps("abc"),
"zoom_api_secret": ujson.dumps("abc"),
}
result = self.client_patch('/json/realm', req)
self.assert_json_success(result)
mock_validation.assert_not_called()
with mock.patch("zerver.views.realm.request_zoom_video_call_url",
return_value={'join_url': 'example.com'}) as mock_validation:
req = {
"video_chat_provider": ujson.dumps(Realm.VIDEO_CHAT_PROVIDERS['zoom']['id']),
"zoom_user_id": ujson.dumps("example@example.com"),
"zoom_api_key": ujson.dumps("abc"),
"zoom_api_secret": ujson.dumps(""),
}
result = self.client_patch('/json/realm', req)
self.assert_json_success(result)
mock_validation.assert_not_called()
def test_initial_plan_type(self) -> None:
with self.settings(BILLING_ENABLED=True):
@ -779,9 +718,6 @@ class RealmAPITest(ZulipTestCase):
)
],
google_hangouts_domain=['zulip.com', 'zulip.org'],
zoom_api_secret=["abc", "xyz"],
zoom_api_key=["abc", "xyz"],
zoom_user_id=["example@example.com", "example@example.org"]
)
vals = test_values.get(name)

View File

@ -23,7 +23,6 @@ from zerver.lib.validator import check_string, check_dict, check_bool, check_int
check_int_in, to_positive_or_allowed_int, to_non_negative_int
from zerver.lib.streams import access_stream_by_id
from zerver.lib.domains import validate_domain
from zerver.lib.video_calls import request_zoom_video_call_url
from zerver.models import Realm, UserProfile
from zerver.forms import check_subdomain_available as check_subdomain
from confirmation.models import get_object_from_key, Confirmation, ConfirmationKeyException
@ -77,9 +76,6 @@ def update_realm(
default_twenty_four_hour_time: Optional[bool]=REQ(validator=check_bool, default=None),
video_chat_provider: Optional[int]=REQ(validator=check_int, default=None),
google_hangouts_domain: Optional[str]=REQ(validator=check_string, default=None),
zoom_user_id: Optional[str]=REQ(validator=check_string, default=None),
zoom_api_key: Optional[str]=REQ(validator=check_string, default=None),
zoom_api_secret: Optional[str]=REQ(validator=check_string, default=None),
default_code_block_language: Optional[str]=REQ(validator=check_string, default=None),
digest_weekday: Optional[int]=REQ(validator=check_int_in(Realm.DIGEST_WEEKDAY_VALUES), default=None),
) -> HttpResponse:
@ -103,29 +99,6 @@ def update_realm(
validate_domain(google_hangouts_domain)
except ValidationError as e:
return json_error(_('Invalid domain: {}').format(e.messages[0]))
if video_chat_provider == Realm.VIDEO_CHAT_PROVIDERS['zoom']['id']:
if not zoom_api_secret:
# Use the saved Zoom API secret if a new value isn't being sent
zoom_api_secret = user_profile.realm.zoom_api_secret
if not zoom_user_id:
return json_error(_('User ID cannot be empty'))
if not zoom_api_key:
return json_error(_('API key cannot be empty'))
if not zoom_api_secret:
return json_error(_('API secret cannot be empty'))
# If any of the Zoom settings have changed, validate the Zoom credentials.
#
# Technically, we could call some other API endpoint that
# doesn't create a video call link, but this is a nicer
# end-to-end test, since it verifies that the Zoom API user's
# scopes includes the ability to create video calls, which is
# the only capabiility we use.
if ((zoom_user_id != realm.zoom_user_id or
zoom_api_key != realm.zoom_api_key or
zoom_api_secret != realm.zoom_api_secret) and
not request_zoom_video_call_url(zoom_user_id, zoom_api_key, zoom_api_secret)):
return json_error(_('Invalid credentials for the %(third_party_service)s API.') % dict(
third_party_service="Zoom"))
if message_retention_days is not None:
realm.ensure_not_on_limited_plan()

View File

@ -1,12 +1,153 @@
from functools import partial
import json
from typing import Dict
from urllib.parse import urljoin
from django.conf import settings
from django.http import HttpResponse, HttpRequest
from django.middleware import csrf
from django.shortcuts import redirect, render
from django.utils.crypto import constant_time_compare, salted_hmac
from django.utils.translation import ugettext as _
from django.views.decorators.cache import never_cache
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from oauthlib.oauth2 import OAuth2Error
import requests
from requests_oauthlib import OAuth2Session
from zerver.decorator import has_request_variables
from zerver.decorator import REQ, has_request_variables, zulip_login_required
from zerver.lib.actions import do_set_zoom_token
from zerver.lib.exceptions import ErrorCode, JsonableError
from zerver.lib.response import json_success
from zerver.lib.actions import get_zoom_video_call_url
from zerver.models import UserProfile
from zerver.lib.subdomains import get_subdomain
from zerver.lib.validator import check_dict, check_string
from zerver.models import UserProfile, get_realm
class InvalidZoomTokenError(JsonableError):
code = ErrorCode.INVALID_ZOOM_TOKEN
def __init__(self) -> None:
super().__init__(_("Invalid Zoom access token"))
def get_zoom_session(user: UserProfile) -> OAuth2Session:
if settings.VIDEO_ZOOM_CLIENT_ID is None:
raise JsonableError(_("Zoom credentials have not been configured"))
return OAuth2Session(
settings.VIDEO_ZOOM_CLIENT_ID,
redirect_uri=urljoin(settings.ROOT_DOMAIN_URI, "/calls/zoom/complete"),
scope=["meeting:write:admin"],
auto_refresh_url="https://zoom.us/oauth/token",
auto_refresh_kwargs={
"client_id": settings.VIDEO_ZOOM_CLIENT_ID,
"client_secret": settings.VIDEO_ZOOM_CLIENT_SECRET,
},
token=user.zoom_token,
token_updater=partial(do_set_zoom_token, user),
)
def get_zoom_sid(request: HttpRequest) -> str:
# This is used to prevent CSRF attacks on the Zoom OAuth
# authentication flow. We want this value to be unpredictable and
# tied to the session, but we dont want to expose the main CSRF
# token directly to the Zoom server.
csrf.get_token(request)
return (
""
if getattr(request, "_dont_enforce_csrf_checks", False)
else salted_hmac("Zulip Zoom sid", request.META["CSRF_COOKIE"]).hexdigest()
)
@zulip_login_required
@never_cache
def register_zoom_user(request: HttpRequest) -> HttpResponse:
oauth = get_zoom_session(request.user)
authorization_url, state = oauth.authorization_url(
"https://zoom.us/oauth/authorize",
state=json.dumps(
{"realm": get_subdomain(request), "sid": get_zoom_sid(request)}
),
)
return redirect(authorization_url)
@never_cache
@has_request_variables
def get_zoom_url(request: HttpRequest, user_profile: UserProfile) -> HttpResponse:
return json_success({'zoom_url': get_zoom_video_call_url(
user_profile.realm
)})
def complete_zoom_user(
request: HttpRequest,
state: Dict[str, str] = REQ(validator=check_dict([("realm", check_string)])),
) -> HttpResponse:
if get_subdomain(request) != state["realm"]:
return redirect(urljoin(get_realm(state["realm"]).uri, request.get_full_path()))
return complete_zoom_user_in_realm(request)
@zulip_login_required
@has_request_variables
def complete_zoom_user_in_realm(
request: HttpRequest,
code: str = REQ(),
state: Dict[str, str] = REQ(validator=check_dict([("sid", check_string)])),
) -> HttpResponse:
if not constant_time_compare(state["sid"], get_zoom_sid(request)):
raise JsonableError(_("Invalid Zoom session identifier"))
oauth = get_zoom_session(request.user)
try:
token = oauth.fetch_token(
"https://zoom.us/oauth/token",
code=code,
client_secret=settings.VIDEO_ZOOM_CLIENT_SECRET,
)
except OAuth2Error:
raise JsonableError(_("Invalid Zoom credentials"))
do_set_zoom_token(request.user, token)
return render(request, "zerver/close_window.html")
def make_zoom_video_call(request: HttpRequest, user: UserProfile) -> HttpResponse:
oauth = get_zoom_session(user)
if not oauth.authorized:
raise InvalidZoomTokenError
try:
res = oauth.post("https://api.zoom.us/v2/users/me/meetings", json={})
except OAuth2Error:
do_set_zoom_token(user, None)
raise InvalidZoomTokenError
if res.status_code == 401:
do_set_zoom_token(user, None)
raise InvalidZoomTokenError
elif not res.ok:
raise JsonableError(_("Failed to create Zoom call"))
return json_success({"url": res.json()["join_url"]})
@csrf_exempt
@require_POST
@has_request_variables
def deauthorize_zoom_user(request: HttpRequest) -> HttpResponse:
data = json.loads(request.body.decode("utf-8"))
payload = data["payload"]
if payload["user_data_retention"] == "false":
requests.post(
"https://api.zoom.us/oauth/data/compliance",
json={
"client_id": settings.VIDEO_ZOOM_CLIENT_ID,
"user_id": payload["user_id"],
"account_id": payload["account_id"],
"deauthorization_event_received": payload,
"compliance_completed": True,
},
auth=(settings.VIDEO_ZOOM_CLIENT_ID, settings.VIDEO_ZOOM_CLIENT_SECRET),
).raise_for_status()
return json_success()

View File

@ -72,6 +72,9 @@ GOOGLE_OAUTH2_CLIENT_ID: Optional[str] = None
# Other auth
SSO_APPEND_DOMAIN: Optional[str] = None
VIDEO_ZOOM_CLIENT_ID = get_secret('video_zoom_client_id', development_only=True)
VIDEO_ZOOM_CLIENT_SECRET = get_secret('video_zoom_client_secret')
# Email gateway
EMAIL_GATEWAY_PATTERN = ''
EMAIL_GATEWAY_LOGIN: Optional[str] = None

View File

@ -468,6 +468,16 @@ EMAIL_GATEWAY_IMAP_PORT = 993
EMAIL_GATEWAY_IMAP_FOLDER = "INBOX"
########
# Zoom integration.
#
# Zulip supports using Zoom as a video calling provider. To learn more about
# configuring Zoom, see:
# https://zulip.readthedocs.io/en/latest/production/zoom-configuration.html
#
#VIDEO_ZOOM_CLIENT_ID = <your Zoom Client ID>
################
# LDAP integration.
#

View File

@ -174,6 +174,9 @@ SOCIAL_AUTH_GOOGLE_KEY = "key"
SOCIAL_AUTH_GOOGLE_SECRET = "secret"
SOCIAL_AUTH_SUBDOMAIN = 'auth'
VIDEO_ZOOM_CLIENT_ID = "client_id"
VIDEO_ZOOM_CLIENT_SECRET = "client_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

View File

@ -37,6 +37,7 @@ import zerver.views.digest
import zerver.views.messages
import zerver.views.realm_export
import zerver.views.upload
import zerver.views.video_calls
from zerver.lib.rest import rest_dispatch
@ -395,8 +396,8 @@ v1_api_and_json_patterns = [
{'POST': ('zerver.views.report.report_unnarrow_times', {'intentionally_undocumented'})}),
# Used to generate a Zoom video call URL
url(r'^calls/create$', rest_dispatch,
{'GET': 'zerver.views.video_calls.get_zoom_url'}),
url(r'^calls/zoom/create$', rest_dispatch,
{'POST': 'zerver.views.video_calls.make_zoom_video_call'}),
# export/realm -> zerver.views.realm_export
url(r'^export/realm$', rest_dispatch,
@ -541,6 +542,11 @@ i18n_urls = [
zerver.views.registration.accounts_home_from_multiuse_invite,
name='zerver.views.registration.accounts_home_from_multiuse_invite'),
# Used to generate a Zoom video call URL
url(r'^calls/zoom/register$', zerver.views.video_calls.register_zoom_user),
url(r'^calls/zoom/complete$', zerver.views.video_calls.complete_zoom_user),
url(r'^calls/zoom/deauthorize$', zerver.views.video_calls.deauthorize_zoom_user),
# API and integrations documentation
url(r'^integrations/doc-html/(?P<integration_name>[^/]*)$',
zerver.views.documentation.integration_doc,