Unify tokens and subclient tokens
SCST tokens are now stored in the 'tokens' table. This unifies old token handling and new subclient-specific tokens. Also ensures the BlenderID expiry of the token is taken into account. Removes use of httpretty, in favour of responses.
This commit is contained in:
@@ -1,8 +1,13 @@
|
||||
"""Blender ID subclient endpoint."""
|
||||
"""Blender ID subclient endpoint.
|
||||
|
||||
Also contains functionality for other parts of Pillar to perform communication
|
||||
with Blender ID.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pprint import pformat
|
||||
import datetime
|
||||
|
||||
from bson import tz_util
|
||||
import requests
|
||||
from flask import Blueprint, request, current_app, abort, jsonify
|
||||
from eve.methods.post import post_internal
|
||||
@@ -19,20 +24,51 @@ def store_subclient_token():
|
||||
"""Verifies & stores a user's subclient-specific token."""
|
||||
|
||||
user_id = request.form['user_id'] # User ID at BlenderID
|
||||
scst = request.form['scst']
|
||||
subclient_id = request.form['subclient_id']
|
||||
scst = request.form['token']
|
||||
|
||||
# Verify with Blender ID
|
||||
log.debug('Storing SCST for BlenderID user %s', user_id)
|
||||
user_info = validate_subclient_token(user_id, scst)
|
||||
db_user, status = validate_create_user(user_id, scst, subclient_id)
|
||||
|
||||
if user_info is None:
|
||||
if db_user is None:
|
||||
log.warning('Unable to verify subclient token with Blender ID.')
|
||||
return jsonify({'status': 'fail',
|
||||
'error': 'BLENDER ID ERROR'}), 403
|
||||
|
||||
# Store the user info in MongoDB.
|
||||
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.warning('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.info('Obtained user info from Blender ID: %s', user_info)
|
||||
db_user = find_user_in_db(user_id, scst, **user_info)
|
||||
blender_id_user_id = user_info['user_id']
|
||||
|
||||
# Store the user info in MongoDB.
|
||||
db_user = find_user_in_db(blender_id_user_id, user_info)
|
||||
|
||||
if '_id' in db_user:
|
||||
# Update the existing user
|
||||
@@ -42,32 +78,43 @@ def store_subclient_token():
|
||||
# Create a new user
|
||||
r, _, _, status = post_internal('users', db_user)
|
||||
db_id = r['_id']
|
||||
db_user.update(r) # update with database/eve-generated fields.
|
||||
|
||||
if status not in (200, 201):
|
||||
log.error('internal response: %r %r', status, r)
|
||||
return abort(500)
|
||||
|
||||
return jsonify({'status': 'success',
|
||||
'subclient_user_id': str(db_id)}), status
|
||||
# Store the token in MongoDB.
|
||||
authentication.store_token(db_id, token, token_expiry)
|
||||
|
||||
return db_user, status
|
||||
|
||||
|
||||
def validate_subclient_token(user_id, scst):
|
||||
def validate_token(user_id, token, oauth_subclient_id):
|
||||
"""Verifies a subclient token with Blender ID.
|
||||
|
||||
:returns: the user information from Blender ID on success, in a dict
|
||||
{'email': 'a@b', 'full_name': 'AB'}, or None on failure.
|
||||
: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
|
||||
"""
|
||||
|
||||
client_id = current_app.config['BLENDER_ID_CLIENT_ID']
|
||||
subclient_id = current_app.config['BLENDER_ID_SUBCLIENT_ID']
|
||||
our_subclient_id = current_app.config['BLENDER_ID_SUBCLIENT_ID']
|
||||
|
||||
log.debug('Validating subclient token for Blender ID user %s', user_id)
|
||||
payload = {'client_id': client_id,
|
||||
'subclient_id': subclient_id,
|
||||
'user_id': user_id,
|
||||
'scst': scst}
|
||||
url = '{0}/subclients/validate_token'.format(authentication.blender_id_endpoint())
|
||||
# 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 %s', user_id)
|
||||
payload = {'user_id': user_id,
|
||||
'token': token}
|
||||
if oauth_subclient_id:
|
||||
payload['subclient_id'] = oauth_subclient_id
|
||||
|
||||
url = '{0}/subclients/validate_token'.format(blender_id_endpoint())
|
||||
log.debug('POSTing to %r', url)
|
||||
|
||||
# POST to Blender ID, handling errors as negative verification results.
|
||||
@@ -75,40 +122,60 @@ def validate_subclient_token(user_id, scst):
|
||||
r = requests.post(url, data=payload)
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
log.error('Connection error trying to POST to %s, handling as invalid token.', url)
|
||||
return None
|
||||
return None, None
|
||||
|
||||
if r.status_code != 200:
|
||||
log.info('Token invalid, HTTP status %i returned', r.status_code)
|
||||
return None
|
||||
return None, None
|
||||
|
||||
resp = r.json()
|
||||
if resp['status'] != 'success':
|
||||
log.warning('Failed response from %s: %s', url, resp)
|
||||
return None
|
||||
return None, None
|
||||
|
||||
return resp['user']
|
||||
expires = _compute_token_expiry(resp['token_expires'])
|
||||
|
||||
return resp['user'], expires
|
||||
|
||||
|
||||
def find_user_in_db(user_id, scst, email, full_name):
|
||||
"""Find the user in our database, creating/updating it where needed."""
|
||||
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.
|
||||
"""
|
||||
|
||||
users = current_app.data.driver.db['users']
|
||||
|
||||
query = {'auth': {'$elemMatch': {'user_id': user_id, 'provider': 'blender-id'}}}
|
||||
query = {'auth': {'$elemMatch': {'user_id': str(blender_id_user_id),
|
||||
'provider': 'blender-id'}}}
|
||||
log.debug('Querying: %s', query)
|
||||
db_user = users.find_one(query)
|
||||
|
||||
# TODO: include token expiry in database.
|
||||
if db_user:
|
||||
log.debug('User %r already in our database, updating with info from Blender ID.', user_id)
|
||||
db_user['full_name'] = full_name
|
||||
db_user['email'] = email
|
||||
log.debug('User blender_id_user_id=%r already in our database, '
|
||||
'updating with info from Blender ID.', blender_id_user_id)
|
||||
db_user['full_name'] = user_info['full_name']
|
||||
db_user['email'] = user_info['email']
|
||||
else:
|
||||
log.debug('User %r not yet in our database, create a new one.', blender_id_user_id)
|
||||
db_user = authentication.create_new_user_document(user_info['email'], blender_id_user_id,
|
||||
user_info['full_name'])
|
||||
db_user['username'] = authentication.make_unique_username(user_info['email'])
|
||||
|
||||
auth = next(auth for auth in db_user['auth'] if auth['provider'] == 'blender-id')
|
||||
auth['token'] = scst
|
||||
return db_user
|
||||
|
||||
log.debug('User %r not yet in our database, create a new one.', user_id)
|
||||
db_user = authentication.create_new_user_document(email, user_id, full_name, token=scst)
|
||||
db_user['username'] = authentication.make_unique_username(email)
|
||||
return db_user
|
||||
|
@@ -1,9 +1,14 @@
|
||||
"""Generic authentication.
|
||||
|
||||
Contains functionality to validate tokens, create users and tokens, and make
|
||||
unique usernames from emails. Calls out to the application.modules.blender_id
|
||||
module for Blender ID communication.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import requests
|
||||
|
||||
from bson import tz_util
|
||||
from datetime import datetime
|
||||
from datetime import timedelta
|
||||
from flask import g
|
||||
from flask import request
|
||||
from eve.methods.post import post_internal
|
||||
@@ -13,41 +18,6 @@ from application import app
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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 app.config['BLENDER_ID_ENDPOINT'].rstrip('/')
|
||||
|
||||
|
||||
def validate(token):
|
||||
"""Validate a token against the Blender ID server. This simple lookup
|
||||
returns a dictionary with the following keys:
|
||||
|
||||
- message: a success message
|
||||
- valid: a boolean, stating if the token is valid
|
||||
- user: a dictionary with information regarding the user
|
||||
"""
|
||||
|
||||
log.debug("Validating token %s", token)
|
||||
payload = dict(
|
||||
token=token)
|
||||
url = "{0}/u/validate_token".format(blender_id_endpoint())
|
||||
|
||||
try:
|
||||
log.debug('POSTing to %r', url)
|
||||
r = requests.post(url, data=payload)
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
log.error('Connection error trying to POST to %s, handling as invalid token.', url)
|
||||
return None
|
||||
|
||||
if r.status_code != 200:
|
||||
log.info('HTTP error %i validating token: %s', r.status_code, r.content)
|
||||
return None
|
||||
|
||||
return r.json()
|
||||
|
||||
|
||||
def validate_token():
|
||||
"""Validate the token provided in the request and populate the current_user
|
||||
flask.g object, so that permissions and access to a resource can be defined
|
||||
@@ -70,79 +40,68 @@ def validate_token():
|
||||
|
||||
# Check the users to see if there is one with this Blender ID token.
|
||||
token = request.authorization.username
|
||||
db_user = find_user_by_token(token)
|
||||
if db_user is not None:
|
||||
log.debug(u'Token for %s found as locally stored blender-id subclient token.',
|
||||
db_user['full_name'])
|
||||
current_user = dict(
|
||||
user_id=db_user['_id'],
|
||||
token=token,
|
||||
groups=db_user['groups'],
|
||||
token_expire_time=datetime.now() + timedelta(hours=1) # TODO: get from Blender ID
|
||||
)
|
||||
g.current_user = current_user
|
||||
return True
|
||||
oauth_subclient = request.authorization.password
|
||||
|
||||
# Fall back to deprecated behaviour.
|
||||
log.debug('Token not found as locally stored blender-id subclient token; '
|
||||
'falling back on deprecated behaviour.')
|
||||
|
||||
tokens_collection = app.data.driver.db['tokens']
|
||||
|
||||
lookup = {'token': token, 'expire_time': {"$gt": datetime.now()}}
|
||||
db_token = tokens_collection.find_one(lookup)
|
||||
db_token = find_token(token)
|
||||
if not db_token:
|
||||
log.debug('Token %s not found in our local database.', token)
|
||||
|
||||
# If no valid token is found in our local database, we issue a new
|
||||
# request to the Blender ID server to verify the validity of the token
|
||||
# passed via the HTTP header. We will get basic user info if the user
|
||||
# passed via the HTTP header. We will get basic user info if the user
|
||||
# is authorized, and we will store the token in our local database.
|
||||
validation = validate(token)
|
||||
if validation is None or validation.get('status', '') != 'success':
|
||||
log.debug('Validation failed, result is %r', validation)
|
||||
return False
|
||||
from application.modules import blender_id
|
||||
|
||||
users = app.data.driver.db['users']
|
||||
email = validation['data']['user']['email']
|
||||
db_user = users.find_one({'email': email})
|
||||
username = make_unique_username(email)
|
||||
|
||||
if not db_user:
|
||||
# We don't even know this user; create it on the fly.
|
||||
log.debug('Validation success, creating new user in our database.')
|
||||
user_id = create_new_user(
|
||||
email, username, validation['data']['user']['id'])
|
||||
groups = None
|
||||
else:
|
||||
log.debug('Validation success, user is already in our database.')
|
||||
user_id = db_user['_id']
|
||||
groups = db_user['groups']
|
||||
|
||||
token_data = {
|
||||
'user': user_id,
|
||||
'token': token,
|
||||
'expire_time': datetime.now() + timedelta(hours=1)
|
||||
}
|
||||
post_internal('tokens', token_data)
|
||||
current_user = dict(
|
||||
user_id=user_id,
|
||||
token=token,
|
||||
groups=groups,
|
||||
token_expire_time=token_data['expire_time'])
|
||||
db_user, status = blender_id.validate_create_user('', token, oauth_subclient)
|
||||
else:
|
||||
log.debug("User is already in our database and token hasn't expired yet.")
|
||||
users = app.data.driver.db['users']
|
||||
db_user = users.find_one(db_token['user'])
|
||||
current_user = dict(
|
||||
user_id=db_token['user'],
|
||||
token=db_token['token'],
|
||||
groups=db_user['groups'],
|
||||
token_expire_time=db_token['expire_time'])
|
||||
|
||||
g.current_user = current_user
|
||||
if db_user is None:
|
||||
log.debug('Validation failed, user not logged in')
|
||||
return False
|
||||
|
||||
g.current_user = {'user_id': db_user['_id'],
|
||||
'groups': db_user['groups']}
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def find_token(token, **extra_filters):
|
||||
"""Returns the token document, or None if it doesn't exist (or is expired)."""
|
||||
|
||||
tokens_collection = app.data.driver.db['tokens']
|
||||
|
||||
# TODO: remove expired tokens from collection.
|
||||
lookup = {'token': token,
|
||||
'expire_time': {"$gt": datetime.now(tz=tz_util.utc)}}
|
||||
lookup.update(extra_filters)
|
||||
|
||||
db_token = tokens_collection.find_one(lookup)
|
||||
return db_token
|
||||
|
||||
|
||||
def store_token(user_id, token, token_expiry):
|
||||
"""Stores an authentication token.
|
||||
|
||||
:returns: the token document from MongoDB
|
||||
"""
|
||||
|
||||
token_data = {
|
||||
'user': user_id,
|
||||
'token': token,
|
||||
'expire_time': token_expiry,
|
||||
}
|
||||
r, _, _, status = post_internal('tokens', token_data)
|
||||
|
||||
if status not in {200, 201}:
|
||||
log.error('Unable to store authentication token: %s', r)
|
||||
raise RuntimeError('Unable to store authentication token.')
|
||||
|
||||
return r
|
||||
|
||||
|
||||
def create_new_user(email, username, user_id):
|
||||
"""Creates a new user in our local database.
|
||||
|
||||
@@ -158,7 +117,7 @@ def create_new_user(email, username, user_id):
|
||||
return user_id
|
||||
|
||||
|
||||
def create_new_user_document(email, user_id, username, token=''):
|
||||
def create_new_user_document(email, user_id, username):
|
||||
"""Creates a new user document, without storing it in MongoDB."""
|
||||
|
||||
user_data = {
|
||||
@@ -168,10 +127,11 @@ def create_new_user_document(email, user_id, username, token=''):
|
||||
'auth': [{
|
||||
'provider': 'blender-id',
|
||||
'user_id': str(user_id),
|
||||
'token': token}],
|
||||
'token': ''}], # TODO: remove 'token' field altogether.
|
||||
'settings': {
|
||||
'email_communications': 1
|
||||
}
|
||||
},
|
||||
'groups': [],
|
||||
}
|
||||
return user_data
|
||||
|
||||
@@ -202,11 +162,3 @@ def make_unique_username(email):
|
||||
if user_from_username is None:
|
||||
return unique_name
|
||||
suffix += 1
|
||||
|
||||
|
||||
def find_user_by_token(scst):
|
||||
users = app.data.driver.db['users']
|
||||
|
||||
query = {'auth': {'$elemMatch': {'provider': 'blender-id',
|
||||
'token': scst}}}
|
||||
return users.find_one(query)
|
||||
|
Reference in New Issue
Block a user