2016-04-13 15:33:54 +02:00
|
|
|
"""Blender ID subclient endpoint.
|
|
|
|
|
|
|
|
Also contains functionality for other parts of Pillar to perform communication
|
|
|
|
with Blender ID.
|
|
|
|
"""
|
2016-04-08 18:45:35 +02:00
|
|
|
|
|
|
|
import logging
|
|
|
|
|
2016-08-19 09:19:06 +02:00
|
|
|
import datetime
|
2016-04-08 18:45:35 +02:00
|
|
|
import requests
|
2016-08-19 09:19:06 +02:00
|
|
|
from bson import tz_util
|
|
|
|
from flask import Blueprint, request, current_app, jsonify
|
|
|
|
from pillar.api.utils import authentication, remove_private_keys
|
2016-06-15 10:09:46 +02:00
|
|
|
from requests.adapters import HTTPAdapter
|
2016-07-06 11:53:10 +02:00
|
|
|
from werkzeug import exceptions as wz_exceptions
|
2016-04-08 18:45:35 +02:00
|
|
|
|
|
|
|
blender_id = Blueprint('blender_id', __name__)
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
@blender_id.route('/store_scst', methods=['POST'])
|
|
|
|
def store_subclient_token():
|
|
|
|
"""Verifies & stores a user's subclient-specific token."""
|
|
|
|
|
2016-04-12 15:24:50 +02:00
|
|
|
user_id = request.form['user_id'] # User ID at BlenderID
|
2016-04-13 15:33:54 +02:00
|
|
|
subclient_id = request.form['subclient_id']
|
|
|
|
scst = request.form['token']
|
2016-04-08 18:45:35 +02:00
|
|
|
|
2016-04-13 15:33:54 +02:00
|
|
|
db_user, status = validate_create_user(user_id, scst, subclient_id)
|
2016-04-08 18:45:35 +02:00
|
|
|
|
2016-04-13 15:33:54 +02:00
|
|
|
if db_user is None:
|
2016-04-08 18:45:35 +02:00
|
|
|
log.warning('Unable to verify subclient token with Blender ID.')
|
2016-04-12 15:24:50 +02:00
|
|
|
return jsonify({'status': 'fail',
|
|
|
|
'error': 'BLENDER ID ERROR'}), 403
|
2016-04-08 18:45:35 +02:00
|
|
|
|
2016-04-13 15:33:54 +02:00
|
|
|
return jsonify({'status': 'success',
|
|
|
|
'subclient_user_id': str(db_user['_id'])}), status
|
|
|
|
|
|
|
|
|
|
|
|
def blender_id_endpoint():
|
|
|
|
"""Gets the endpoint for the authentication API. If the env variable
|
|
|
|
is defined, it's possible to override the (default) production address.
|
|
|
|
"""
|
|
|
|
return current_app.config['BLENDER_ID_ENDPOINT'].rstrip('/')
|
|
|
|
|
|
|
|
|
|
|
|
def validate_create_user(blender_id_user_id, token, oauth_subclient_id):
|
|
|
|
"""Validates a user against Blender ID, creating the user in our database.
|
|
|
|
|
|
|
|
:param blender_id_user_id: the user ID at the BlenderID server.
|
|
|
|
:param token: the OAuth access token.
|
|
|
|
:param oauth_subclient_id: the subclient ID, or empty string if not a subclient.
|
|
|
|
:returns: (user in MongoDB, HTTP status 200 or 201)
|
|
|
|
"""
|
|
|
|
|
|
|
|
# Verify with Blender ID
|
|
|
|
log.debug('Storing token for BlenderID user %s', blender_id_user_id)
|
|
|
|
user_info, token_expiry = validate_token(blender_id_user_id, token, oauth_subclient_id)
|
|
|
|
|
|
|
|
if user_info is None:
|
2016-05-09 11:28:12 +02:00
|
|
|
log.debug('Unable to verify token with Blender ID.')
|
2016-04-13 15:33:54 +02:00
|
|
|
return None, None
|
|
|
|
|
|
|
|
# Blender ID can be queried without user ID, and will always include the
|
|
|
|
# correct user ID in its response.
|
2016-05-03 15:19:29 +02:00
|
|
|
log.debug('Obtained user info from Blender ID: %s', user_info)
|
2016-04-15 12:33:26 +02:00
|
|
|
blender_id_user_id = user_info['id']
|
2016-04-13 15:33:54 +02:00
|
|
|
|
|
|
|
# Store the user info in MongoDB.
|
|
|
|
db_user = find_user_in_db(blender_id_user_id, user_info)
|
2016-07-06 11:53:10 +02:00
|
|
|
db_id, status = upsert_user(db_user, blender_id_user_id)
|
|
|
|
|
|
|
|
# Store the token in MongoDB.
|
|
|
|
authentication.store_token(db_id, token, token_expiry, oauth_subclient_id)
|
|
|
|
|
|
|
|
return db_user, status
|
|
|
|
|
|
|
|
|
|
|
|
def upsert_user(db_user, blender_id_user_id):
|
|
|
|
"""Inserts/updates the user in MongoDB.
|
|
|
|
|
|
|
|
Retries a few times when there are uniqueness issues in the username.
|
|
|
|
|
|
|
|
:returns: the user's database ID and the status of the PUT/POST.
|
|
|
|
The status is 201 on insert, and 200 on update.
|
|
|
|
:type: (ObjectId, int)
|
|
|
|
"""
|
2016-04-08 18:45:35 +02:00
|
|
|
|
2017-03-03 12:00:24 +01:00
|
|
|
if 'subscriber' in db_user.get('groups', []):
|
2016-07-27 12:51:17 +02:00
|
|
|
log.error('Non-ObjectID string found in user.groups: %s', db_user)
|
2017-05-04 17:49:18 +02:00
|
|
|
raise wz_exceptions.InternalServerError(
|
|
|
|
'Non-ObjectID string found in user.groups: %s' % db_user)
|
2016-07-27 12:51:17 +02:00
|
|
|
|
2016-06-01 10:33:01 +02:00
|
|
|
r = {}
|
|
|
|
for retry in range(5):
|
|
|
|
if '_id' in db_user:
|
|
|
|
# Update the existing user
|
|
|
|
attempted_eve_method = 'PUT'
|
|
|
|
db_id = db_user['_id']
|
2016-08-19 09:19:06 +02:00
|
|
|
r, _, _, status = current_app.put_internal('users', remove_private_keys(db_user),
|
|
|
|
_id=db_id)
|
2016-05-30 15:42:11 +02:00
|
|
|
if status == 422:
|
2016-06-01 10:33:01 +02:00
|
|
|
log.error('Status %i trying to PUT user %s with values %s, should not happen! %s',
|
|
|
|
status, db_id, remove_private_keys(db_user), r)
|
2016-05-30 15:42:11 +02:00
|
|
|
else:
|
2016-06-01 10:33:01 +02:00
|
|
|
# Create a new user, retry for non-unique usernames.
|
|
|
|
attempted_eve_method = 'POST'
|
2016-08-19 09:19:06 +02:00
|
|
|
r, _, _, status = current_app.post_internal('users', db_user)
|
2016-05-30 15:42:11 +02:00
|
|
|
|
2016-07-04 13:19:02 +02:00
|
|
|
if status not in {200, 201}:
|
|
|
|
log.error('Status %i trying to create user for BlenderID %s with values %s: %s',
|
|
|
|
status, blender_id_user_id, db_user, r)
|
2016-07-06 11:53:10 +02:00
|
|
|
raise wz_exceptions.InternalServerError()
|
2016-07-04 13:19:02 +02:00
|
|
|
|
2016-06-01 10:33:01 +02:00
|
|
|
db_id = r['_id']
|
|
|
|
db_user.update(r) # update with database/eve-generated fields.
|
|
|
|
|
|
|
|
if status == 422:
|
|
|
|
# Probably non-unique username, so retry a few times with different usernames.
|
|
|
|
log.info('Error creating new user: %s', r)
|
2017-03-03 12:00:24 +01:00
|
|
|
username_issue = r.get('_issues', {}).get('username', '')
|
|
|
|
if 'not unique' in username_issue:
|
2016-06-01 10:33:01 +02:00
|
|
|
# Retry
|
|
|
|
db_user['username'] = authentication.make_unique_username(db_user['email'])
|
|
|
|
continue
|
|
|
|
|
|
|
|
# Saving was successful, or at least didn't break on a non-unique username.
|
|
|
|
break
|
|
|
|
else:
|
|
|
|
log.error('Unable to create new user %s: %s', db_user, r)
|
2016-07-06 11:53:10 +02:00
|
|
|
raise wz_exceptions.InternalServerError()
|
2016-04-12 15:24:50 +02:00
|
|
|
|
|
|
|
if status not in (200, 201):
|
2016-05-31 14:14:33 +02:00
|
|
|
log.error('internal response from %s to Eve: %r %r', attempted_eve_method, status, r)
|
2016-07-06 11:53:10 +02:00
|
|
|
raise wz_exceptions.InternalServerError()
|
2016-04-08 18:45:35 +02:00
|
|
|
|
2016-07-06 11:53:10 +02:00
|
|
|
return db_id, status
|
2016-04-08 18:45:35 +02:00
|
|
|
|
2016-04-13 15:33:54 +02:00
|
|
|
|
|
|
|
def validate_token(user_id, token, oauth_subclient_id):
|
2016-04-08 18:45:35 +02:00
|
|
|
"""Verifies a subclient token with Blender ID.
|
|
|
|
|
2016-04-13 15:33:54 +02:00
|
|
|
:returns: (user info, token expiry) on success, or (None, None) on failure.
|
|
|
|
The user information from Blender ID is returned as dict
|
|
|
|
{'email': 'a@b', 'full_name': 'AB'}, token expiry as a datime.datetime.
|
2016-04-08 18:45:35 +02:00
|
|
|
:rtype: dict
|
|
|
|
"""
|
|
|
|
|
2016-04-13 15:33:54 +02:00
|
|
|
our_subclient_id = current_app.config['BLENDER_ID_SUBCLIENT_ID']
|
|
|
|
|
|
|
|
# Check that IF there is a subclient ID given, it is the correct one.
|
|
|
|
if oauth_subclient_id and our_subclient_id != oauth_subclient_id:
|
|
|
|
log.warning('validate_token(): BlenderID user %s is trying to use the wrong subclient '
|
|
|
|
'ID %r; treating as invalid login.', user_id, oauth_subclient_id)
|
|
|
|
return None, None
|
2016-04-08 18:45:35 +02:00
|
|
|
|
2016-04-13 15:33:54 +02:00
|
|
|
# Validate against BlenderID.
|
2016-05-04 14:06:15 +02:00
|
|
|
log.debug('Validating subclient token for BlenderID user %r, subclient %r', user_id,
|
|
|
|
oauth_subclient_id)
|
2016-04-13 15:33:54 +02:00
|
|
|
payload = {'user_id': user_id,
|
|
|
|
'token': token}
|
|
|
|
if oauth_subclient_id:
|
|
|
|
payload['subclient_id'] = oauth_subclient_id
|
|
|
|
|
2016-04-15 12:19:43 +02:00
|
|
|
url = '{0}/u/validate_token'.format(blender_id_endpoint())
|
2016-04-08 18:45:35 +02:00
|
|
|
log.debug('POSTing to %r', url)
|
|
|
|
|
2016-06-15 10:09:46 +02:00
|
|
|
# Retry a few times when POSTing to BlenderID fails.
|
|
|
|
# Source: http://stackoverflow.com/a/15431343/875379
|
|
|
|
s = requests.Session()
|
|
|
|
s.mount(blender_id_endpoint(), HTTPAdapter(max_retries=5))
|
|
|
|
|
2016-04-08 18:45:35 +02:00
|
|
|
# POST to Blender ID, handling errors as negative verification results.
|
|
|
|
try:
|
2016-08-05 16:37:33 +02:00
|
|
|
r = s.post(url, data=payload, timeout=5,
|
|
|
|
verify=current_app.config['TLS_CERT_FILE'])
|
2016-04-08 18:45:35 +02:00
|
|
|
except requests.exceptions.ConnectionError as e:
|
|
|
|
log.error('Connection error trying to POST to %s, handling as invalid token.', url)
|
2016-04-13 15:33:54 +02:00
|
|
|
return None, None
|
2016-04-08 18:45:35 +02:00
|
|
|
|
|
|
|
if r.status_code != 200:
|
2016-05-09 11:28:12 +02:00
|
|
|
log.debug('Token %s invalid, HTTP status %i returned', token, r.status_code)
|
2016-04-13 15:33:54 +02:00
|
|
|
return None, None
|
2016-04-08 18:45:35 +02:00
|
|
|
|
|
|
|
resp = r.json()
|
|
|
|
if resp['status'] != 'success':
|
|
|
|
log.warning('Failed response from %s: %s', url, resp)
|
2016-04-13 15:33:54 +02:00
|
|
|
return None, None
|
|
|
|
|
|
|
|
expires = _compute_token_expiry(resp['token_expires'])
|
2016-04-08 18:45:35 +02:00
|
|
|
|
2016-04-13 15:33:54 +02:00
|
|
|
return resp['user'], expires
|
2016-04-08 18:45:35 +02:00
|
|
|
|
|
|
|
|
2016-04-13 15:33:54 +02:00
|
|
|
def _compute_token_expiry(token_expires_string):
|
|
|
|
"""Computes token expiry based on current time and BlenderID expiry.
|
|
|
|
|
|
|
|
Expires our side of the token when either the BlenderID token expires,
|
|
|
|
or in one hour. The latter case is to ensure we periodically verify
|
|
|
|
the token.
|
|
|
|
"""
|
|
|
|
|
|
|
|
date_format = current_app.config['RFC1123_DATE_FORMAT']
|
|
|
|
blid_expiry = datetime.datetime.strptime(token_expires_string, date_format)
|
|
|
|
blid_expiry = blid_expiry.replace(tzinfo=tz_util.utc)
|
|
|
|
our_expiry = datetime.datetime.now(tz=tz_util.utc) + datetime.timedelta(hours=1)
|
|
|
|
|
|
|
|
return min(blid_expiry, our_expiry)
|
|
|
|
|
|
|
|
|
|
|
|
def find_user_in_db(blender_id_user_id, user_info):
|
|
|
|
"""Find the user in our database, creating/updating the returned document where needed.
|
|
|
|
|
|
|
|
Does NOT update the user in the database.
|
|
|
|
"""
|
2016-04-12 16:53:27 +02:00
|
|
|
|
2016-04-08 18:45:35 +02:00
|
|
|
users = current_app.data.driver.db['users']
|
|
|
|
|
2016-04-13 15:33:54 +02:00
|
|
|
query = {'auth': {'$elemMatch': {'user_id': str(blender_id_user_id),
|
|
|
|
'provider': 'blender-id'}}}
|
2016-04-08 18:45:35 +02:00
|
|
|
log.debug('Querying: %s', query)
|
|
|
|
db_user = users.find_one(query)
|
|
|
|
|
|
|
|
if db_user:
|
2016-04-13 15:33:54 +02:00
|
|
|
log.debug('User blender_id_user_id=%r already in our database, '
|
|
|
|
'updating with info from Blender ID.', blender_id_user_id)
|
|
|
|
db_user['email'] = user_info['email']
|
|
|
|
else:
|
|
|
|
log.debug('User %r not yet in our database, create a new one.', blender_id_user_id)
|
2016-07-05 12:36:32 +02:00
|
|
|
db_user = authentication.create_new_user_document(
|
|
|
|
email=user_info['email'],
|
|
|
|
user_id=blender_id_user_id,
|
|
|
|
username=user_info['full_name'])
|
2016-04-13 15:33:54 +02:00
|
|
|
db_user['username'] = authentication.make_unique_username(user_info['email'])
|
2016-07-05 12:36:32 +02:00
|
|
|
if not db_user['full_name']:
|
|
|
|
db_user['full_name'] = db_user['username']
|
2016-04-08 18:45:35 +02:00
|
|
|
|
|
|
|
return db_user
|
2016-08-19 09:19:06 +02:00
|
|
|
|
|
|
|
|
2017-05-04 17:49:18 +02:00
|
|
|
def fetch_blenderid_user() -> dict:
|
|
|
|
"""Returns the user info of the currently logged in user from BlenderID.
|
|
|
|
|
|
|
|
Returns an empty dict if communication fails.
|
|
|
|
|
|
|
|
Example dict:
|
|
|
|
{
|
|
|
|
"email": "some@email.example.com",
|
|
|
|
"full_name": "dr. Sybren A. St\u00fcvel",
|
|
|
|
"id": 5555,
|
|
|
|
"roles": {
|
|
|
|
"admin": true,
|
|
|
|
"bfct_trainer": false,
|
|
|
|
"cloud_single_member": true,
|
|
|
|
"conference_speaker": true,
|
|
|
|
"network_member": true
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
import httplib2 # used by the oauth2 package
|
|
|
|
|
2017-05-04 18:29:11 +02:00
|
|
|
bid_url = '%s/api/user' % blender_id_endpoint()
|
2017-05-04 17:49:18 +02:00
|
|
|
log.debug('Fetching user info from %s', bid_url)
|
|
|
|
|
|
|
|
try:
|
|
|
|
bid_resp = current_app.oauth_blender_id.get(bid_url)
|
|
|
|
except httplib2.HttpLib2Error:
|
|
|
|
log.exception('Error getting %s from BlenderID', bid_url)
|
|
|
|
return {}
|
|
|
|
|
|
|
|
if bid_resp.status != 200:
|
|
|
|
log.warning('Error %i from BlenderID %s: %s', bid_resp.status, bid_url, bid_resp.data)
|
|
|
|
return {}
|
|
|
|
|
|
|
|
if not bid_resp.data:
|
|
|
|
log.warning('Empty data returned from BlenderID %s', bid_url)
|
|
|
|
return {}
|
|
|
|
|
|
|
|
log.debug('BlenderID returned %s', bid_resp.data)
|
|
|
|
return bid_resp.data
|
|
|
|
|
|
|
|
|
2016-08-19 09:19:06 +02:00
|
|
|
def setup_app(app, url_prefix):
|
|
|
|
app.register_api_blueprint(blender_id, url_prefix=url_prefix)
|
2017-05-05 12:56:19 +02:00
|
|
|
|
|
|
|
|
|
|
|
def switch_user_url(next_url: str) -> str:
|
|
|
|
from urllib.parse import quote
|
|
|
|
|
|
|
|
base_url = '%s/switch' % blender_id_endpoint()
|
|
|
|
if next_url:
|
|
|
|
return '%s?next=%s' % (base_url, quote(next_url))
|
|
|
|
return base_url
|