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:
2016-04-13 15:33:54 +02:00
parent 0f6eeef32b
commit 66eeb25529
6 changed files with 250 additions and 190 deletions

View File

@@ -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

View File

@@ -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)