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" %} {% extends "!layout.html" %}
{% block document %} {% block document %}
<!--- {#
# This allows us to insert a warning that appears only on the development # 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. # 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 # 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') %} {% if pagename in ["production/management-commands", "production/email-gateway", "production/upgrade-or-modify", "production/zoom-configuration"] and release.endswith('+git') %}
<!-- {#
# email-gateway.html page doesn't exist in the stable documentation yet. # This page doesn't exist in the stable documentation yet.
# This temporary workaround prevents CircleCI failure and should be removed after the next release. # This temporary workaround prevents CircleCI failure and should be removed after the next release.
--> #}
<div class="admonition warning"> <div class="admonition warning">
<p class="first admonition-title">Warning</p> <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. <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 email
deployment deployment
email-gateway email-gateway
zoom-configuration

View File

@ -100,6 +100,7 @@ Some popular settings in `/etc/zulip/settings.py` include:
tweets. tweets.
* The [email gateway](../production/email-gateway.md), which lets * The [email gateway](../production/email-gateway.md), which lets
users send emails into Zulip. users send emails into Zulip.
* The [Zoom video call integration](zoom-configuration.md).
## Zulip announcement list ## 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('echo');
zrequire('compose'); zrequire('compose');
zrequire('upload'); zrequire('upload');
zrequire('server_events_dispatch');
people.small_avatar_url_for_person = function () { people.small_avatar_url_for_person = function () {
return 'http://example.com/example.png'; return 'http://example.com/example.png';
@ -1458,13 +1459,18 @@ run_test('on_events', () => {
page_params.realm_video_chat_provider = page_params.realm_video_chat_provider =
page_params.realm_available_video_chat_providers.zoom.id; 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) { window.open = function (url) {
assert(options.url === '/json/calls/create'); assert(url.endsWith('/calls/zoom/register'));
options.success({ zoom_url: 'example.zoom.com' }); 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); handler(ev);

View File

@ -181,3 +181,7 @@ django-cookies-samesite
# For server-side enforcement of password strength # For server-side enforcement of password strength
zxcvbn 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 \ requests-oauthlib==1.3.0 \
--hash=sha256:7f71572defaecd16372f9006f33c2ec8c077c3cfa6f5911a9a90202beb513f3d \ --hash=sha256:7f71572defaecd16372f9006f33c2ec8c077c3cfa6f5911a9a90202beb513f3d \
--hash=sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a \ --hash=sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a \
# via python-twitter, social-auth-core # via -r requirements/common.in, python-twitter, social-auth-core
requests[security]==2.23.0 \ requests[security]==2.23.0 \
--hash=sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee \ --hash=sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee \
--hash=sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6 \ --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 \ responses==0.10.12 \
--hash=sha256:0474ce3c897fbcc1aef286117c93499882d5c440f06a805947e4b1cb5ab3d474 \ --hash=sha256:0474ce3c897fbcc1aef286117c93499882d5c440f06a805947e4b1cb5ab3d474 \
--hash=sha256:f83613479a021e233e82d52ffb3e2e0e2836d24b0cc88a0fa31978789f78d0e5 \ --hash=sha256:f83613479a021e233e82d52ffb3e2e0e2836d24b0cc88a0fa31978789f78d0e5 \

View File

@ -582,11 +582,11 @@ regex==2020.4.4 \
requests-oauthlib==1.3.0 \ requests-oauthlib==1.3.0 \
--hash=sha256:7f71572defaecd16372f9006f33c2ec8c077c3cfa6f5911a9a90202beb513f3d \ --hash=sha256:7f71572defaecd16372f9006f33c2ec8c077c3cfa6f5911a9a90202beb513f3d \
--hash=sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a \ --hash=sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a \
# via python-twitter, social-auth-core # via -r requirements/common.in, python-twitter, social-auth-core
requests[security]==2.23.0 \ requests[security]==2.23.0 \
--hash=sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee \ --hash=sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee \
--hash=sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6 \ --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 \ s3transfer==0.3.3 \
--hash=sha256:2482b4259524933a022d59da830f51bd746db62f047d6eb213f2f8855dcb8a13 \ --hash=sha256:2482b4259524933a022d59da830f51bd746db62f047d6eb213f2f8855dcb8a13 \
--hash=sha256:921a37e2aefc64145e7b73d50c71bb4f26f46e4c9f414dc648c6245ff92cf7db \ --hash=sha256:921a37e2aefc64145e7b73d50c71bb4f26f46e4c9f414dc648c6245ff92cf7db \

View File

@ -168,6 +168,17 @@ exports.abort_xhr = function () {
uppy.cancelAll(); 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 () { exports.empty_topic_placeholder = function () {
return i18n.t("(no topic)"); return i18n.t("(no topic)");
}; };
@ -1078,13 +1089,45 @@ exports.initialize = function () {
if (page_params.realm_video_chat_provider === available_providers.google_hangouts.id) { 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; 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); insert_video_call_url(video_call_link, target_textarea);
} else if (page_params.realm_video_chat_provider === available_providers.zoom.id) { } else if (available_providers.zoom
channel.get({ && page_params.realm_video_chat_provider === available_providers.zoom.id) {
url: '/json/calls/create', exports.abort_zoom(edit_message_id);
success: function (response) { const key = edit_message_id || "";
insert_video_call_url(response.zoom_url, target_textarea);
}, 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 { } else {
video_call_link = page_params.jitsi_server_url + "/" + video_call_id; video_call_link = page_params.jitsi_server_url + "/" + video_call_id;
insert_video_call_url(video_call_link, target_textarea); insert_video_call_url(video_call_link, target_textarea);

View File

@ -246,6 +246,7 @@ exports.cancel = function () {
clear_box(); clear_box();
notifications.clear_compose_notifications(); notifications.clear_compose_notifications();
compose.abort_xhr(); compose.abort_xhr();
compose.abort_zoom(undefined);
compose_state.set_message_type(false); compose_state.set_message_type(false);
compose_pm_pill.clear(); compose_pm_pill.clear();
$(document).trigger($.Event('compose_canceled.zulip')); $(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); currently_editing_messages.delete(message.id);
current_msg_list.hide_edit_message(row); current_msg_list.hide_edit_message(row);
compose.abort_zoom(message.id);
} }
condense.show_message_expander(row); condense.show_message_expander(row);
row.find(".message_reactions").show(); row.find(".message_reactions").show();

View File

@ -38,6 +38,16 @@ exports.dispatch_normal_event = function dispatch_normal_event(event) {
break; 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': case 'hotspots':
hotspots.load_new(event.hotspots); hotspots.load_new(event.hotspots);
page_params.hotspots = page_params.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, emails_restricted_to_domains: noop,
video_chat_provider: compose.update_video_chat_button_display, video_chat_provider: compose.update_video_chat_button_display,
waiting_period_threshold: noop, 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)) { if (event.op === 'update' && Object.prototype.hasOwnProperty.call(realm_settings, event.property)) {
page_params['realm_' + event.property] = event.value; 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); $("#id_realm_video_chat_provider").val(chat_provider_id);
if (chat_provider_id === available_providers.google_hangouts.id) { if (chat_provider_id === available_providers.google_hangouts.id) {
$("#google_hangouts_domain").show(); $("#google_hangouts_domain").show();
$(".zoom_credentials").hide();
$("#id_realm_google_hangouts_domain").val(page_params.realm_google_hangouts_domain); $("#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 { } else {
$("#google_hangouts_domain").hide(); $("#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) { if (video_chat_provider_id === available_providers.google_hangouts.id) {
$("#google_hangouts_domain").show(); $("#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 { } else {
$("#google_hangouts_domain").hide(); $("#google_hangouts_domain").hide();
$(".zoom_credentials").hide();
} }
}); });

View File

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

View File

@ -196,36 +196,6 @@
class="admin-realm-google-hangouts-domain setting-widget prop-element" class="admin-realm-google-hangouts-domain setting-widget prop-element"
data-setting-widget-type="string"/> data-setting-widget-type="string"/>
</div> </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> </div>
{{> dropdown_list_widget {{> 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 source video conferencing solution. Organization administrators can also
change the organization's video chat provider. change the organization's video chat provider.
Note that both the Google Hangouts and Zoom integrations require paid Note that the Google Hangouts integration requires a paid Google G
accounts with their respective providers. Suite account.
### Change your organization's video chat provider ### 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. Under **Other settings** set **Video chat provider** to **Zoom**.
1. Enter your Zoom email address, API key, and API secret.
1. Click **Save changes**. 1. Click **Save changes**.
!!! warn "" Any user who creates a video call link using the instructions above
**Note**: Zoom support is experimental. In particular, Zulip currently will be prompted to link a Zoom account with their Zulip account.
supports having only one active Zoom meeting at a time.
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} {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 # historical commits sharing the same major version, in which case a
# minor version bump suffices. # 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, \ from zerver.lib.upload import claim_attachment, delete_message_image, \
upload_emoji_image, delete_avatar_image, \ upload_emoji_image, delete_avatar_image, \
delete_export_tarball delete_export_tarball
from zerver.lib.video_calls import request_zoom_video_call_url
from zerver.tornado.event_queue import send_event from zerver.tornado.event_queue import send_event
from zerver.lib.types import ProfileFieldData from zerver.lib.types import ProfileFieldData
from zerver.lib.streams import access_stream_for_send_message, subscribed_to_stream, check_stream_name, \ 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) setattr(realm, name, value)
realm.save(update_fields=[name]) realm.save(update_fields=[name])
if name == 'zoom_api_secret':
# Send '' as the value through the API for the API secret
value = ''
event = dict( event = dict(
type='realm', type='realm',
op='update', op='update',
@ -5747,18 +5743,13 @@ def do_send_realm_reactivation_email(realm: Realm) -> None:
from_name=FromAddress.security_email_from_name(language=language), from_name=FromAddress.security_email_from_name(language=language),
language=language, context=context) language=language, context=context)
def get_zoom_video_call_url(realm: Realm) -> str: def do_set_zoom_token(user: UserProfile, token: Optional[Dict[str, object]]) -> None:
response = request_zoom_video_call_url( user.zoom_token = token
realm.zoom_user_id, user.save(update_fields=["zoom_token"])
realm.zoom_api_key, send_event(
realm.zoom_api_secret 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: def notify_realm_export(user_profile: UserProfile) -> None:
# In the future, we may want to send this event to all realm admins. # In the future, we may want to send this event to all realm admins.
event = dict(type='realm_export', 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: for property_name in Realm.property_types:
state['realm_' + property_name] = getattr(realm, property_name) 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; # Most state is handled via the property_types framework;
# these manual entries are for those realm settings that don't # these manual entries are for those realm settings that don't
# fit into that framework. # fit into that framework.
@ -325,6 +321,9 @@ def fetch_initial_state_data(user_profile: UserProfile,
if want('user_status'): if want('user_status'):
state['user_status'] = get_user_info_dict(realm_id=realm.id) 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 return state
def apply_events(state: Dict[str, Any], events: Iterable[Dict[str, Any]], 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) user_status.pop(user_id, None)
state['user_status'] = user_status state['user_status'] = user_status
elif event['type'] == 'has_zoom_token':
state['has_zoom_token'] = event['value']
else: else:
raise AssertionError("Unexpected event type %s" % (event['type'],)) raise AssertionError("Unexpected event type %s" % (event['type'],))

View File

@ -46,6 +46,7 @@ class ErrorCode(AbstractEnum):
INVALID_MARKDOWN_INCLUDE_STATEMENT = () INVALID_MARKDOWN_INCLUDE_STATEMENT = ()
REQUEST_CONFUSING_VAR = () REQUEST_CONFUSING_VAR = ()
INVALID_API_KEY = () INVALID_API_KEY = ()
INVALID_ZOOM_TOKEN = ()
class JsonableError(Exception): class JsonableError(Exception):
'''A standardized error format we can turn into a nice JSON HTTP response. '''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, \ ExtendedFieldElement, UserFieldElement, FieldElement, \
DisplayRecipientT DisplayRecipientT
from zerver.lib.exceptions import JsonableError from zerver.lib.exceptions import JsonableError
from django.contrib.postgres.fields import JSONField
from bitfield import BitField from bitfield import BitField
from bitfield.types import BitHandler from bitfield.types import BitHandler
@ -321,11 +322,12 @@ class Realm(models.Model):
'name': "Google Hangouts", 'name': "Google Hangouts",
'id': 2 '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", 'name': "Zoom",
'id': 3 'id': 3
} }
}
video_chat_provider = models.PositiveSmallIntegerField(default=VIDEO_CHAT_PROVIDERS['jitsi_meet']['id']) video_chat_provider = models.PositiveSmallIntegerField(default=VIDEO_CHAT_PROVIDERS['jitsi_meet']['id'])
google_hangouts_domain = models.TextField(default="") google_hangouts_domain = models.TextField(default="")
zoom_user_id = models.TextField(default="") zoom_user_id = models.TextField(default="")
@ -350,9 +352,6 @@ class Realm(models.Model):
email_address_visibility=int, email_address_visibility=int,
email_changes_disabled=bool, email_changes_disabled=bool,
google_hangouts_domain=str, google_hangouts_domain=str,
zoom_user_id=str,
zoom_api_key=str,
zoom_api_secret=str,
invite_required=bool, invite_required=bool,
invite_by_admins_only=bool, invite_by_admins_only=bool,
inline_image_preview=bool, inline_image_preview=bool,
@ -1026,6 +1025,8 @@ class UserProfile(AbstractBaseUser, PermissionsMixin):
# completed. # completed.
onboarding_steps: str = models.TextField(default='[]') onboarding_steps: str = models.TextField(default='[]')
zoom_token: Optional[object] = JSONField(default=None, null=True)
objects: UserManager = UserManager() objects: UserManager = UserManager()
# Define the types of the various automatically managed properties # Define the types of the various automatically managed properties

View File

@ -1,7 +1,7 @@
from typing import Any, Optional from typing import Any, Optional
from django.conf import settings 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.dispatch import receiver
from django.utils.timezone import \ from django.utils.timezone import \
get_current_timezone_name as timezone_get_current_timezone_name 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 django.utils.translation import ugettext as _
from confirmation.models import one_click_unsubscribe_link 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.queue import queue_json_publish
from zerver.lib.send_email import FromAddress from zerver.lib.send_email import FromAddress
from zerver.models import UserProfile 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, 'from_address': FromAddress.NOREPLY,
'context': context} 'context': context}
queue_json_publish("email_senders", email_dict) 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 import responses
from unittest import mock
from zerver.lib.test_classes import ZulipTestCase from zerver.lib.test_classes import ZulipTestCase
from typing import Dict
class TestVideoCall(ZulipTestCase): class TestVideoCall(ZulipTestCase):
def setUp(self) -> None: def setUp(self) -> None:
super().setUp() super().setUp()
user_profile = self.example_user('hamlet') self.user = self.example_user("hamlet")
self.login_user(user_profile) self.login_user(self.user)
def test_create_video_call_success(self) -> None: def test_register_video_request_no_settings(self) -> None:
with mock.patch('zerver.lib.actions.request_zoom_video_call_url', return_value={'join_url': 'example.com'}): with self.settings(VIDEO_ZOOM_CLIENT_ID=None):
result = self.client_get("/json/calls/create") response = self.client_get("/calls/zoom/register")
self.assert_json_success(result) self.assert_json_error(
self.assertEqual(200, result.status_code) response, "Zoom credentials have not been configured"
content = result.json() )
self.assertEqual(content['zoom_url'], 'example.com')
def test_create_video_call_failure(self) -> None: def test_register_video_request(self) -> None:
with mock.patch('zerver.lib.actions.request_zoom_video_call_url', return_value=None): response = self.client_get("/calls/zoom/register")
result = self.client_get("/json/calls/create") self.assertEqual(response.status_code, 302)
self.assert_json_success(result)
self.assertEqual(200, result.status_code)
content = result.json()
self.assertEqual(content['zoom_url'], '')
@responses.activate
def test_create_video_request_success(self) -> None: def test_create_video_request_success(self) -> None:
class MockResponse: responses.add(
def __init__(self) -> None: responses.POST,
self.status_code = 200 "https://zoom.us/oauth/token",
json={"access_token": "oldtoken", "expires_in": -60},
)
def json(self) -> Dict[str, str]: response = self.client_get(
return {"join_url": "example.com"} "/calls/zoom/complete",
{"code": "code", "state": '{"realm":"zulip","sid":""}'},
)
self.assertEqual(response.status_code, 200)
def raise_for_status(self) -> None: responses.replace(
return None responses.POST,
"https://zoom.us/oauth/token",
json={"access_token": "newtoken", "expires_in": 60},
)
with mock.patch('requests.post', return_value=MockResponse()): responses.add(
result = self.client_get("/json/calls/create") responses.POST,
self.assert_json_success(result) "https://api.zoom.us/v2/users/me/meetings",
json={"join_url": "example.com"},
)
def test_create_video_request_http_error(self) -> None: response = self.client_post("/json/calls/zoom/create")
class MockResponse: self.assertEqual(
def __init__(self) -> None: responses.calls[-1].request.url, "https://api.zoom.us/v2/users/me/meetings"
self.status_code = 401 )
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: self.logout()
raise Exception("Invalid request!") self.login_user(self.user)
with mock.patch('requests.post', return_value=MockResponse()): response = self.client_post("/json/calls/zoom/create")
result = self.client_get("/json/calls/create") self.assert_json_error(response, "Invalid Zoom access token")
self.assert_json_success(result)
result_dict = json.loads(result.content.decode('utf-8'))
# TODO: Arguably this is the wrong result for errors, but def test_create_video_realm_redirect(self) -> None:
# in any case we should test it. response = self.client_get(
self.assertEqual(result_dict['zoom_url'], '') "/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: def test_create_video_sid_error(self) -> None:
with mock.patch('requests.post'): response = self.client_get(
result = self.client_get("/json/calls/create") "/calls/zoom/complete",
self.assert_json_success(result) {"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_user_display_setting,
do_set_realm_notifications_stream, do_set_realm_notifications_stream,
do_set_realm_signup_notifications_stream, do_set_realm_signup_notifications_stream,
do_set_zoom_token,
do_unmute_topic, do_unmute_topic,
do_update_embedded_data, do_update_embedded_data,
do_update_message, do_update_message,
@ -1628,9 +1629,6 @@ class EventsRegisterTest(ZulipTestCase):
Realm.VIDEO_CHAT_PROVIDERS['google_hangouts']['id'] Realm.VIDEO_CHAT_PROVIDERS['google_hangouts']['id']
], ],
google_hangouts_domain=["zulip.com", "zulip.org"], 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'], default_code_block_language=['python', 'javascript'],
) )
@ -1661,8 +1659,6 @@ class EventsRegisterTest(ZulipTestCase):
do_set_realm_property(self.user_profile.realm, name, vals[0]) do_set_realm_property(self.user_profile.realm, name, vals[0])
for val in vals[1:]: for val in vals[1:]:
state_change_expected = True state_change_expected = True
if name == "zoom_api_secret":
state_change_expected = False
events = self.do_test( events = self.do_test(
lambda: do_set_realm_property(self.user_profile.realm, name, val), lambda: do_set_realm_property(self.user_profile.realm, name, val),
state_change_expected=state_change_expected) state_change_expected=state_change_expected)
@ -2940,6 +2936,25 @@ class EventsRegisterTest(ZulipTestCase):
error = failed_schema_checker('events[1]', events[1]) error = failed_schema_checker('events[1]', events[1])
self.assert_on_error(error) 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): class FetchInitialStateDataTest(ZulipTestCase):
# Non-admin users don't have access to all bots # Non-admin users don't have access to all bots
def test_realm_bots_non_admin(self) -> None: def test_realm_bots_non_admin(self) -> None:
@ -3744,6 +3759,7 @@ class FetchQueriesTest(ZulipTestCase):
update_global_notifications=0, update_global_notifications=0,
update_message_flags=5, update_message_flags=5,
user_status=1, user_status=1,
video_calls=0,
) )
wanted_event_types = { wanted_event_types = {

View File

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

View File

@ -255,7 +255,7 @@ class OpenAPIArgumentsTest(ZulipTestCase):
# Used for failed approach with dead Android app. # Used for failed approach with dead Android app.
'/fetch_google_client_id', '/fetch_google_client_id',
# API for video calls we're planning to remove/replace. # API for video calls we're planning to remove/replace.
'/calls/create', '/calls/zoom/create',
#### Documented endpoints not properly detected by tooling. #### Documented endpoints not properly detected by tooling.
# E.g. '/user_groups/<user_group_id>' in urls.py but fails the # 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'])} req = {"video_chat_provider": ujson.dumps(Realm.VIDEO_CHAT_PROVIDERS['zoom']['id'])}
result = self.client_patch('/json/realm', req) result = self.client_patch('/json/realm', req)
self.assert_json_error(result, "User ID cannot be empty") self.assert_json_success(result)
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: def test_initial_plan_type(self) -> None:
with self.settings(BILLING_ENABLED=True): with self.settings(BILLING_ENABLED=True):
@ -779,9 +718,6 @@ class RealmAPITest(ZulipTestCase):
) )
], ],
google_hangouts_domain=['zulip.com', 'zulip.org'], 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) 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 check_int_in, to_positive_or_allowed_int, to_non_negative_int
from zerver.lib.streams import access_stream_by_id 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.lib.video_calls import request_zoom_video_call_url
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 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), default_twenty_four_hour_time: Optional[bool]=REQ(validator=check_bool, default=None),
video_chat_provider: Optional[int]=REQ(validator=check_int, default=None), video_chat_provider: Optional[int]=REQ(validator=check_int, default=None),
google_hangouts_domain: Optional[str]=REQ(validator=check_string, 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), 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), digest_weekday: Optional[int]=REQ(validator=check_int_in(Realm.DIGEST_WEEKDAY_VALUES), default=None),
) -> HttpResponse: ) -> HttpResponse:
@ -103,29 +99,6 @@ def update_realm(
validate_domain(google_hangouts_domain) validate_domain(google_hangouts_domain)
except ValidationError as e: except ValidationError as e:
return json_error(_('Invalid domain: {}').format(e.messages[0])) 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: if message_retention_days is not None:
realm.ensure_not_on_limited_plan() 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.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.response import json_success
from zerver.lib.actions import get_zoom_video_call_url from zerver.lib.subdomains import get_subdomain
from zerver.models import UserProfile 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 @has_request_variables
def get_zoom_url(request: HttpRequest, user_profile: UserProfile) -> HttpResponse: def complete_zoom_user(
return json_success({'zoom_url': get_zoom_video_call_url( request: HttpRequest,
user_profile.realm 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 # Other auth
SSO_APPEND_DOMAIN: Optional[str] = None 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
EMAIL_GATEWAY_PATTERN = '' EMAIL_GATEWAY_PATTERN = ''
EMAIL_GATEWAY_LOGIN: Optional[str] = None EMAIL_GATEWAY_LOGIN: Optional[str] = None

View File

@ -468,6 +468,16 @@ EMAIL_GATEWAY_IMAP_PORT = 993
EMAIL_GATEWAY_IMAP_FOLDER = "INBOX" 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. # LDAP integration.
# #

View File

@ -174,6 +174,9 @@ SOCIAL_AUTH_GOOGLE_KEY = "key"
SOCIAL_AUTH_GOOGLE_SECRET = "secret" SOCIAL_AUTH_GOOGLE_SECRET = "secret"
SOCIAL_AUTH_SUBDOMAIN = 'auth' 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. # By default two factor authentication is disabled in tests.
# Explicitly set this to True within tests that must have this on. # Explicitly set this to True within tests that must have this on.
TWO_FACTOR_AUTHENTICATION_ENABLED = False TWO_FACTOR_AUTHENTICATION_ENABLED = False

View File

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