integration: Generate dynamic name for BigBlueButton video calls.

The name for a BigBlueButton meeting is now generated from the stream
name and topic name.

The createTime option is used to have the user redirected to a link
that is only valid for this meeting.

Even if the same link in Zulip is used again, a new createTime
parameter will be created, as the Meeting on the BigBlueButton server
has to be recreated.

Fixes #16498.
Fixes #20509.
Fixes #20804.
This commit is contained in:
strifel
2020-10-20 19:25:34 +02:00
committed by Tim Abbott
parent 0d117ab033
commit a967a86b10
5 changed files with 158 additions and 88 deletions

View File

@@ -27,6 +27,7 @@ set_global("ResizeObserver", function () {
const server_events_dispatch = zrequire("server_events_dispatch"); const server_events_dispatch = zrequire("server_events_dispatch");
const compose_ui = zrequire("compose_ui"); const compose_ui = zrequire("compose_ui");
const compose_closed = zrequire("compose_closed_ui");
const compose = zrequire("compose"); const compose = zrequire("compose");
function stub_out_video_calls() { function stub_out_video_calls() {
const $elem = $("#below-compose-content .video_link"); const $elem = $("#below-compose-content .video_link");
@@ -210,8 +211,11 @@ test("videos", ({override, override_rewire}) => {
page_params.realm_video_chat_provider = page_params.realm_video_chat_provider =
realm_available_video_chat_providers.big_blue_button.id; realm_available_video_chat_providers.big_blue_button.id;
compose_closed.get_recipient_label = () => "a";
channel.get = (options) => { channel.get = (options) => {
assert.equal(options.url, "/json/calls/bigbluebutton/create"); assert.equal(options.url, "/json/calls/bigbluebutton/create");
assert.equal(options.data.meeting_name, "a meeting");
options.success({ options.success({
url: "/calls/bigbluebutton/join?meeting_id=%22zulip-1%22&password=%22AAAAAAAAAA%22&checksum=%2232702220bff2a22a44aee72e96cfdb4c4091752e%22", url: "/calls/bigbluebutton/join?meeting_id=%22zulip-1%22&password=%22AAAAAAAAAA%22&checksum=%2232702220bff2a22a44aee72e96cfdb4c4091752e%22",
}); });

View File

@@ -5,6 +5,7 @@ import _ from "lodash";
import * as blueslip from "./blueslip"; import * as blueslip from "./blueslip";
import * as channel from "./channel"; import * as channel from "./channel";
import * as compose_actions from "./compose_actions"; import * as compose_actions from "./compose_actions";
import {get_recipient_label} from "./compose_closed_ui";
import * as compose_error from "./compose_error"; import * as compose_error from "./compose_error";
import * as compose_fade from "./compose_fade"; import * as compose_fade from "./compose_fade";
import * as compose_state from "./compose_state"; import * as compose_state from "./compose_state";
@@ -616,8 +617,12 @@ export function initialize() {
available_providers.big_blue_button && available_providers.big_blue_button &&
page_params.realm_video_chat_provider === available_providers.big_blue_button.id page_params.realm_video_chat_provider === available_providers.big_blue_button.id
) { ) {
const meeting_name = get_recipient_label() + " meeting";
channel.get({ channel.get({
url: "/json/calls/bigbluebutton/create", url: "/json/calls/bigbluebutton/create",
data: {
meeting_name,
},
success(response) { success(response) {
insert_video_call_url(response.url, $target_textarea); insert_video_call_url(response.url, $target_textarea);
}, },

View File

@@ -13868,6 +13868,16 @@ paths:
description: | description: |
Create a video call URL for a BigBlueButton video call. Create a video call URL for a BigBlueButton video call.
Requires BigBlueButton to be configured on the Zulip server. Requires BigBlueButton to be configured on the Zulip server.
parameters:
- in: query
name: meeting_name
schema:
type: string
required: true
description: |
Title to use for the BigBlueButton meeting.
A good choice is something like "{stream_name} meeting".
responses: responses:
"200": "200":
description: Success. description: Success.
@@ -13885,12 +13895,12 @@ paths:
description: | description: |
The URL for the BigBlueButton video call. The URL for the BigBlueButton video call.
type: string type: string
example: "/calls/bbb/join?meeting_id=%22zulip-something%22&password=%22something%22&checksum=%22somechecksum%22" example: "/calls/bigbluebutton/join?meeting_id=%22zulip-something%22&password=%22something%22&name=%22your_meeting_name%22&checksum=%22somechecksum%22"
example: example:
{ {
"msg": "", "msg": "",
"result": "success", "result": "success",
"url": "/calls/bbb/join?meeting_id=%22zulip-something%22&password=%22something%22&checksum=%22somechecksum%22", "url": "/calls/bigbluebutton/join?meeting_id=%22zulip-something%22&password=%22something%22&checksum=%22somechecksum%22",
} }
components: components:

View File

@@ -1,9 +1,11 @@
from unittest import mock from unittest import mock
import responses import responses
from django.core.signing import Signer
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from zerver.lib.test_classes import ZulipTestCase from zerver.lib.test_classes import ZulipTestCase
from zerver.lib.url_encoding import append_url_query_string
class TestVideoCall(ZulipTestCase): class TestVideoCall(ZulipTestCase):
@@ -11,6 +13,15 @@ class TestVideoCall(ZulipTestCase):
super().setUp() super().setUp()
self.user = self.example_user("hamlet") self.user = self.example_user("hamlet")
self.login_user(self.user) self.login_user(self.user)
# Signing for bbb
self.signer = Signer()
self.signed_bbb_a_object = self.signer.sign_object(
{
"meeting_id": "a",
"name": "a",
"password": "a",
}
)
def test_register_video_request_no_settings(self) -> None: def test_register_video_request_no_settings(self) -> None:
with self.settings(VIDEO_ZOOM_CLIENT_ID=None): with self.settings(VIDEO_ZOOM_CLIENT_ID=None):
@@ -168,46 +179,70 @@ class TestVideoCall(ZulipTestCase):
def test_create_bigbluebutton_link(self) -> None: def test_create_bigbluebutton_link(self) -> None:
with mock.patch("zerver.views.video_calls.random.randint", return_value="1"), mock.patch( with mock.patch("zerver.views.video_calls.random.randint", return_value="1"), mock.patch(
"secrets.token_bytes", return_value=b"\x00" * 7 "secrets.token_bytes", return_value=b"\x00" * 12
): ):
response = self.client_get("/json/calls/bigbluebutton/create") response = self.client_get(
"/json/calls/bigbluebutton/create?meeting_name=general > meeting"
)
self.assert_json_success(response) self.assert_json_success(response)
self.assertEqual( self.assertEqual(
response.json()["url"], response.json()["url"],
"/calls/bigbluebutton/join?meeting_id=zulip-1&password=AAAAAAAAAA" append_url_query_string(
"&checksum=d5eb2098bcd0e69a33caf2b18490991b843c8fa6be779316b4303c7990aca687", "/calls/bigbluebutton/join",
"bigbluebutton="
+ self.signer.sign_object(
{
"meeting_id": "zulip-1",
"name": "general > meeting",
"password": "AAAAAAAAAAAAAAAAAAAA",
}
),
),
) )
@responses.activate @responses.activate
def test_join_bigbluebutton_redirect(self) -> None: def test_join_bigbluebutton_redirect(self) -> None:
responses.add( responses.add(
responses.GET, responses.GET,
"https://bbb.example.com/bigbluebutton/api/create?meetingID=zulip-1&moderatorPW=a&attendeePW=aa&checksum=check", "https://bbb.example.com/bigbluebutton/api/create?meetingID=a&name=a"
"<response><returncode>SUCCESS</returncode><messageKey/></response>", "&moderatorPW=a&attendeePW=a&checksum=131bdec35f62fc63d5436e6f791d6d7aed7cf79ef256c03597e51d320d042823",
"<response><returncode>SUCCESS</returncode><messageKey/><createTime>0</createTime></response>",
) )
response = self.client_get( response = self.client_get(
"/calls/bigbluebutton/join", "/calls/bigbluebutton/join", {"bigbluebutton": self.signed_bbb_a_object}
{"meeting_id": "zulip-1", "password": "a", "checksum": "check"},
) )
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(isinstance(response, HttpResponseRedirect), True) self.assertEqual(isinstance(response, HttpResponseRedirect), True)
self.assertEqual( self.assertEqual(
response.url, response.url,
"https://bbb.example.com/bigbluebutton/api/join?meetingID=zulip-1&password=a" "https://bbb.example.com/bigbluebutton/api/join?meetingID=a&"
"&fullName=King%20Hamlet&checksum=ca78d6d3c3e04918bfab9d7d6cbc6e50602ab2bdfe1365314570943346a71a00", "password=a&fullName=King%20Hamlet&createTime=0&checksum=47ca959b4ff5c8047a5a56d6e99c07e17eac43dbf792afc0a2a9f6491ec0048b",
) )
@responses.activate @responses.activate
def test_join_bigbluebutton_redirect_wrong_check(self) -> None: def test_join_bigbluebutton_invalid_signature(self) -> None:
responses.add( responses.add(
responses.GET, responses.GET,
"https://bbb.example.com/bigbluebutton/api/create?meetingID=zulip-1&moderatorPW=a&attendeePW=aa&checksum=check", "https://bbb.example.com/bigbluebutton/api/create?meetingID=a&name=a"
"&moderatorPW=a&attendeePW=a&checksum=131bdec35f62fc63d5436e6f791d6d7aed7cf79ef256c03597e51d320d042823",
"<response><returncode>SUCCESS</returncode><messageKey/><createTime>0</createTime></response>",
)
response = self.client_get(
"/calls/bigbluebutton/join", {"bigbluebutton": self.signed_bbb_a_object + "zoo"}
)
self.assert_json_error(response, "Invalid signature.")
@responses.activate
def test_join_bigbluebutton_redirect_wrong_big_blue_button_checksum(self) -> None:
responses.add(
responses.GET,
"https://bbb.example.com/bigbluebutton/api/create?meetingID=a&name=a&moderatorPW=a&attendeePW=a&checksum=131bdec35f62fc63d5436e6f791d6d7aed7cf79ef256c03597e51d320d042823",
"<response><returncode>FAILED</returncode><messageKey>checksumError</messageKey>" "<response><returncode>FAILED</returncode><messageKey>checksumError</messageKey>"
"<message>You did not pass the checksum security check</message></response>", "<message>You did not pass the checksum security check</message></response>",
) )
response = self.client_get( response = self.client_get(
"/calls/bigbluebutton/join", "/calls/bigbluebutton/join",
{"meeting_id": "zulip-1", "password": "a", "checksum": "check"}, {"bigbluebutton": self.signed_bbb_a_object},
) )
self.assert_json_error(response, "Error authenticating to the BigBlueButton server.") self.assert_json_error(response, "Error authenticating to the BigBlueButton server.")
@@ -216,13 +251,13 @@ class TestVideoCall(ZulipTestCase):
# Simulate bbb server error # Simulate bbb server error
responses.add( responses.add(
responses.GET, responses.GET,
"https://bbb.example.com/bigbluebutton/api/create?meetingID=zulip-1&moderatorPW=a&attendeePW=aa&checksum=check", "https://bbb.example.com/bigbluebutton/api/create?meetingID=a&name=a&moderatorPW=a&attendeePW=a&checksum=131bdec35f62fc63d5436e6f791d6d7aed7cf79ef256c03597e51d320d042823",
"", "",
status=500, status=500,
) )
response = self.client_get( response = self.client_get(
"/calls/bigbluebutton/join", "/calls/bigbluebutton/join",
{"meeting_id": "zulip-1", "password": "a", "checksum": "check"}, {"bigbluebutton": self.signed_bbb_a_object},
) )
self.assert_json_error(response, "Error connecting to the BigBlueButton server.") self.assert_json_error(response, "Error connecting to the BigBlueButton server.")
@@ -231,12 +266,12 @@ class TestVideoCall(ZulipTestCase):
# Simulate bbb server error # Simulate bbb server error
responses.add( responses.add(
responses.GET, responses.GET,
"https://bbb.example.com/bigbluebutton/api/create?meetingID=zulip-1&moderatorPW=a&attendeePW=aa&checksum=check", "https://bbb.example.com/bigbluebutton/api/create?meetingID=a&name=a&moderatorPW=a&attendeePW=a&checksum=131bdec35f62fc63d5436e6f791d6d7aed7cf79ef256c03597e51d320d042823",
"<response><returncode>FAILURE</returncode><messageKey>otherFailure</messageKey></response>", "<response><returncode>FAILURE</returncode><messageKey>otherFailure</messageKey></response>",
) )
response = self.client_get( response = self.client_get(
"/calls/bigbluebutton/join", "/calls/bigbluebutton/join",
{"meeting_id": "zulip-1", "password": "a", "checksum": "check"}, {"bigbluebutton": self.signed_bbb_a_object},
) )
self.assert_json_error(response, "BigBlueButton server returned an unexpected error.") self.assert_json_error(response, "BigBlueButton server returned an unexpected error.")
@@ -244,6 +279,6 @@ class TestVideoCall(ZulipTestCase):
with self.settings(BIG_BLUE_BUTTON_SECRET=None, BIG_BLUE_BUTTON_URL=None): with self.settings(BIG_BLUE_BUTTON_SECRET=None, BIG_BLUE_BUTTON_URL=None):
response = self.client_get( response = self.client_get(
"/calls/bigbluebutton/join", "/calls/bigbluebutton/join",
{"meeting_id": "zulip-1", "password": "a", "checksum": "check"}, {"bigbluebutton": self.signed_bbb_a_object},
) )
self.assert_json_error(response, "BigBlueButton is not configured.") self.assert_json_error(response, "BigBlueButton is not configured.")

View File

@@ -10,6 +10,7 @@ from urllib.parse import quote, urlencode, urljoin
import requests import requests
from defusedxml import ElementTree from defusedxml import ElementTree
from django.conf import settings from django.conf import settings
from django.core.signing import Signer
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.middleware import csrf from django.middleware import csrf
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
@@ -168,35 +169,27 @@ def deauthorize_zoom_user(request: HttpRequest) -> HttpResponse:
return json_success(request) return json_success(request)
def get_bigbluebutton_url(request: HttpRequest, user_profile: UserProfile) -> HttpResponse: @has_request_variables
def get_bigbluebutton_url(
request: HttpRequest, user_profile: UserProfile, meeting_name: str = REQ()
) -> HttpResponse:
# https://docs.bigbluebutton.org/dev/api.html#create for reference on the API calls # https://docs.bigbluebutton.org/dev/api.html#create for reference on the API calls
# https://docs.bigbluebutton.org/dev/api.html#usage for reference for checksum # https://docs.bigbluebutton.org/dev/api.html#usage for reference for checksum
id = "zulip-" + str(random.randint(100000000000, 999999999999)) id = "zulip-" + str(random.randint(100000000000, 999999999999))
password = b32encode(secrets.token_bytes(7))[:10].decode() password = b32encode(secrets.token_bytes(7))[:20].decode()
checksum = hashlib.sha256(
( # We sign our data here to ensure a Zulip user can not tamper with
"create" # the join link to gain access to other meetings that are on the
+ "meetingID=" # same bigbluebutton server.
+ id signed = Signer().sign_object(
+ "&moderatorPW=" {
+ password "meeting_id": id,
+ "&attendeePW=" "name": meeting_name,
+ password "password": password,
+ "a" }
+ settings.BIG_BLUE_BUTTON_SECRET
).encode()
).hexdigest()
url = append_url_query_string(
"/calls/bigbluebutton/join",
urlencode(
{
"meeting_id": id,
"password": password,
"checksum": checksum,
}
),
) )
return json_success(request, data={"url": url}) url = append_url_query_string("/calls/bigbluebutton/join", "bigbluebutton=" + signed)
return json_success(request, {"url": url})
# We use zulip_login_required here mainly to get access to the user's # We use zulip_login_required here mainly to get access to the user's
@@ -207,55 +200,78 @@ def get_bigbluebutton_url(request: HttpRequest, user_profile: UserProfile) -> Ht
@zulip_login_required @zulip_login_required
@never_cache @never_cache
@has_request_variables @has_request_variables
def join_bigbluebutton( def join_bigbluebutton(request: HttpRequest, bigbluebutton: str = REQ()) -> HttpResponse:
request: HttpRequest,
meeting_id: str = REQ(),
password: str = REQ(),
checksum: str = REQ(),
) -> HttpResponse:
assert request.user.is_authenticated assert request.user.is_authenticated
if settings.BIG_BLUE_BUTTON_URL is None or settings.BIG_BLUE_BUTTON_SECRET is None: if settings.BIG_BLUE_BUTTON_URL is None or settings.BIG_BLUE_BUTTON_SECRET is None:
raise JsonableError(_("BigBlueButton is not configured.")) raise JsonableError(_("BigBlueButton is not configured."))
else:
try:
response = VideoCallSession().get(
append_url_query_string(
settings.BIG_BLUE_BUTTON_URL + "api/create",
urlencode(
{
"meetingID": meeting_id,
"moderatorPW": password,
"attendeePW": password + "a",
"checksum": checksum,
}
),
)
)
response.raise_for_status()
except requests.RequestException:
raise JsonableError(_("Error connecting to the BigBlueButton server."))
payload = ElementTree.fromstring(response.text) try:
if payload.find("messageKey").text == "checksumError": bigbluebutton_data = Signer().unsign_object(bigbluebutton)
raise JsonableError(_("Error authenticating to the BigBlueButton server.")) except Exception:
raise JsonableError(_("Invalid signature."))
if payload.find("returncode").text != "SUCCESS": create_params = urlencode(
raise JsonableError(_("BigBlueButton server returned an unexpected error.")) {
"meetingID": bigbluebutton_data["meeting_id"],
"name": bigbluebutton_data["name"],
"moderatorPW": bigbluebutton_data["password"],
# We generate the attendee password from moderatorPW,
# because the BigBlueButton API requires a separate
# password. This integration is designed to have all users
# join as moderators, so we generate attendeePW by
# truncating the moderatorPW while keeping it long enough
# to not be vulnerable to brute force attacks.
"attendeePW": bigbluebutton_data["password"][:16],
},
quote_via=quote,
)
join_params = urlencode( checksum = hashlib.sha256(
{ ("create" + create_params + settings.BIG_BLUE_BUTTON_SECRET).encode()
"meetingID": meeting_id, ).hexdigest()
"password": password,
"fullName": request.user.full_name, try:
}, response = VideoCallSession().get(
quote_via=quote, append_url_query_string(settings.BIG_BLUE_BUTTON_URL + "api/create", create_params)
+ "&checksum="
+ checksum
) )
response.raise_for_status()
except requests.RequestException:
raise JsonableError(_("Error connecting to the BigBlueButton server."))
checksum = hashlib.sha256( payload = ElementTree.fromstring(response.text)
("join" + join_params + settings.BIG_BLUE_BUTTON_SECRET).encode() if payload.find("messageKey").text == "checksumError":
).hexdigest() raise JsonableError(_("Error authenticating to the BigBlueButton server."))
redirect_url_base = append_url_query_string(
settings.BIG_BLUE_BUTTON_URL + "api/join", join_params if payload.find("returncode").text != "SUCCESS":
) raise JsonableError(_("BigBlueButton server returned an unexpected error."))
return redirect(append_url_query_string(redirect_url_base, "checksum=" + checksum))
join_params = urlencode(
{
"meetingID": bigbluebutton_data["meeting_id"],
# We use the moderator password here to grant ever user
# full moderator permissions to the bigbluebutton session.
"password": bigbluebutton_data["password"],
"fullName": request.user.full_name,
# https://docs.bigbluebutton.org/dev/api.html#create
# The createTime option is used to have the user redirected to a link
# that is only valid for this meeting.
#
# Even if the same link in Zulip is used again, a new
# createTime parameter will be created, as the meeting on
# the BigBlueButton server has to be recreated. (after a
# few minutes)
"createTime": payload.find("createTime").text,
},
quote_via=quote,
)
checksum = hashlib.sha256(
("join" + join_params + settings.BIG_BLUE_BUTTON_SECRET).encode()
).hexdigest()
redirect_url_base = append_url_query_string(
settings.BIG_BLUE_BUTTON_URL + "api/join", join_params
)
return redirect(append_url_query_string(redirect_url_base, "checksum=" + checksum))