diff --git a/backend/src/appointment/controller/apis/zoom_client.py b/backend/src/appointment/controller/apis/zoom_client.py index f8747a6f..638ed278 100644 --- a/backend/src/appointment/controller/apis/zoom_client.py +++ b/backend/src/appointment/controller/apis/zoom_client.py @@ -1,4 +1,5 @@ import json +import os import time import sentry_sdk @@ -14,6 +15,15 @@ class ZoomClient: OAUTH_REQUEST_URL = 'https://api.zoom.us/v2' SCOPES = ['user:read', 'user_info:read', 'meeting:write'] + NEW_SCOPES = [ + 'meeting:read:meeting', + 'meeting:write:meeting', + 'meeting:update:meeting', + 'meeting:delete:meeting', + 'meeting:write:invite_links', + 'user:read:email', + 'user:read:user', + ] client: OAuth2Session | None = None subscriber_id: int | None = None @@ -24,6 +34,14 @@ def __init__(self, client_id, client_secret, callback_url): self.callback_url = callback_url self.subscriber_id = None self.client = None + self.use_new_scopes = os.getenv('ZOOM_API_NEW_APP', False) == 'True' + + @property + def scopes(self): + """Returns the appropriate scopes""" + if self.use_new_scopes: + return self.NEW_SCOPES + return self.SCOPES def check_expiry(self, token: dict | None): """Checks expires_at and if expired sets expires_in to a negative number to trigger refresh""" @@ -50,7 +68,7 @@ def setup(self, subscriber_id=None, token=None): self.client = OAuth2Session( self.client_id, redirect_uri=self.callback_url, - scope=self.SCOPES, + scope=self.scopes, auto_refresh_url=self.OAUTH_TOKEN_URL, auto_refresh_kwargs={ 'client_id': self.client_id, diff --git a/backend/src/appointment/controller/zoom.py b/backend/src/appointment/controller/zoom.py new file mode 100644 index 00000000..9d1999f2 --- /dev/null +++ b/backend/src/appointment/controller/zoom.py @@ -0,0 +1,16 @@ +from sqlalchemy.orm import Session + +from appointment.database import repo, models +from appointment.database.models import ExternalConnectionType + + +def disconnect(db: Session, subscriber_id: int, type_id: str) -> bool: + """Disconnects a zoom external connection from a given subscriber id and zoom type id""" + repo.external_connection.delete_by_type(db, subscriber_id, ExternalConnectionType.zoom, type_id) + schedules = repo.schedule.get_by_subscriber(db, subscriber_id) + for schedule in schedules: + if schedule.meeting_link_provider == models.MeetingLinkProviderType.zoom: + schedule.meeting_link_provider = models.MeetingLinkProviderType.none + db.add(schedule) + db.commit() + return True diff --git a/backend/src/appointment/database/repo/external_connection.py b/backend/src/appointment/database/repo/external_connection.py index 5bc9a83c..6eda6a5e 100644 --- a/backend/src/appointment/database/repo/external_connection.py +++ b/backend/src/appointment/database/repo/external_connection.py @@ -77,3 +77,19 @@ def get_subscriber_by_fxa_uid(db: Session, type_id: str): return result.owner return None + + +def get_subscriber_by_zoom_user_id(db: Session, type_id: str): + """Return a subscriber from a zoom user id""" + query = ( + db.query(models.ExternalConnections) + .filter(models.ExternalConnections.type == models.ExternalConnectionType.zoom) + .filter(models.ExternalConnections.type_id == type_id) + ) + + result = query.first() + + if result is not None: + return result.owner + + return None diff --git a/backend/src/appointment/dependencies/zoom.py b/backend/src/appointment/dependencies/zoom.py index 4e139331..3fa5c59c 100644 --- a/backend/src/appointment/dependencies/zoom.py +++ b/backend/src/appointment/dependencies/zoom.py @@ -1,7 +1,9 @@ +import hashlib +import hmac import logging import os -from fastapi import Depends +from fastapi import Depends, Request from .auth import get_subscriber from ..controller.apis.zoom_client import ZoomClient @@ -25,3 +27,38 @@ def get_zoom_client(subscriber: Subscriber = Depends(get_subscriber)): raise e return _zoom_client + + +async def get_webhook_auth(request: Request): + data = await request.json() + event = data.get('event') + + if not event or event != 'app_deauthorized': + return None + + signature = request.headers.get('x-zm-signature') + signature_timestamp = request.headers.get('x-zm-request-timestamp') + key = os.getenv('ZOOM_API_SECRET') + + if not signature or not signature_timestamp or not key: + return None + + # Grab the body, and get encoding! + # Body is encoded in bytes so we'll need to decode it and re-encode it... + body = await request.body() + key = bytes(key, 'UTF-8') + message = bytes(f'v0:{signature_timestamp}:{body.decode('UTF-8')}', 'UTF-8') + hash = hmac.new(key, message, hashlib.sha256).hexdigest() + hash = f'v0={hash}' + + if hash != signature: + return None + + payload = data.get('payload', {}) + user_id = payload.get('user_id') + deauthorized_at = payload.get('deauthorization_time') + + if not user_id or not deauthorized_at: + return None + + return payload diff --git a/backend/src/appointment/routes/webhooks.py b/backend/src/appointment/routes/webhooks.py index deb8b3a8..5e11d12a 100644 --- a/backend/src/appointment/routes/webhooks.py +++ b/backend/src/appointment/routes/webhooks.py @@ -2,14 +2,16 @@ import requests import sentry_sdk -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Request from sqlalchemy.orm import Session -from ..controller import auth, data +from ..controller import auth, data, zoom from ..controller.apis.fxa_client import FxaClient from ..database import repo, models, schemas +from ..database.models import ExternalConnectionType from ..dependencies.database import get_db -from ..dependencies.fxa import get_webhook_auth, get_fxa_client +from ..dependencies.fxa import get_webhook_auth as get_webhook_auth_fxa, get_fxa_client +from ..dependencies.zoom import get_webhook_auth as get_webhook_auth_zoom from ..exceptions.account_api import AccountDeletionSubscriberFail from ..exceptions.fxa_api import MissingRefreshTokenException @@ -19,14 +21,14 @@ @router.post('/fxa-process') def fxa_process( db: Session = Depends(get_db), - decoded_token: dict = Depends(get_webhook_auth), + decoded_token: dict = Depends(get_webhook_auth_fxa), fxa_client: FxaClient = Depends(get_fxa_client), ): """Main for webhooks regarding fxa""" subscriber: models.Subscriber = repo.external_connection.get_subscriber_by_fxa_uid(db, decoded_token.get('sub')) if not subscriber: - logging.warning('Webhook event received for non-existent user.') + logging.warning('FXA webhook event received for non-existent user.') return subscriber_external_connection = subscriber.get_external_connection(models.ExternalConnectionType.fxa) @@ -86,3 +88,31 @@ def fxa_process( case _: logging.warning(f'Ignoring event {event}') + + +@router.post('/zoom-deauthorization') +def zoom_deauthorization( + request: Request, + db: Session = Depends(get_db), + webhook_payload: dict | None = Depends(get_webhook_auth_zoom) +): + if not webhook_payload: + logging.warning('Invalid zoom webhook event received.') + return + + user_id = webhook_payload.get('user_id') + + subscriber = repo.external_connection.get_subscriber_by_zoom_user_id( + db, + user_id + ) + + if not subscriber: + logging.warning('Zoom webhook event received for non-existent user.') + return + + try: + zoom.disconnect(db, subscriber.id, user_id) + except Exception as ex: + sentry_sdk.capture_exception(ex) + logging.error(f'Error disconnecting zoom connection: {ex}') diff --git a/backend/src/appointment/routes/zoom.py b/backend/src/appointment/routes/zoom.py index ebfa88d5..295dc076 100644 --- a/backend/src/appointment/routes/zoom.py +++ b/backend/src/appointment/routes/zoom.py @@ -5,6 +5,7 @@ from fastapi.responses import RedirectResponse from sqlalchemy.orm import Session +from ..controller import zoom from ..controller.apis.zoom_client import ZoomClient from ..controller.auth import sign_url from ..database import repo, schemas, models @@ -115,13 +116,7 @@ def disconnect_account( zoom_connection = subscriber.get_external_connection(ExternalConnectionType.zoom) if zoom_connection: - repo.external_connection.delete_by_type(db, subscriber.id, zoom_connection.type, zoom_connection.type_id) - schedules = repo.schedule.get_by_subscriber(db, subscriber.id) - for schedule in schedules: - if schedule.meeting_link_provider == models.MeetingLinkProviderType.zoom: - schedule.meeting_link_provider = models.MeetingLinkProviderType.none - db.add(schedule) - db.commit() + zoom.disconnect(db, subscriber.id, zoom_connection.type_id) else: return False diff --git a/backend/src/appointment/secrets.py b/backend/src/appointment/secrets.py index 69ed0e17..6755ff4e 100644 --- a/backend/src/appointment/secrets.py +++ b/backend/src/appointment/secrets.py @@ -61,6 +61,8 @@ def normalize_secrets(): os.environ['ZOOM_AUTH_CLIENT_ID'] = secrets.get('client_id') os.environ['ZOOM_AUTH_SECRET'] = secrets.get('secret') + os.environ['ZOOM_API_SECRET'] = secrets.get('api_secret') + os.environ['ZOOM_API_NEW_APP'] = secrets.get('api_new_app', False) fxa_secrets = os.getenv('FXA_SECRETS') diff --git a/backend/test/integration/test_webhooks.py b/backend/test/integration/test_webhooks.py index 1e89e48f..fce05de7 100644 --- a/backend/test/integration/test_webhooks.py +++ b/backend/test/integration/test_webhooks.py @@ -1,7 +1,13 @@ import datetime +import hashlib +import hmac +import json +import os +import pytest from freezegun import freeze_time from appointment.database import models, repo +from appointment.database.models import ExternalConnectionType from appointment.dependencies.fxa import get_webhook_auth from defines import FXA_CLIENT_PATCH @@ -167,3 +173,151 @@ def override_get_webhook_auth(): assert repo.subscriber.get(db, subscriber.id) is None assert repo.calendar.get(db, calendar.id) is None assert repo.appointment.get(db, appointment.id) is None + +class TestZoomWebhooks: + @pytest.fixture + def setup_deauthorization(self, make_pro_subscriber, make_external_connections): + zoom_user_id = 'z9jkdsfsdfjhdkfjQ' + + request_body = { + "event": "app_deauthorized", + "payload": { + "account_id": "EabCDEFghiLHMA", + "user_id": zoom_user_id, + "signature": "827edc3452044f0bc86bdd5684afb7d1e6becfa1a767f24df1b287853cf73000", + "deauthorization_time": "2019-06-17T13:52:28.632Z", + "client_id": "ADZ9k9bTWmGUoUbECUKU_a" + } + } + + zoom_signature = 'v0=cc6857f5b05fea4fb0f2057912c14a68996cfcf36a4267c65f15a3e9f1602477' + zoom_timestamp = "2019-06-17T13:52:28.632Z" + request_headers = { + 'x-zm-signature': zoom_signature, + 'x-zm-request-timestamp': zoom_timestamp + } + + fake_secret = 'cake' + os.environ['ZOOM_API_SECRET'] = fake_secret + + subscriber = make_pro_subscriber() + external_connection = make_external_connections( + subscriber_id=subscriber.id, + type=models.ExternalConnectionType.zoom.value, + type_id=zoom_user_id + ) + + return request_body, request_headers, subscriber, external_connection + + def test_deauthorization(self, with_client, with_db, setup_deauthorization): + """Test a successful deauthorization (i.e. deleting the zoom connection)""" + request_body, request_headers, subscriber, external_connection = setup_deauthorization + with with_db() as db: + assert subscriber + assert external_connection + + db.add(subscriber) + db.add(external_connection) + + zoom_user_id = external_connection.type_id + + response = with_client.post( + '/webhooks/zoom-deauthorization', + json=request_body, + headers=request_headers + ) + assert response.status_code == 200, response.text + + db.refresh(subscriber) + external_connection = repo.external_connection.get_by_type( + db, + subscriber.id, + type=ExternalConnectionType.zoom, + type_id=zoom_user_id + ) + + assert subscriber + assert not external_connection + + def test_deauthorization_silent_fail_due_to_no_connection(self, with_client, with_db, setup_deauthorization): + """Test that a missing zoom connection doesn't crash the webhook""" + request_body, request_headers, subscriber, external_connection = setup_deauthorization + + with with_db() as db: + assert subscriber + assert external_connection + + db.add(subscriber) + db.add(external_connection) + + # Remove our external connection + db.delete(external_connection) + db.commit() + + response = with_client.post( + '/webhooks/zoom-deauthorization', + json=request_body, + headers=request_headers + ) + assert response.status_code == 200, response.text + + def test_deauthorization_silent_fail_due_to_no_user(self, with_client, with_db, setup_deauthorization): + """Test that a missing subscriber doesn't crash the webhook""" + request_body, request_headers, subscriber, external_connection = setup_deauthorization + + with with_db() as db: + assert subscriber + assert external_connection + + db.add(subscriber) + db.add(external_connection) + + # Remove our external connection AND subscriber + db.delete(external_connection) + db.delete(subscriber) + db.commit() + + response = with_client.post( + '/webhooks/zoom-deauthorization', + json=request_body, + headers=request_headers + ) + assert response.status_code == 200, response.text + + def test_deauthorization_with_invalid_webhook(self, with_client, with_db): + """Test that an invalid request doesn't crash the webhook""" + response = with_client.post( + '/webhooks/zoom-deauthorization', + json={ + 'event': 'im-a-fake-event-woo!' + }, + ) + assert response.status_code == 200, response.text + + def test_deauthorization_with_invalid_webhook_headers(self, with_client, with_db, setup_deauthorization): + """Test that a valid response body with invalid headers doesn't remove the connection""" + request_body, request_headers, subscriber, external_connection = setup_deauthorization + + with with_db() as db: + assert subscriber + assert external_connection + + db.add(subscriber) + db.add(external_connection) + + response = with_client.post( + '/webhooks/zoom-deauthorization', + json=request_body, + headers={ + 'x-zm-signature': 'bad-signature', + 'x-zm-signature-timestamp': 'bad-timestamp' + } + ) + assert response.status_code == 200, response.text + + # Ensure that our connection still exists + db.refresh(subscriber) + db.refresh(external_connection) + + assert subscriber + assert external_connection