diff --git a/cloud/__init__.py b/cloud/__init__.py index 13abe92..3f94a71 100644 --- a/cloud/__init__.py +++ b/cloud/__init__.py @@ -41,6 +41,7 @@ class CloudExtension(PillarExtension): return { 'EXTERNAL_SUBSCRIPTIONS_MANAGEMENT_SERVER': 'https://store.blender.org/api/', 'EXTERNAL_SUBSCRIPTIONS_TIMEOUT_SECS': 10, + 'BLENDER_ID_WEBHOOK_USER_CHANGED_SECRET': 'oos9wah1Zoa0Yau6ahThohleiChephoi', } def eve_settings(self): @@ -93,10 +94,11 @@ class CloudExtension(PillarExtension): """ from pillar.api.service import signal_user_changed_role - from . import routes + from . import routes, webhooks signal_user_changed_role.connect(self._user_changed_role) routes.setup_app(app) + app.register_api_blueprint(webhooks.blueprint, '/webhooks') def _user_changed_role(self, sender, user: dict): from pillar.api import service diff --git a/cloud/webhooks.py b/cloud/webhooks.py new file mode 100644 index 0000000..9341ca9 --- /dev/null +++ b/cloud/webhooks.py @@ -0,0 +1,97 @@ +"""Blender ID webhooks.""" + +import hashlib +import hmac +import json +import logging + +from flask_login import request +from flask import Blueprint +import werkzeug.exceptions as wz_exceptions + +from pillar import current_app +from pillar.api.blender_cloud import subscription +from pillar.auth import UserClass + +blueprint = Blueprint('cloud-webhooks', __name__) +log = logging.getLogger(__name__) +WEBHOOK_MAX_BODY_SIZE = 1024 * 10 # 10 kB is large enough for + + +def webhook_payload(hmac_secret: str) -> dict: + """Obtains the webhook payload from the request, verifying its HMAC. + + :returns the webhook payload as dictionary. + """ + # Check the content type + if request.content_type != 'application/json': + log.info('request from %s to %s had bad content type %s', + request.remote_addr, request, request.content_type) + raise wz_exceptions.BadRequest('Content type not supported') + + # Check the length of the body + if request.content_length > WEBHOOK_MAX_BODY_SIZE: + raise wz_exceptions.BadRequest('Request too large') + body = request.get_data() + if len(body) > request.content_length: + raise wz_exceptions.BadRequest('Larger body than Content-Length header') + + # Validate the request + mac = hmac.new(hmac_secret.encode(), body, hashlib.sha256) + req_hmac = request.headers.get('X-Webhook-HMAC', '') + our_hmac = mac.hexdigest() + if not hmac.compare_digest(req_hmac, our_hmac): + log.info('request from %s to %s had bad HMAC %r, expected %r', + request.remote_addr, request, req_hmac, our_hmac) + raise wz_exceptions.BadRequest('Bad HMAC') + + try: + return json.loads(body) + except json.JSONDecodeError as ex: + log.warning('request from %s to %s had bad JSON: %s', + request.remote_addr, request, ex) + raise wz_exceptions.BadRequest('Bad JSON') + + +@blueprint.route('/user-modified', methods=['POST']) +def user_modified(): + """Updates the local user based on the info from Blender ID.""" + my_log = log.getChild('user_modified') + my_log.debug('Received request from %s', request.remote_addr) + + hmac_secret = current_app.config['BLENDER_ID_WEBHOOK_USER_CHANGED_SECRET'] + payload = webhook_payload(hmac_secret) + + my_log.info('payload: %s', payload) + + # Update the user + users_coll = current_app.db('users') + db_user = users_coll.find_one({'email': payload['old_email']}) + if not db_user: + my_log.warning('Received update for unknown user %r', payload['old_email']) + return '', 204 + + # Use direct database updates to change the email and full name. + updates = {} + if payload['old_email'] != payload['email']: + my_log.info('User changed email from %s to %s', payload['old_email'], payload['email']) + updates['email'] = payload['email'] + + if payload['full_name'] != db_user['full_name']: + my_log.info('User changed full name from %s to %s', + payload['full_name'], db_user['full_name']) + updates['full_name'] = payload['full_name'] + + if updates: + update_res = users_coll.update_one({'_id': db_user['_id']}, + {'$set': updates}) + if update_res.matched_count != 1: + my_log.error('Unable to find user %s to update, even though ' + 'we found them by email address %s', + db_user['_id'], payload['old_email']) + + # Defer to Pillar to do the role updates. + local_user = UserClass.construct('', db_user) + subscription.do_update_subscription(local_user, payload) + + return '', 204 diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py new file mode 100644 index 0000000..716e930 --- /dev/null +++ b/tests/test_webhooks.py @@ -0,0 +1,168 @@ +import hashlib +import hmac +import json +from abstract_cloud_test import AbstractCloudTest + + +class UserModifiedTest(AbstractCloudTest): + def setUp(self, **kwargs): + super().setUp(**kwargs) + self.enter_app_context() + self.hmac_secret = b'1234 je moeder' + self.app.config['BLENDER_ID_WEBHOOK_USER_CHANGED_SECRET'] = self.hmac_secret.decode() + self.uid = self.create_user(24 * 'a', + roles={'subscriber'}, + email='old@email.address') + + def test_change_full_name(self): + payload = {'id': 1112333, + 'old_email': 'old@email.address', + 'full_name': 'ကြယ်ဆွတ်', + 'email': 'old@email.address', + 'roles': ['cloud_subscriber']} + as_json = json.dumps(payload).encode() + mac = hmac.new(self.hmac_secret, + as_json, hashlib.sha256) + self.post('/api/webhooks/user-modified', + data=as_json, + content_type='application/json', + headers={'X-Webhook-HMAC': mac.hexdigest()}, + expected_status=204) + + # Check the effect on the user + db_user = self.fetch_user_from_db(self.uid) + self.assertEqual('old@email.address', db_user['email']) + self.assertEqual('ကြယ်ဆွတ်', db_user['full_name']) + self.assertEqual(['subscriber'], db_user['roles']) + + def test_change_email(self): + payload = {'id': 1112333, + 'old_email': 'old@email.address', + 'full_name': 'ကြယ်ဆွတ်', + 'email': 'new.address+here-there@email.address', + 'roles': ['cloud_subscriber']} + as_json = json.dumps(payload).encode() + mac = hmac.new(self.hmac_secret, + as_json, hashlib.sha256) + self.post('/api/webhooks/user-modified', + data=as_json, + content_type='application/json', + headers={'X-Webhook-HMAC': mac.hexdigest()}, + expected_status=204) + + # Check the effect on the user + db_user = self.fetch_user_from_db(self.uid) + self.assertEqual('new.address+here-there@email.address', db_user['email']) + self.assertEqual('ကြယ်ဆွတ်', db_user['full_name']) + self.assertEqual(['subscriber'], db_user['roles']) + + def test_change_roles(self): + payload = {'id': 1112333, + 'old_email': 'old@email.address', + 'full_name': 'ကြယ်ဆွတ်', + 'email': 'old@email.address', + 'roles': ['cloud_demo']} + as_json = json.dumps(payload).encode() + mac = hmac.new(self.hmac_secret, + as_json, hashlib.sha256) + self.post('/api/webhooks/user-modified', + data=as_json, + content_type='application/json', + headers={'X-Webhook-HMAC': mac.hexdigest()}, + expected_status=204) + + # Check the effect on the user + db_user = self.fetch_user_from_db(self.uid) + self.assertEqual('old@email.address', db_user['email']) + self.assertEqual('ကြယ်ဆွတ်', db_user['full_name']) + self.assertEqual({'flamenco-user', 'attract-user', 'demo'}, set(db_user['roles'])) + + def test_bad_hmac(self): + payload = {'id': 1112333, + 'old_email': 'old@email.address', + 'full_name': 'ကြယ်ဆွတ်', + 'email': 'new@email.address', + 'roles': ['cloud_demo']} + as_json = json.dumps(payload).encode() + mac = hmac.new(self.hmac_secret, + as_json, hashlib.sha256) + self.post('/api/webhooks/user-modified', + data=as_json, + content_type='application/json', + headers={'X-Webhook-HMAC': mac.hexdigest()[:-2]}, + expected_status=400) + + # Check the effect on the user + db_user = self.fetch_user_from_db(self.uid) + self.assertEqual('old@email.address', db_user['email']) + self.assertEqual('คนรักของผัดไทย', db_user['full_name']) + self.assertEqual({'subscriber'}, set(db_user['roles'])) + + def test_no_hmac(self): + payload = {'id': 1112333, + 'old_email': 'old@email.address', + 'full_name': 'ကြယ်ဆွတ်', + 'email': 'new@email.address', + 'roles': ['cloud_demo']} + as_json = json.dumps(payload).encode() + self.post('/api/webhooks/user-modified', + data=as_json, + content_type='application/json', + expected_status=400) + + # Check the effect on the user + db_user = self.fetch_user_from_db(self.uid) + self.assertEqual('old@email.address', db_user['email']) + self.assertEqual('คนรักของผัดไทย', db_user['full_name']) + self.assertEqual({'subscriber'}, set(db_user['roles'])) + + def test_unknown_email(self): + payload = {'id': 1112333, + 'old_email': 'unknown@email.address', + 'full_name': 'ကြယ်ဆွတ်', + 'email': 'new@email.address', + 'roles': ['cloud_demo']} + as_json = json.dumps(payload).encode() + mac = hmac.new(self.hmac_secret, + as_json, hashlib.sha256) + self.post('/api/webhooks/user-modified', + data=as_json, + content_type='application/json', + headers={'X-Webhook-HMAC': mac.hexdigest()}, + expected_status=204) + + # Check the effect on the user + db_user = self.fetch_user_from_db(self.uid) + self.assertEqual('old@email.address', db_user['email']) + self.assertEqual('คนรักของผัดไทย', db_user['full_name']) + self.assertEqual({'subscriber'}, set(db_user['roles'])) + + def test_huge_request(self): + payload = b'a' * 1024 * 100 + mac = hmac.new(self.hmac_secret, payload, hashlib.sha256) + self.post('/api/webhooks/user-modified', + data=payload, + content_type='application/json', + headers={'X-Webhook-HMAC': mac.hexdigest()}, + expected_status=400) + + # Check the effect on the user + db_user = self.fetch_user_from_db(self.uid) + self.assertEqual('old@email.address', db_user['email']) + self.assertEqual('คนรักของผัดไทย', db_user['full_name']) + self.assertEqual({'subscriber'}, set(db_user['roles'])) + + def test_invalid_json(self): + payload = b'\x00' * 1024 * 5 + mac = hmac.new(self.hmac_secret, payload, hashlib.sha256) + self.post('/api/webhooks/user-modified', + data=payload, + content_type='application/json', + headers={'X-Webhook-HMAC': mac.hexdigest()}, + expected_status=400) + + # Check the effect on the user + db_user = self.fetch_user_from_db(self.uid) + self.assertEqual('old@email.address', db_user['email']) + self.assertEqual('คนรักของผัดไทย', db_user['full_name']) + self.assertEqual({'subscriber'}, set(db_user['roles']))