This is a two-stage approach that happens when a new token is verified with Blender ID and stored in our local MongoDB: - Given the remote IP address of the HTTP request, compute and store the org roles in the token document. - Recompute the user's roles based on their own roles, regular org roles, and the roles stored in non-expired token documents. This happens once per hour, since that's how long we store tokens in our database.
273 lines
9.1 KiB
Python
273 lines
9.1 KiB
Python
"""Blender ID subclient endpoint.
|
|
|
|
Also contains functionality for other parts of Pillar to perform communication
|
|
with Blender ID.
|
|
"""
|
|
|
|
import datetime
|
|
import logging
|
|
|
|
import requests
|
|
from bson import tz_util
|
|
from rauth import OAuth2Session
|
|
from flask import Blueprint, request, jsonify, session
|
|
from requests.adapters import HTTPAdapter
|
|
|
|
from pillar import current_app
|
|
from pillar.api import service
|
|
from pillar.api.utils import authentication
|
|
from pillar.api.utils.authentication import find_user_in_db, upsert_user
|
|
|
|
blender_id = Blueprint('blender_id', __name__)
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
class LogoutUser(Exception):
|
|
"""Raised when Blender ID tells us the current user token is invalid.
|
|
|
|
This indicates the user should be immediately logged out.
|
|
"""
|
|
|
|
|
|
@blender_id.route('/store_scst', methods=['POST'])
|
|
def store_subclient_token():
|
|
"""Verifies & stores a user's subclient-specific token."""
|
|
|
|
user_id = request.form['user_id'] # User ID at BlenderID
|
|
subclient_id = request.form['subclient_id']
|
|
scst = request.form['token']
|
|
|
|
db_user, status = validate_create_user(user_id, scst, subclient_id)
|
|
|
|
if db_user is None:
|
|
log.warning('Unable to verify subclient token with Blender ID.')
|
|
return jsonify({'status': 'fail',
|
|
'error': 'BLENDER ID ERROR'}), 403
|
|
|
|
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:
|
|
log.debug('Unable to verify token with Blender ID.')
|
|
return None, None
|
|
|
|
# Blender ID can be queried without user ID, and will always include the
|
|
# correct user ID in its response.
|
|
log.debug('Obtained user info from Blender ID: %s', user_info)
|
|
|
|
# Store the user info in MongoDB.
|
|
db_user = find_user_in_db(user_info)
|
|
db_id, status = upsert_user(db_user)
|
|
|
|
# Store the token in MongoDB.
|
|
ip_based_roles = current_app.org_manager.roles_for_request()
|
|
authentication.store_token(db_id, token, token_expiry, oauth_subclient_id,
|
|
org_roles=ip_based_roles)
|
|
|
|
if current_app.org_manager is not None:
|
|
roles = current_app.org_manager.refresh_roles(db_id)
|
|
db_user['roles'] = list(roles)
|
|
|
|
return db_user, status
|
|
|
|
|
|
def validate_token(user_id, token, oauth_subclient_id):
|
|
"""Verifies a subclient token with Blender ID.
|
|
|
|
: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.
|
|
:rtype: dict
|
|
"""
|
|
|
|
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
|
|
|
|
# Validate against BlenderID.
|
|
log.debug('Validating subclient token for BlenderID user %r, subclient %r', user_id,
|
|
oauth_subclient_id)
|
|
payload = {'user_id': user_id,
|
|
'token': token}
|
|
if oauth_subclient_id:
|
|
payload['subclient_id'] = oauth_subclient_id
|
|
|
|
url = '{0}/u/validate_token'.format(blender_id_endpoint())
|
|
log.debug('POSTing to %r', url)
|
|
|
|
# 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))
|
|
|
|
# POST to Blender ID, handling errors as negative verification results.
|
|
try:
|
|
r = s.post(url, data=payload, timeout=5,
|
|
verify=current_app.config['TLS_CERT_FILE'])
|
|
except requests.exceptions.ConnectionError:
|
|
log.error('Connection error trying to POST to %s, handling as invalid token.', url)
|
|
return None, None
|
|
except requests.exceptions.ReadTimeout:
|
|
log.error('Read timeout trying to POST to %s, handling as invalid token.', url)
|
|
return None, None
|
|
except requests.exceptions.RequestException as ex:
|
|
log.error('Requests error "%s" trying to POST to %s, handling as invalid token.', ex, url)
|
|
return None, None
|
|
except IOError as ex:
|
|
log.error('Unknown I/O error "%s" trying to POST to %s, handling as invalid token.',
|
|
ex, url)
|
|
return None, None
|
|
|
|
if r.status_code != 200:
|
|
log.debug('Token %s invalid, HTTP status %i returned', token, r.status_code)
|
|
return None, None
|
|
|
|
resp = r.json()
|
|
if resp['status'] != 'success':
|
|
log.warning('Failed response from %s: %s', url, resp)
|
|
return None, None
|
|
|
|
expires = _compute_token_expiry(resp['token_expires'])
|
|
|
|
return resp['user'], expires
|
|
|
|
|
|
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.
|
|
"""
|
|
|
|
# requirement is called python-dateutil, so PyCharm doesn't find it.
|
|
# noinspection PyPackageRequirements
|
|
from dateutil import parser
|
|
|
|
blid_expiry = parser.parse(token_expires_string)
|
|
blid_expiry = blid_expiry.astimezone(tz_util.utc)
|
|
our_expiry = datetime.datetime.now(tz=tz_util.utc) + datetime.timedelta(hours=1)
|
|
|
|
return min(blid_expiry, our_expiry)
|
|
|
|
|
|
def get_user_blenderid(db_user: dict) -> str:
|
|
"""Returns the Blender ID user ID for this Pillar user.
|
|
|
|
Takes the string from 'auth.*.user_id' for the '*' where 'provider'
|
|
is 'blender-id'.
|
|
|
|
:returns the user ID, or the empty string when the user has none.
|
|
"""
|
|
|
|
bid_user_ids = [auth['user_id']
|
|
for auth in db_user['auth']
|
|
if auth['provider'] == 'blender-id']
|
|
try:
|
|
return bid_user_ids[0]
|
|
except IndexError:
|
|
return ''
|
|
|
|
|
|
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_has_subscription": true,
|
|
"cloud_subscriber": true,
|
|
"conference_speaker": true,
|
|
"network_member": true
|
|
}
|
|
}
|
|
|
|
:raises LogoutUser: when Blender ID tells us the current token is
|
|
invalid, and the user should be logged out.
|
|
"""
|
|
import httplib2 # used by the oauth2 package
|
|
|
|
my_log = log.getChild('fetch_blenderid_user')
|
|
|
|
bid_url = '%s/api/user' % blender_id_endpoint()
|
|
my_log.debug('Fetching user info from %s', bid_url)
|
|
|
|
credentials = current_app.config['OAUTH_CREDENTIALS']['blender-id']
|
|
oauth_token = session.get('blender_id_oauth_token')
|
|
if not oauth_token:
|
|
my_log.warning('no Blender ID oauth token found in user session')
|
|
return {}
|
|
|
|
assert isinstance(oauth_token, str), f'oauth token must be str, not {type(oauth_token)}'
|
|
|
|
oauth_session = OAuth2Session(
|
|
credentials['id'], credentials['secret'],
|
|
access_token=oauth_token)
|
|
|
|
try:
|
|
bid_resp = oauth_session.get(bid_url)
|
|
except httplib2.HttpLib2Error:
|
|
my_log.exception('Error getting %s from BlenderID', bid_url)
|
|
return {}
|
|
|
|
if bid_resp.status_code == 403:
|
|
my_log.warning('Error %i from BlenderID %s, logging out user', bid_resp.status_code, bid_url)
|
|
raise LogoutUser()
|
|
|
|
if bid_resp.status_code != 200:
|
|
my_log.warning('Error %i from BlenderID %s: %s', bid_resp.status_code, bid_url, bid_resp.text)
|
|
return {}
|
|
|
|
payload = bid_resp.json()
|
|
if not payload:
|
|
my_log.warning('Empty data returned from BlenderID %s', bid_url)
|
|
return {}
|
|
|
|
my_log.debug('BlenderID returned %s', payload)
|
|
return payload
|
|
|
|
|
|
def setup_app(app, url_prefix):
|
|
app.register_api_blueprint(blender_id, url_prefix=url_prefix)
|
|
|
|
|
|
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
|