From de2115389145109cc5b08efaa0732c5eadb407cb Mon Sep 17 00:00:00 2001 From: Steven Loria Date: Sun, 27 Aug 2023 23:51:29 -0400 Subject: [PATCH] feat(zoom): Zoom oauth server-to-server --- .env.example | 9 +- bot/__main__.py | 4 +- bot/app.py | 5 +- bot/exts/meetings/_zoom.py | 15 ++- bot/exts/meetings/meetings.py | 134 ++--------------------- bot/exts/meetings/zoom_webhooks.py | 32 +++++- bot/settings.py | 7 +- lib/meetings/__init__.py | 131 +--------------------- lib/meetings/zoom.py | 169 +++++++++++++++++++++++++++++ 9 files changed, 232 insertions(+), 274 deletions(-) create mode 100644 lib/meetings/zoom.py diff --git a/.env.example b/.env.example index 951c3f49..07245f5d 100644 --- a/.env.example +++ b/.env.example @@ -23,10 +23,15 @@ SIGN_CAFE_ENABLE_UNMUTE_WARNING="true" DAILY_PRACTICE_SEND_TIME=14:00 GUILD_SETTINGS="" +# Zoom server-to-server OAuth +ZOOM_ACCOUNT_ID="CHANGEME" +ZOOM_CLIENT_ID="CHANGEME" +ZOOM_CLIENT_SECRET="CHANGEME" + # Mapping of Discord usernames w/ discriminator => Zoom user email addresses or IDs + ZOOM_USERS="1234=bob@example.com,5678=alice@example.com" -ZOOM_JWT="CHANGEME" -ZOOM_HOOK_TOKEN="CHANGEME" +ZOOM_HOOK_SECRET="CHANGEME" ZZZZOOM_URL=https://zzzzooom.us WATCH2GETHER_API_KEY="CHANGEME" diff --git a/bot/__main__.py b/bot/__main__.py index 13172983..7d325eb1 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -3,7 +3,7 @@ from aiohttp import web from . import __version__, settings -from .app import app +from .app import app, bot log_format = "%(asctime)s - %(name)s %(levelname)s: %(message)s" logging.getLogger("disnake").setLevel(logging.WARNING) @@ -14,4 +14,4 @@ logger = logging.getLogger(__name__) logger.info(f"starting bot version {__version__}") -web.run_app(app, port=settings.PORT) +web.run_app(app, loop=bot.loop, port=settings.PORT) diff --git a/bot/app.py b/bot/app.py index a5d4665c..0f767e38 100644 --- a/bot/app.py +++ b/bot/app.py @@ -1,4 +1,3 @@ -import asyncio import logging import aiohttp_cors @@ -60,11 +59,11 @@ async def start_bot(): await bot.close() -async def on_startup(app): +async def on_startup(app: web.Application): for ext in walk_extensions(): bot.load_extension(ext) await store.connect() - app["bot_task"] = asyncio.create_task(start_bot()) + app["bot_task"] = bot.loop.create_task(start_bot()) app["bot"] = bot diff --git a/bot/exts/meetings/_zoom.py b/bot/exts/meetings/_zoom.py index 6c39d0a3..cebb8b18 100644 --- a/bot/exts/meetings/_zoom.py +++ b/bot/exts/meetings/_zoom.py @@ -7,10 +7,10 @@ import disnake import holiday_emojis -import meetings from aiohttp import client from disnake.ext.commands import Bot, Context, errors, has_any_role from disnake.ext.commands.errors import MissingAnyRole, NoPrivateMessage +from meetings.zoom import ZoomClient from nameparser import HumanName from bot import settings @@ -83,6 +83,12 @@ "🙀", ) +zoom_client = ZoomClient( + account_id=settings.ZOOM_ACCOUNT_ID, + client_id=settings.ZOOM_CLIENT_ID, + client_secret=settings.ZOOM_CLIENT_SECRET, +) + def display_participant_names( participants: Sequence[Mapping], meeting: Mapping, max_to_display: int = 15 @@ -256,9 +262,7 @@ async def maybe_create_zoom_meeting( meeting_exists = await store.zoom_meeting_exists(meeting_id=meeting_id) if not meeting_exists: try: - meeting = await meetings.get_zoom( - token=settings.ZOOM_JWT, meeting_id=meeting_id - ) + meeting = await zoom_client.get_zoom(meeting_id=meeting_id) except client.ClientResponseError as error: logger.exception(f"error when fetching zoom meeting {meeting_id}") raise errors.CheckFailure( @@ -336,8 +340,7 @@ async def zoom_impl( return zoom_meeting_id, message else: try: - meeting = await meetings.create_zoom( - token=settings.ZOOM_JWT, + meeting = await zoom_client.create_zoom( user_id=zoom_user, topic="", settings={ diff --git a/bot/exts/meetings/meetings.py b/bot/exts/meetings/meetings.py index 5294341a..16e7028a 100644 --- a/bot/exts/meetings/meetings.py +++ b/bot/exts/meetings/meetings.py @@ -5,11 +5,7 @@ import disnake import meetings -from disnake import ( - ApplicationCommandInteraction, - GuildCommandInteraction, - MessageInteraction, -) +from disnake import ApplicationCommandInteraction, GuildCommandInteraction from disnake.ext.commands import ( Bot, Cog, @@ -34,7 +30,7 @@ maybe_clear_reaction, should_handle_reaction, ) -from bot.utils.ui import ButtonGroupOption, ButtonGroupView, DropdownView +from bot.utils.ui import ButtonGroupOption, ButtonGroupView from ._zoom import ( REPOST_EMOJI, @@ -44,6 +40,7 @@ get_zoom_meeting_id, is_allowed_zoom_access, make_zoom_send_kwargs, + zoom_client, zoom_impl, ) @@ -338,7 +335,7 @@ async def zoom_stop( async def zoom_users(self, inter: ApplicationCommandInteraction): """(Bot owner only) List users who have access to the zoom commands""" try: - users = await meetings.list_zoom_users(token=settings.ZOOM_JWT) + users = await zoom_client.list_zoom_users() except asyncio.exceptions.TimeoutError: logger.exception("zoom request timed out") await inter.send( @@ -346,7 +343,9 @@ async def zoom_users(self, inter: ApplicationCommandInteraction): ) return licensed_user_emails = { - user.email for user in users if user.type == meetings.ZoomPlanType.LICENSED + user.email + for user in users + if user.type == meetings.zoom.ZoomPlanType.LICENSED } description = "\n".join( tuple( @@ -362,125 +361,6 @@ async def zoom_users(self, inter: ApplicationCommandInteraction): embed.set_footer(text="👑 = Licensed") await inter.send(embed=embed) - @zoom_command.sub_command( - name="license", hidden=True, help="Upgrade a user to the Licensed plan type." - ) - @is_owner() - async def zoom_license( - self, - inter: ApplicationCommandInteraction, - user: disnake.User, - ): - """(Bot owner only) Upgrade a Zoom user to a Licensed plan""" - assert inter.user is not None - if user.id not in settings.ZOOM_USERS: - await inter.send(f"🚨 _{user.mention} is not a configured Zoom user._") - return - - await inter.send( - f"✋ **{user.mention} will be upgraded to a Licensed plan**.", - ) - zoom_user_id = settings.ZOOM_USERS[user.id] - try: - logger.info(f"attempting to upgrade user {user.id} to licensed plan") - await meetings.update_zoom_user( - token=settings.ZOOM_JWT, - user_id=zoom_user_id, - data={"type": meetings.ZoomPlanType.LICENSED}, - ) - except meetings.MaxZoomLicensesError: - try: - users = await meetings.list_zoom_users(token=settings.ZOOM_JWT) - except asyncio.exceptions.TimeoutError: - logger.exception("zoom request timed out") - await inter.send( - "🚨 _Request to Zoom API timed out. This may be due to rate limiting. Try again later._" - ) - return - zoom_to_discord_user_mapping = { - email.lower(): disnake_id - for disnake_id, email in settings.ZOOM_USERS.items() - } - # Discord user IDs for Licensed users - licensed_user_discord_ids = tuple( - zoom_to_discord_user_mapping[user.email.lower()] - for user in users - if user.email.lower() in zoom_to_discord_user_mapping - and user.type == meetings.ZoomPlanType.LICENSED - # Don't allow de-licensing the bot owner, of course - and zoom_to_discord_user_mapping[user.email.lower()] != settings.OWNER_ID - ) - if len(licensed_user_discord_ids): - options = [ - disnake.SelectOption( - label=settings.ZOOM_USERS[discord_user_id], value=discord_user_id - ) - for discord_user_id in licensed_user_discord_ids - ] - - async def on_select(select_interaction: MessageInteraction, value: str): - downgraded_user_id = int(value) - await select_interaction.response.edit_message( - content=f"☑️ Selected <@!{downgraded_user_id}> to downgrade.", - view=None, - ) - try: - logger.info( - f"attempting to downgrade user {downgraded_user_id} to basic plan" - ) - await meetings.update_zoom_user( - token=settings.ZOOM_JWT, - user_id=settings.ZOOM_USERS[downgraded_user_id], - data={"type": meetings.ZoomPlanType.BASIC}, - ) - except meetings.ZoomClientError: - logger.exception(f"failed to downgrade user {downgraded_user_id}") - await inter.send( - f"🚨 _Failed to downgrade <@!{downgraded_user_id}>. Check the logs for details._" - ) - try: - logger.info( - f"re-attempting to upgrade user {user.id} to licensed plan" - ) - await meetings.update_zoom_user( - token=settings.ZOOM_JWT, - user_id=zoom_user_id, - data={"type": meetings.ZoomPlanType.LICENSED}, - ) - except meetings.ZoomClientError: - logger.exception(f"failed to upgrade user {user.id}") - await inter.send( - f"🚨 _Failed to upgrade {user.mention}. Check the logs for details._" - ) - await inter.send( - f"👑 **{user.mention} successfully upgraded to Licensed plan.**\n<@!{downgraded_user_id}> downgraded to Basic." - ) - - view = DropdownView.from_options( - options=options, - on_select=on_select, - placeholder="Choose a user", - creator_id=inter.user.id, - ) - await inter.send("Choose a user to downgrade to Basic.", view=view) - else: - await inter.send( - "🚨 _No available users to downgrade on Discord. Go to the Zoom account settings to manage licenses_." - ) - return - return - except meetings.ZoomClientError as error: - await inter.send(f"🚨 _{error.args[0]}_") - return - except Exception: - logger.exception(f"failed to license user {user}") - await inter.send( - f"🚨 _Failed to license user {user.mention}. Check the logs for details._" - ) - return - else: - await inter.send(f"👑 **{user.mention} upgraded to a Licensed plan**.") - @slash_command(name="watch2gether") async def watch2gether_command( self, inter: ApplicationCommandInteraction, video_url: Optional[str] = None diff --git a/bot/exts/meetings/zoom_webhooks.py b/bot/exts/meetings/zoom_webhooks.py index b497312b..995ece38 100644 --- a/bot/exts/meetings/zoom_webhooks.py +++ b/bot/exts/meetings/zoom_webhooks.py @@ -1,5 +1,8 @@ import asyncio import datetime as dt +import hashlib +import hmac +import json import logging from typing import cast @@ -140,10 +143,33 @@ async def handle_zoom_event(bot: Bot, data: dict): def setup(bot: Bot) -> None: - async def zoom(request): - if request.headers["authorization"] != settings.ZOOM_HOOK_TOKEN: + async def zoom(request: web.Request): + text = await request.text() + data = json.loads(text) + event = data["event"] + # https://developers.zoom.us/docs/api/rest/webhook-reference/#validate-your-webhook-endpoint + if event == "endpoint.url_validation": + plain_token = data["payload"]["plainToken"] + encrypted_token = hmac.new( + settings.ZOOM_HOOK_SECRET.encode("utf-8"), + plain_token.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return web.json_response( + {"plainToken": plain_token, "encryptedToken": encrypted_token} + ) + # https://developers.zoom.us/docs/api/rest/webhook-reference/#verify-webhook-events + message = f"v0:{request.headers['x-zm-request-timestamp']}:{text}" + signature = hmac.new( + settings.ZOOM_HOOK_SECRET.encode("utf-8"), + message.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + expected_signature = request.headers["x-zm-signature"] + actual_signature = f"v0={signature}" + if expected_signature != actual_signature: return web.Response(body="", status=403) - data = await request.json() + # Zoom expects responses within 3 seconds, so run the handler logic asynchronously # https://marketplace.zoom.us/docs/api-reference/webhook-reference#notification-delivery asyncio.create_task(handle_zoom_event(bot, data)) diff --git a/bot/settings.py b/bot/settings.py index d75db979..b7a4520b 100644 --- a/bot/settings.py +++ b/bot/settings.py @@ -67,12 +67,15 @@ # Mapping of Discord user IDs => emails ZOOM_USERS = env.dict("ZOOM_USERS", subcast_keys=int, required=True) +ZOOM_ACCOUNT_ID = env.str("ZOOM_ACCOUNT_ID", required=True) +ZOOM_CLIENT_ID = env.str("ZOOM_CLIENT_ID", required=True) +ZOOM_CLIENT_SECRET = env.str("ZOOM_CLIENT_SECRET", required=True) # Emails for Zoom users that should never be downgraded to Basic ZOOM_NO_DOWNGRADE = env.list("ZOOM_NO_DOWNGRADE", default=[], subcast=str) ZOOM_EMAILS = {email: zoom_id for zoom_id, email in ZOOM_USERS.items()} -ZOOM_JWT = env.str("ZOOM_JWT", required=True) -ZOOM_HOOK_TOKEN = env.str("ZOOM_HOOK_TOKEN", required=True) +ZOOM_HOOK_SECRET = env.str("ZOOM_HOOK_SECRET", required=True) ZOOM_REPOST_COOLDOWN = env.int("ZOOM_REPOST_COOLDOWN", 30) + ZZZZOOM_URL = env.str("ZZZZOOM_URL", "https://zzzzoom.us") WATCH2GETHER_API_KEY = env.str("WATCH2GETHER_API_KEY", required=True) diff --git a/lib/meetings/__init__.py b/lib/meetings/__init__.py index af75e243..dc36295e 100644 --- a/lib/meetings/__init__.py +++ b/lib/meetings/__init__.py @@ -3,142 +3,15 @@ import base64 import hashlib import hmac -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager -from enum import IntEnum from typing import Callable, NamedTuple import aiohttp import cuteid from slugify import slugify +from . import zoom -class ZoomClientError(Exception): - pass - - -class MaxZoomLicensesError(ZoomClientError): - pass - - -class BasicUserLimitReachedError(ZoomClientError): - pass - - -@asynccontextmanager -async def zoom_request( - method: str, - path: str, - *, - token: str, - raise_for_status: bool = True, - timeout: int = 10, - **kwargs, -) -> AsyncIterator[aiohttp.ClientResponse]: - client_timeout = aiohttp.ClientTimeout(total=timeout) - async with aiohttp.request( - method, - f"https://api.zoom.us/v2/{path.lstrip('/')}", - timeout=client_timeout, - headers={"Authorization": f"Bearer {token}"}, - **kwargs, - ) as resp: - if raise_for_status: - resp.raise_for_status() - yield resp - - -class ZoomMeeting(NamedTuple): - id: int - join_url: str - passcode: str - topic: str - - -async def create_zoom( - *, token: str, user_id: str, topic: str, settings: dict -) -> ZoomMeeting: - """Create and return a Zoom meeting via the Zoom API.""" - async with zoom_request( - "POST", - f"/users/{user_id}/meetings", - token=token, - json={ - "type": 1, - "topic": topic, - "settings": settings, - }, - ) as resp: - data = await resp.json() - return ZoomMeeting( - id=data["id"], - join_url=data["join_url"], - passcode=data["password"], - # Pass topic directly so we don't get the default 'Zoom Meeting' topic - topic=topic, - ) - - -class ZoomPlanType(IntEnum): - BASIC = 1 - LICENSED = 2 - - -class ZoomUser(NamedTuple): - id: str - email: str - type: ZoomPlanType - - -async def list_zoom_users(*, token: str) -> list[ZoomUser]: - async with zoom_request("GET", "/users", token=token) as resp: - data = await resp.json() - return [ZoomUser(id=u["id"], email=u["email"], type=u["type"]) for u in data["users"]] - - -async def update_zoom_user(*, token: str, user_id: str, data: dict): - async with zoom_request( - "PATCH", - f"/users/{user_id}", - token=token, - json=data, - raise_for_status=False, - ) as resp: - if resp.status >= 400: - resp_data = await resp.json() - error_code = int(resp_data["code"]) - message = resp_data["message"] - # NOTE: Contrary to Zoom's docs, 3412 is used for max licenses exceeded - if error_code in {2034, 3412}: - raise MaxZoomLicensesError(message) - elif error_code == 2033: - raise BasicUserLimitReachedError(message) - else: - resp.raise_for_status() - else: - resp.raise_for_status() - return None - - -async def get_zoom( - *, - token: str, - meeting_id: int, -) -> ZoomMeeting: - """Get an existing Zoom meeting via the Zoom API.""" - async with aiohttp.ClientSession() as client: - resp = await client.get( - f"https://api.zoom.us/v2/meetings/{meeting_id}", - headers={"Authorization": f"Bearer {token}"}, - ) - resp.raise_for_status() - data = await resp.json() - return ZoomMeeting( - id=data["id"], - join_url=data["join_url"], - passcode=data["password"], - topic=data["topic"], - ) +__all__ = ["zoom", "create_watch2gether", "create_jitsi_meet", "create_speakeasy"] async def create_watch2gether(api_key: str, video_url: str | None = None) -> str: diff --git a/lib/meetings/zoom.py b/lib/meetings/zoom.py new file mode 100644 index 00000000..6be6c91b --- /dev/null +++ b/lib/meetings/zoom.py @@ -0,0 +1,169 @@ +import base64 +import time +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from enum import IntEnum +from typing import NamedTuple + +import aiohttp + + +class ZoomTokenManager: + """Utility class for getting and storing server-to-server access tokens via + via the OAuth token API. + + https://developers.zoom.us/docs/internal-apps/s2s-oauth/#use-account-credentials-to-get-an-access-token + """ + + URL = "https://zoom.us/oauth/token" + + def __init__( + self, + *, + account_id: str, + client_id: str, + client_secret: str, + renew_pad_secs: int = 60, + ): + self.account_id = account_id + self.client_id = client_id + self.client_secret = client_secret + self.renew_pad_secs = renew_pad_secs + self._token = None + self._exp = None + + async def login(self): + """Use account credentials to get and store an access token.""" + credentials = base64.b64encode( + f"{self.client_id}:{self.client_secret}".encode() + ).decode("utf-8") + async with aiohttp.ClientSession() as client: + resp = await client.post( + self.URL, + params={ + "grant_type": "account_credentials", + "account_id": self.account_id, + }, + headers={ + "Host": "zoom.us", + "Authorization": f"Basic {credentials}", + }, + ) + resp.raise_for_status() + data = await resp.json() + + self._token = data["access_token"] + self._exp = time.time() + data["expires_in"] - self.renew_pad_secs + + async def token(self): + if not self._token or not self._exp or time.time() > self._exp: + await self.login() + + return self._token + + +# ----------------------------------------------------------------------------- + + +class ZoomMeeting(NamedTuple): + id: int + join_url: str + passcode: str + topic: str + + +class ZoomPlanType(IntEnum): + BASIC = 1 + LICENSED = 2 + + +class ZoomUser(NamedTuple): + id: str + email: str + type: ZoomPlanType + + +# ----------------------------------------------------------------------------- + + +class ZoomClient: + def __init__( + self, + *, + account_id: str, + client_id: str, + client_secret: str, + ): + self.token_manager = ZoomTokenManager( + account_id=account_id, client_id=client_id, client_secret=client_secret + ) + + @asynccontextmanager + async def _zoom_request( + self, + method: str, + path: str, + *, + raise_for_status: bool = True, + timeout: int = 10, + **kwargs, + ) -> AsyncIterator[aiohttp.ClientResponse]: + client_timeout = aiohttp.ClientTimeout(total=timeout) + token = await self.token_manager.token() + async with aiohttp.request( + method, + f"https://api.zoom.us/v2/{path.lstrip('/')}", + timeout=client_timeout, + headers={"Host": "api.zoom.us", "Authorization": f"Bearer {token}"}, + **kwargs, + ) as resp: + if raise_for_status: + resp.raise_for_status() + yield resp + + async def create_zoom( + self, *, user_id: str, topic: str, settings: dict + ) -> ZoomMeeting: + """Create and return a Zoom meeting via the Zoom API.""" + async with self._zoom_request( + "POST", + f"/users/{user_id}/meetings", + json={ + "type": 1, + "topic": topic, + "settings": settings, + }, + ) as resp: + data = await resp.json() + return ZoomMeeting( + id=data["id"], + join_url=data["join_url"], + passcode=data["password"], + # Pass topic directly so we don't get the default 'Zoom Meeting' topic + topic=topic, + ) + + async def get_zoom( + self, + *, + meeting_id: int, + ) -> ZoomMeeting: + """Get an existing Zoom meeting via the Zoom API.""" + async with self._zoom_request( + "GET", + f"/meetings/{meeting_id}", + ) as resp: + data = await resp.json() + return ZoomMeeting( + id=data["id"], + join_url=data["join_url"], + passcode=data["password"], + topic=data["topic"], + ) + + async def list_zoom_users(self) -> list[ZoomUser]: + async with self._zoom_request("GET", "/users") as resp: + data = await resp.json() + return [ + ZoomUser(id=u["id"], email=u["email"], type=u["type"]) for u in data["users"] + ]