2020-04-27 22:41:31 +02:00
|
|
|
|
import hashlib
|
2019-11-16 09:26:28 +01:00
|
|
|
|
import json
|
2020-04-27 22:41:31 +02:00
|
|
|
|
import random
|
2020-09-05 04:02:13 +02:00
|
|
|
|
import secrets
|
|
|
|
|
from base64 import b32encode
|
2020-06-11 00:54:34 +02:00
|
|
|
|
from functools import partial
|
2019-11-16 09:26:28 +01:00
|
|
|
|
from typing import Dict
|
2020-04-27 22:41:31 +02:00
|
|
|
|
from urllib.parse import quote, urlencode, urljoin
|
2019-11-16 09:26:28 +01:00
|
|
|
|
|
2020-06-11 00:54:34 +02:00
|
|
|
|
import requests
|
2020-04-27 22:41:31 +02:00
|
|
|
|
from defusedxml import ElementTree
|
2019-11-16 09:26:28 +01:00
|
|
|
|
from django.conf import settings
|
2020-06-11 00:54:34 +02:00
|
|
|
|
from django.http import HttpRequest, HttpResponse
|
2019-11-16 09:26:28 +01:00
|
|
|
|
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
|
|
|
|
|
from requests_oauthlib import OAuth2Session
|
2018-12-28 20:45:54 +01:00
|
|
|
|
|
2019-11-16 09:26:28 +01:00
|
|
|
|
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
|
2019-12-20 00:00:45 +01:00
|
|
|
|
from zerver.lib.pysa import mark_sanitized
|
2020-04-27 22:41:31 +02:00
|
|
|
|
from zerver.lib.response import json_error, json_success
|
2019-11-16 09:26:28 +01:00
|
|
|
|
from zerver.lib.subdomains import get_subdomain
|
2020-04-27 22:41:31 +02:00
|
|
|
|
from zerver.lib.url_encoding import add_query_arg_to_redirect_url, add_query_to_redirect_url
|
2019-11-16 09:26:28 +01:00
|
|
|
|
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"),
|
|
|
|
|
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)
|
2019-12-20 00:00:45 +01:00
|
|
|
|
# Use 'mark_sanitized' to cause Pysa to ignore the flow of user controlled
|
|
|
|
|
# data out of this function. 'request.META' is indeed user controlled, but
|
2020-08-11 01:47:44 +02:00
|
|
|
|
# post-HMAC output is no longer meaningfully controllable.
|
2019-12-20 00:00:45 +01:00
|
|
|
|
return mark_sanitized(
|
2019-11-16 09:26:28 +01:00
|
|
|
|
""
|
|
|
|
|
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(
|
python: Use trailing commas consistently.
Automatically generated by the following script, based on the output
of lint with flake8-comma:
import re
import sys
last_filename = None
last_row = None
lines = []
for msg in sys.stdin:
m = re.match(
r"\x1b\[35mflake8 \|\x1b\[0m \x1b\[1;31m(.+):(\d+):(\d+): (\w+)", msg
)
if m:
filename, row_str, col_str, err = m.groups()
row, col = int(row_str), int(col_str)
if filename == last_filename:
assert last_row != row
else:
if last_filename is not None:
with open(last_filename, "w") as f:
f.writelines(lines)
with open(filename) as f:
lines = f.readlines()
last_filename = filename
last_row = row
line = lines[row - 1]
if err in ["C812", "C815"]:
lines[row - 1] = line[: col - 1] + "," + line[col - 1 :]
elif err in ["C819"]:
assert line[col - 2] == ","
lines[row - 1] = line[: col - 2] + line[col - 1 :].lstrip(" ")
if last_filename is not None:
with open(last_filename, "w") as f:
f.writelines(lines)
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-04-10 05:23:40 +02:00
|
|
|
|
{"realm": get_subdomain(request), "sid": get_zoom_sid(request)},
|
2019-11-16 09:26:28 +01:00
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
return redirect(authorization_url)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@never_cache
|
|
|
|
|
@has_request_variables
|
|
|
|
|
def complete_zoom_user(
|
|
|
|
|
request: HttpRequest,
|
2020-06-21 03:24:44 +02:00
|
|
|
|
state: Dict[str, str] = REQ(validator=check_dict([("realm", check_string)], value_validator=check_string)),
|
2019-11-16 09:26:28 +01:00
|
|
|
|
) -> 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(),
|
2020-06-21 03:24:44 +02:00
|
|
|
|
state: Dict[str, str] = REQ(validator=check_dict([("sid", check_string)], value_validator=check_string)),
|
2019-11-16 09:26:28 +01:00
|
|
|
|
) -> 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
|
2018-12-28 20:45:54 +01:00
|
|
|
|
@has_request_variables
|
2019-11-16 09:26:28 +01:00
|
|
|
|
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()
|
2020-04-27 22:41:31 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_bigbluebutton_url(request: HttpRequest, user_profile: UserProfile) -> HttpResponse:
|
|
|
|
|
# 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
|
|
|
|
|
id = "zulip-" + str(random.randint(100000000000, 999999999999))
|
2020-09-05 04:02:13 +02:00
|
|
|
|
password = b32encode(secrets.token_bytes(7))[:10].decode()
|
2020-04-27 22:41:31 +02:00
|
|
|
|
checksum = hashlib.sha1(("create" + "meetingID=" + id + "&moderatorPW="
|
|
|
|
|
+ password + "&attendeePW=" + password + "a" + settings.BIG_BLUE_BUTTON_SECRET).encode()).hexdigest()
|
|
|
|
|
url = add_query_to_redirect_url("/calls/bigbluebutton/join", urlencode({
|
|
|
|
|
"meeting_id": "\"" + id + "\"",
|
|
|
|
|
"password": "\"" + password + "\"",
|
|
|
|
|
"checksum": "\"" + checksum + "\""
|
|
|
|
|
}))
|
|
|
|
|
return json_success({"url": url})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# We use zulip_login_required here mainly to get access to the user's
|
|
|
|
|
# full name from Zulip to prepopulate the user's name in the
|
|
|
|
|
# BigBlueButton meeting. Since the meeting's details are encoded in
|
|
|
|
|
# the link the user is clicking, there is no validation tying this
|
|
|
|
|
# meeting to the Zulip organization it was created in.
|
|
|
|
|
@zulip_login_required
|
|
|
|
|
@never_cache
|
|
|
|
|
@has_request_variables
|
|
|
|
|
def join_bigbluebutton(request: HttpRequest, meeting_id: str = REQ(validator=check_string),
|
|
|
|
|
password: str = REQ(validator=check_string),
|
|
|
|
|
checksum: str = REQ(validator=check_string)) -> HttpResponse:
|
|
|
|
|
if settings.BIG_BLUE_BUTTON_URL is None or settings.BIG_BLUE_BUTTON_SECRET is None:
|
|
|
|
|
return json_error(_("Big Blue Button is not configured."))
|
|
|
|
|
else:
|
|
|
|
|
response = requests.get(
|
|
|
|
|
add_query_to_redirect_url(settings.BIG_BLUE_BUTTON_URL + "api/create", urlencode({
|
|
|
|
|
"meetingID": meeting_id,
|
|
|
|
|
"moderatorPW": password,
|
|
|
|
|
"attendeePW": password + "a",
|
|
|
|
|
"checksum": checksum
|
|
|
|
|
})))
|
|
|
|
|
try:
|
|
|
|
|
response.raise_for_status()
|
|
|
|
|
except Exception:
|
|
|
|
|
return json_error(_("Error connecting to the Big Blue Button server."))
|
|
|
|
|
|
|
|
|
|
payload = ElementTree.fromstring(response.text)
|
|
|
|
|
if payload.find("messageKey").text == "checksumError":
|
|
|
|
|
return json_error(_("Error authenticating to the Big Blue Button server."))
|
|
|
|
|
|
|
|
|
|
if payload.find("returncode").text != "SUCCESS":
|
|
|
|
|
return json_error(_("Big Blue Button server returned an unexpected error."))
|
|
|
|
|
|
2020-09-02 02:50:08 +02:00
|
|
|
|
join_params = urlencode( # type: ignore[type-var] # https://github.com/python/typeshed/issues/4234
|
|
|
|
|
{
|
|
|
|
|
"meetingID": meeting_id,
|
|
|
|
|
"password": password,
|
|
|
|
|
"fullName": request.user.full_name,
|
|
|
|
|
},
|
|
|
|
|
quote_via=quote,
|
|
|
|
|
)
|
2020-04-27 22:41:31 +02:00
|
|
|
|
|
|
|
|
|
checksum = hashlib.sha1(("join" + join_params + settings.BIG_BLUE_BUTTON_SECRET).encode()).hexdigest()
|
|
|
|
|
redirect_url_base = add_query_to_redirect_url(settings.BIG_BLUE_BUTTON_URL + "api/join", join_params)
|
|
|
|
|
return redirect(add_query_arg_to_redirect_url(redirect_url_base, "checksum=" + checksum))
|