Webhooks: smarter way to find the user the webhook applies to.

We now also match on Blender ID (through the 'auth' section of the user
document), so that even when we have missed an email change we can still
apply the changes from the webhook.
This commit is contained in:
2017-12-20 12:22:51 +01:00
parent 16e378b7ad
commit a2c24375e5
2 changed files with 132 additions and 7 deletions

View File

@@ -1,9 +1,11 @@
"""Blender ID webhooks."""
import functools
import hashlib
import hmac
import json
import logging
import typing
from flask_login import request
from flask import Blueprint
@@ -53,6 +55,66 @@ def webhook_payload(hmac_secret: str) -> dict:
raise wz_exceptions.BadRequest('Bad JSON')
def score(wh_payload: dict, user: dict) -> int:
"""Determine how likely it is that this is the correct user to modify.
:param wh_payload: the info we received from Blender ID;
see user_modified()
:param user: the user in our database
:return: the score for this user
"""
bid_str = str(wh_payload['id'])
try:
match_on_bid = any((auth['provider'] == 'blender-id' and auth['user_id'] == bid_str)
for auth in user['auth'])
except KeyError:
match_on_bid = False
match_on_old_email = user.get('email', 'none') == wh_payload.get('old_email', 'nothere')
match_on_new_email = user.get('email', 'none') == wh_payload.get('email', 'nothere')
return match_on_bid * 10 + match_on_old_email + match_on_new_email * 2
def fetch_user(wh_payload: dict) -> typing.Optional[dict]:
"""Fetch the user from the DB
:returns the user document, or None when not found.
"""
users_coll = current_app.db('users')
my_log = log.getChild('insert_or_fetch_user')
# Find the user by their Blender ID, or any of their email addresses.
# We use one query to find all matching users. This is done as a
# consistency check; if more than one user is returned, we know the
# database is inconsistent with Blender ID and can emit a warning
# about this.
bid_str = str(wh_payload['id'])
query = {'$or': [
{'auth.provider': 'blender-id', 'auth.user_id': bid_str},
{'email': {'$in': [wh_payload['old_email'], wh_payload['email']]}},
]}
db_users = users_coll.find(query)
user_count = db_users.count()
if user_count > 1:
# Now we have to pay the price for finding users in one query; we
# have to prioritise them and return the one we think is most reliable.
calc_score = functools.partial(score, wh_payload)
best_score = max(db_users, key=calc_score)
my_log.warning('%d users found for query %s, picking %s',
user_count, query, best_score['email'])
return best_score
if user_count:
db_user = db_users[0]
my_log.debug('found user %s', db_user['email'])
return db_user
my_log.info('Received update for unknown user %r', wh_payload['old_email'])
return None
@blueprint.route('/user-modified', methods=['POST'])
def user_modified():
"""Updates the local user based on the info from Blender ID.
@@ -73,17 +135,14 @@ def user_modified():
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']})
db_user = fetch_user(payload)
if not db_user:
db_user = users_coll.find_one({'email': payload['email']})
if not db_user:
my_log.info('Received update for unknown user %r', payload['old_email'])
return '', 204
my_log.info('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']:
if db_user['email'] != payload['email']:
my_log.info('User changed email from %s to %s', payload['old_email'], payload['email'])
updates['email'] = payload['email']
@@ -97,6 +156,7 @@ def user_modified():
updates['full_name'] = db_user['username']
if updates:
users_coll = current_app.db('users')
update_res = users_coll.update_one({'_id': db_user['_id']},
{'$set': updates})
if update_res.matched_count != 1:

View File

@@ -100,6 +100,34 @@ class UserModifiedTest(AbstractCloudTest):
self.assertEqual('ကြယ်ဆွတ်', db_user['full_name'])
self.assertEqual(['demo'], db_user['roles'])
def test_change_email_unknown_bid_known(self):
users_coll = self.app.db('users')
users_coll.update_one({'_id': self.uid},
{'$set': {'auth': [
{'provider': 'mastodon', 'user_id': 'hey@there'},
{'provider': 'blender-id', 'user_id': '1112333'}
]}})
payload = {'id': 1112333,
'old_email': 'new@elsewhere.address',
'full_name': 'ကြယ်ဆွတ်',
'email': 'new@elsewhere.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('new@elsewhere.address', db_user['email'])
self.assertEqual('ကြယ်ဆွတ်', db_user['full_name'])
self.assertEqual(['demo'], db_user['roles'])
def test_change_roles(self):
payload = {'id': 1112333,
'old_email': 'old@email.address',
@@ -189,3 +217,40 @@ class UserModifiedTest(AbstractCloudTest):
self.assertEqual('old@email.address', db_user['email'])
self.assertEqual('คนรักของผัดไทย', db_user['full_name'])
self.assertEqual({'subscriber'}, set(db_user['roles']))
class UserScoreTest(AbstractCloudTest):
def setUp(self, **kwargs):
super().setUp(**kwargs)
self.payload = {'id': 123,
'old_email': 'old@email.address',
'full_name': 'ကြယ်ဆွတ်',
'email': 'new@email.address',
'roles': ['cloud_demo']}
def test_score_bid_only(self):
from cloud.webhooks import score
self.assertEqual(10, score(self.payload,
{'auth': [
{'provider': 'mastodon', 'user_id': 'hey@there'},
{'provider': 'blender-id', 'user_id': '123'}
]}))
def test_score_old_mail_only(self):
from cloud.webhooks import score
self.assertEqual(1, score(self.payload, {'email': 'old@email.address'}))
def test_score_new_mail_only(self):
from cloud.webhooks import score
self.assertEqual(2, score(self.payload, {'email': 'new@email.address'}))
def test_match_everything(self):
from cloud.webhooks import score
self.payload['old_email'] = self.payload['email']
self.assertEqual(13, score(self.payload,
{'auth': [
{'provider': 'mastodon', 'user_id': 'hey@there'},
{'provider': 'blender-id', 'user_id': '123'}
],
'email': 'new@email.address'
}))