294 lines
9.9 KiB
Python
294 lines
9.9 KiB
Python
"""Blender ID subclient endpoint.
|
|
|
|
Also contains functionality for other parts of Pillar to perform communication
|
|
with Blender ID.
|
|
"""
|
|
|
|
import datetime
|
|
import logging
|
|
from urllib.parse import urljoin
|
|
|
|
import requests
|
|
from bson import tz_util
|
|
from rauth import OAuth2Session
|
|
from flask import Blueprint, request, jsonify, session
|
|
from requests.adapters import HTTPAdapter
|
|
import urllib3.util.retry
|
|
|
|
from pillar import current_app
|
|
from pillar.auth import get_blender_id_oauth_token
|
|
from pillar.api.utils import authentication, utcnow
|
|
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.
|
|
"""
|
|
|
|
|
|
class Session(requests.Session):
|
|
"""Requests Session suitable for Blender ID communication."""
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
|
|
retries = urllib3.util.retry.Retry(
|
|
total=10,
|
|
backoff_factor=0.05,
|
|
)
|
|
http_adapter = requests.adapters.HTTPAdapter(max_retries=retries)
|
|
|
|
self.mount('https://', http_adapter)
|
|
self.mount('http://', http_adapter)
|
|
|
|
def authenticate(self):
|
|
"""Attach the current user's authentication token to the request."""
|
|
bid_token = get_blender_id_oauth_token()
|
|
if not bid_token:
|
|
raise TypeError('authenticate() requires current user to be logged in with Blender ID')
|
|
|
|
self.headers['Authorization'] = f'Bearer {bid_token}'
|
|
|
|
|
|
@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 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:
|
|
# If the subclient ID is set, the token belongs to another OAuth Client,
|
|
# in which case we do not set the client_id field.
|
|
payload['subclient_id'] = oauth_subclient_id
|
|
else:
|
|
# We only want to accept Blender Cloud tokens.
|
|
payload['client_id'] = current_app.config['OAUTH_CREDENTIALS']['blender-id']['id']
|
|
|
|
blender_id_endpoint = current_app.config['BLENDER_ID_ENDPOINT']
|
|
url = urljoin(blender_id_endpoint, 'u/validate_token')
|
|
log.debug('POSTing to %r', url)
|
|
|
|
# POST to Blender ID, handling errors as negative verification results.
|
|
s = Session()
|
|
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 = utcnow() + 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 = urljoin(current_app.config['BLENDER_ID_ENDPOINT'], 'api/user')
|
|
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 = urljoin(current_app.config['BLENDER_ID_ENDPOINT'], 'switch')
|
|
if next_url:
|
|
return '%s?next=%s' % (base_url, quote(next_url))
|
|
return base_url
|