From 4d04fa31185209d23142dff5cd00fa2061875072 Mon Sep 17 00:00:00 2001 From: Anders Kaseorg Date: Sat, 16 Nov 2019 00:26:28 -0800 Subject: [PATCH] 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 Co-authored-by: Tim Abbott Signed-off-by: Anders Kaseorg --- docs/_templates/layout.html | 12 +- docs/production/index.rst | 1 + docs/production/settings.md | 1 + docs/production/zoom-configuration.md | 36 ++++ frontend_tests/node_tests/compose.js | 18 +- requirements/common.in | 4 + requirements/dev.txt | 4 +- requirements/prod.txt | 4 +- static/js/compose.js | 57 +++++- static/js/compose_actions.js | 1 + static/js/message_edit.js | 2 + static/js/server_events_dispatch.js | 13 +- static/js/settings_org.js | 13 -- static/styles/settings.scss | 1 - .../settings/organization_settings_admin.hbs | 30 --- templates/zerver/close_window.html | 15 ++ templates/zerver/help/start-a-call.md | 19 +- version.py | 2 +- zerver/lib/actions.py | 19 +- zerver/lib/events.py | 9 +- zerver/lib/exceptions.py | 1 + zerver/lib/video_calls.py | 26 --- zerver/migrations/0281_zoom_oauth.py | 19 ++ zerver/models.py | 11 +- zerver/signals.py | 11 +- zerver/tests/test_create_video_call.py | 187 +++++++++++++----- zerver/tests/test_events.py | 26 ++- zerver/tests/test_home.py | 4 +- zerver/tests/test_openapi.py | 2 +- zerver/tests/test_realm.py | 66 +------ zerver/views/realm.py | 27 --- zerver/views/video_calls.py | 155 ++++++++++++++- zproject/default_settings.py | 3 + zproject/prod_settings_template.py | 10 + zproject/test_settings.py | 3 + zproject/urls.py | 10 +- 36 files changed, 538 insertions(+), 284 deletions(-) create mode 100644 docs/production/zoom-configuration.md create mode 100644 templates/zerver/close_window.html delete mode 100644 zerver/lib/video_calls.py create mode 100644 zerver/migrations/0281_zoom_oauth.py diff --git a/docs/_templates/layout.html b/docs/_templates/layout.html index 13e996930e..d19f8b3a31 100644 --- a/docs/_templates/layout.html +++ b/docs/_templates/layout.html @@ -1,15 +1,15 @@ {% extends "!layout.html" %} {% block document %} - - {% if (pagename == "production/management-commands" or pagename == "production/email-gateway" or pagename == "production/upgrade-or-modify") and release.endswith('+git') %} - + #}

Warning

You are reading a development version of the Zulip documentation. These instructions may not correspond to the latest Zulip Server release. diff --git a/docs/production/index.rst b/docs/production/index.rst index f520abd0ee..fe554358a6 100644 --- a/docs/production/index.rst +++ b/docs/production/index.rst @@ -21,3 +21,4 @@ Zulip in Production email deployment email-gateway + zoom-configuration diff --git a/docs/production/settings.md b/docs/production/settings.md index 8bb5f69030..b671b88f22 100644 --- a/docs/production/settings.md +++ b/docs/production/settings.md @@ -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 diff --git a/docs/production/zoom-configuration.md b/docs/production/zoom-configuration.md new file mode 100644 index 0000000000..1dd7d483f3 --- /dev/null +++ b/docs/production/zoom-configuration.md @@ -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. diff --git a/frontend_tests/node_tests/compose.js b/frontend_tests/node_tests/compose.js index a31574e0dd..d06a06dcd9 100644 --- a/frontend_tests/node_tests/compose.js +++ b/frontend_tests/node_tests/compose.js @@ -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); diff --git a/requirements/common.in b/requirements/common.in index a9bb9b8c4b..a3a1316ab2 100644 --- a/requirements/common.in +++ b/requirements/common.in @@ -181,3 +181,7 @@ django-cookies-samesite # For server-side enforcement of password strength zxcvbn + +# Needed for sending HTTP requests +requests[security] +requests-oauthlib diff --git a/requirements/dev.txt b/requirements/dev.txt index dfdeb0970f..6e61165b86 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -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 \ diff --git a/requirements/prod.txt b/requirements/prod.txt index f1e49abf89..2d41ef1307 100644 --- a/requirements/prod.txt +++ b/requirements/prod.txt @@ -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 \ diff --git a/static/js/compose.js b/static/js/compose.js index 14c18ccd8e..3c96944ffb 100644 --- a/static/js/compose.js +++ b/static/js/compose.js @@ -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); diff --git a/static/js/compose_actions.js b/static/js/compose_actions.js index b4b223d200..cdb636e62c 100644 --- a/static/js/compose_actions.js +++ b/static/js/compose_actions.js @@ -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')); diff --git a/static/js/message_edit.js b/static/js/message_edit.js index 8c58e6a17c..5df0ad5f75 100644 --- a/static/js/message_edit.js +++ b/static/js/message_edit.js @@ -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(); diff --git a/static/js/server_events_dispatch.js b/static/js/server_events_dispatch.js index de7b7ca17b..0499b1c12e 100644 --- a/static/js/server_events_dispatch.js +++ b/static/js/server_events_dispatch.js @@ -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; diff --git a/static/js/settings_org.js b/static/js/settings_org.js index 5a693bb707..4d0f4109c0 100644 --- a/static/js/settings_org.js +++ b/static/js/settings_org.js @@ -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(); } }); diff --git a/static/styles/settings.scss b/static/styles/settings.scss index 9c45296fff..653550c34c 100644 --- a/static/styles/settings.scss +++ b/static/styles/settings.scss @@ -624,7 +624,6 @@ input[type=checkbox] { } #google_hangouts_domain, -#zoom_help_text, .organization-settings-parent div:first-of-type { margin-top: 10px; } diff --git a/static/templates/settings/organization_settings_admin.hbs b/static/templates/settings/organization_settings_admin.hbs index 8d42f05469..dd3f1ca9f7 100644 --- a/static/templates/settings/organization_settings_admin.hbs +++ b/static/templates/settings/organization_settings_admin.hbs @@ -196,36 +196,6 @@ class="admin-realm-google-hangouts-domain setting-widget prop-element" data-setting-widget-type="string"/>

-
-

- Note: Zoom support is experimental. In particular, Zulip currently supports having - only one active Zoom meeting at a time. -

-
-
- - -
-
- - -
-
- - -
{{> dropdown_list_widget diff --git a/templates/zerver/close_window.html b/templates/zerver/close_window.html new file mode 100644 index 0000000000..e2aec753ec --- /dev/null +++ b/templates/zerver/close_window.html @@ -0,0 +1,15 @@ + + + + + Callback complete + + + +

You may now close this window.

+ + diff --git a/templates/zerver/help/start-a-call.md b/templates/zerver/help/start-a-call.md index a998cfe90a..9c76023982 100644 --- a/templates/zerver/help/start-a-call.md +++ b/templates/zerver/help/start-a-call.md @@ -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} diff --git a/version.py b/version.py index c869d621cf..3e61a77f6b 100644 --- a/version.py +++ b/version.py @@ -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' diff --git a/zerver/lib/actions.py b/zerver/lib/actions.py index e95f383349..7f12a1724c 100644 --- a/zerver/lib/actions.py +++ b/zerver/lib/actions.py @@ -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', diff --git a/zerver/lib/events.py b/zerver/lib/events.py index 1a1f63193b..46bc0ab40d 100644 --- a/zerver/lib/events.py +++ b/zerver/lib/events.py @@ -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'],)) diff --git a/zerver/lib/exceptions.py b/zerver/lib/exceptions.py index 514ad13afa..9447394764 100644 --- a/zerver/lib/exceptions.py +++ b/zerver/lib/exceptions.py @@ -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. diff --git a/zerver/lib/video_calls.py b/zerver/lib/video_calls.py deleted file mode 100644 index 8e5d08744b..0000000000 --- a/zerver/lib/video_calls.py +++ /dev/null @@ -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() diff --git a/zerver/migrations/0281_zoom_oauth.py b/zerver/migrations/0281_zoom_oauth.py new file mode 100644 index 0000000000..1ba23beaac --- /dev/null +++ b/zerver/migrations/0281_zoom_oauth.py @@ -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), + ), + ] diff --git a/zerver/models.py b/zerver/models.py index 9d0de87abd..ecd6728def 100644 --- a/zerver/models.py +++ b/zerver/models.py @@ -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 diff --git a/zerver/signals.py b/zerver/signals.py index 70f08f338b..f54c2d70e0 100644 --- a/zerver/signals.py +++ b/zerver/signals.py @@ -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) diff --git a/zerver/tests/test_create_video_call.py b/zerver/tests/test_create_video_call.py index d78c214a18..a3a0a9e162 100644 --- a/zerver/tests/test_create_video_call.py +++ b/zerver/tests/test_create_video_call.py @@ -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) diff --git a/zerver/tests/test_events.py b/zerver/tests/test_events.py index 908033478d..003f5c3968 100644 --- a/zerver/tests/test_events.py +++ b/zerver/tests/test_events.py @@ -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 = { diff --git a/zerver/tests/test_home.py b/zerver/tests/test_home.py index 438fc4637a..6b148f84f5 100644 --- a/zerver/tests/test_home.py +++ b/zerver/tests/test_home.py @@ -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", diff --git a/zerver/tests/test_openapi.py b/zerver/tests/test_openapi.py index b14a66d6e0..8d5fb0ef04 100644 --- a/zerver/tests/test_openapi.py +++ b/zerver/tests/test_openapi.py @@ -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/' in urls.py but fails the diff --git a/zerver/tests/test_realm.py b/zerver/tests/test_realm.py index 7bf6eb6bd9..6c6514c5ef 100644 --- a/zerver/tests/test_realm.py +++ b/zerver/tests/test_realm.py @@ -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() + self.assert_json_success(result) 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) diff --git a/zerver/views/realm.py b/zerver/views/realm.py index 65a0ac99a4..d043e53ba3 100644 --- a/zerver/views/realm.py +++ b/zerver/views/realm.py @@ -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() diff --git a/zerver/views/video_calls.py b/zerver/views/video_calls.py index 6eff10f4f7..567dd7c63f 100644 --- a/zerver/views/video_calls.py +++ b/zerver/views/video_calls.py @@ -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 don’t 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() diff --git a/zproject/default_settings.py b/zproject/default_settings.py index d3b869298b..71db8176f9 100644 --- a/zproject/default_settings.py +++ b/zproject/default_settings.py @@ -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 diff --git a/zproject/prod_settings_template.py b/zproject/prod_settings_template.py index 42d180100d..e84b04e342 100644 --- a/zproject/prod_settings_template.py +++ b/zproject/prod_settings_template.py @@ -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 = + + ################ # LDAP integration. # diff --git a/zproject/test_settings.py b/zproject/test_settings.py index b4c51898e2..3989d7c189 100644 --- a/zproject/test_settings.py +++ b/zproject/test_settings.py @@ -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 diff --git a/zproject/urls.py b/zproject/urls.py index e7e7446564..5d245661bc 100644 --- a/zproject/urls.py +++ b/zproject/urls.py @@ -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[^/]*)$', zerver.views.documentation.integration_doc,