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:
Sybren A. Stüvel 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']
# 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 = {'client_id': client_id,
'subclient_id': subclient_id,
'user_id': user_id,
'scst': scst}
url = '{0}/subclients/validate_token'.format(authentication.blender_id_endpoint())
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
# 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)

View File

@ -24,6 +24,5 @@ wheel==0.24.0
zencoder==0.6.5
# development requirements
httpretty==0.8.14
pytest==2.9.1
responses==0.5.1

View File

@ -1,3 +1,5 @@
# -*- encoding: utf-8 -*-
import json
import copy
import sys
@ -9,7 +11,7 @@ from bson import ObjectId
from eve.tests import TestMinimal
import pymongo.collection
from flask.testing import FlaskClient
import httpretty
import responses
from common_test_data import EXAMPLE_PROJECT, EXAMPLE_FILE
@ -17,6 +19,14 @@ MY_PATH = os.path.dirname(os.path.abspath(__file__))
TEST_EMAIL_USER = 'koro'
TEST_EMAIL_ADDRESS = '%s@testing.blender.org' % TEST_EMAIL_USER
TEST_FULL_NAME = u'врач Сергей'
TEST_SUBCLIENT_TOKEN = 'my-subclient-token-for-pillar'
BLENDER_ID_TEST_USERID = 1896
BLENDER_ID_USER_RESPONSE = {'status': 'success',
'user': {'email': TEST_EMAIL_ADDRESS,
'full_name': TEST_FULL_NAME,
'user_id': BLENDER_ID_TEST_USERID},
'token_expires': 'Mon, 1 Jan 2018 01:02:03 GMT'}
logging.basicConfig(
level=logging.DEBUG,
@ -83,26 +93,23 @@ class AbstractPillarTest(TestMinimal):
return found['_id'], found
def htp_blenderid_validate_unhappy(self):
"""Sets up HTTPretty to mock unhappy validation flow."""
def mock_blenderid_validate_unhappy(self):
"""Sets up Responses to mock unhappy validation flow."""
httpretty.register_uri(httpretty.POST,
'%s/u/validate_token' % self.app.config['BLENDER_ID_ENDPOINT'],
body=json.dumps(
{'data': {'token': 'Token is invalid'}, 'status': 'fail'}),
content_type="application/json")
responses.add(responses.POST,
'%s/subclients/validate_token' % self.app.config['BLENDER_ID_ENDPOINT'],
json={'status': 'fail'},
status=404)
def htp_blenderid_validate_happy(self):
"""Sets up HTTPretty to mock happy validation flow."""
def mock_blenderid_validate_happy(self):
"""Sets up Responses to mock happy validation flow."""
httpretty.register_uri(httpretty.POST,
'%s/u/validate_token' % self.app.config['BLENDER_ID_ENDPOINT'],
body=json.dumps(
{'data': {'user': {'email': TEST_EMAIL_ADDRESS, 'id': 5123}},
'status': 'success'}),
content_type="application/json")
responses.add(responses.POST,
'%s/subclients/validate_token' % self.app.config['BLENDER_ID_ENDPOINT'],
json=BLENDER_ID_USER_RESPONSE,
status=200)
def make_header(self, username, password=''):
def make_header(self, username, subclient_id=''):
"""Returns a Basic HTTP Authentication header value."""
return 'basic ' + base64.b64encode('%s:%s' % (username, password))
return 'basic ' + base64.b64encode('%s:%s' % (username, subclient_id))

View File

@ -1,4 +1,4 @@
import httpretty
import responses
from common_test_class import AbstractPillarTest, TEST_EMAIL_USER, TEST_EMAIL_ADDRESS
@ -15,31 +15,31 @@ class AuthenticationTests(AbstractPillarTest):
auth.create_new_user(TEST_EMAIL_ADDRESS, TEST_EMAIL_USER, 'test1234')
self.assertEqual('%s1' % TEST_EMAIL_USER, auth.make_unique_username(TEST_EMAIL_ADDRESS))
@httpretty.activate
@responses.activate
def test_validate_token__not_logged_in(self):
from application.utils import authentication as auth
with self.app.test_request_context():
self.assertFalse(auth.validate_token())
@httpretty.activate
@responses.activate
def test_validate_token__unknown_token(self):
"""Test validating of invalid token, unknown both to us and Blender ID."""
from application.utils import authentication as auth
self.htp_blenderid_validate_unhappy()
self.mock_blenderid_validate_unhappy()
with self.app.test_request_context(
headers={'Authorization': self.make_header('unknowntoken')}):
self.assertFalse(auth.validate_token())
@httpretty.activate
@responses.activate
def test_validate_token__unknown_but_valid_token(self):
"""Test validating of valid token, unknown to us but known to Blender ID."""
from application.utils import authentication as auth
self.htp_blenderid_validate_happy()
self.mock_blenderid_validate_happy()
with self.app.test_request_context(
headers={'Authorization': self.make_header('knowntoken')}):
self.assertTrue(auth.validate_token())

View File

@ -4,15 +4,10 @@ import responses
import json
from bson import ObjectId
from flask import g
from common_test_class import AbstractPillarTest
TEST_FULL_NAME = u'врач Сергей'
TEST_EMAIL = 'jemoeder@example.com'
TEST_SUBCLIENT_TOKEN = 'my-subclient-token-for-pillar'
BLENDER_ID_TEST_USERID = 1896
BLENDER_ID_USER_RESPONSE = {'status': 'success',
'user': {'email': TEST_EMAIL, 'full_name': TEST_FULL_NAME}}
from common_test_class import (AbstractPillarTest, TEST_EMAIL_ADDRESS, BLENDER_ID_TEST_USERID,
TEST_SUBCLIENT_TOKEN, BLENDER_ID_USER_RESPONSE, TEST_FULL_NAME)
class BlenderIdSubclientTest(AbstractPillarTest):
@ -25,32 +20,72 @@ class BlenderIdSubclientTest(AbstractPillarTest):
# Make sure the user exists in our database.
from application.utils.authentication import create_new_user
with self.app.test_request_context():
create_new_user(TEST_EMAIL, 'apekoppie', BLENDER_ID_TEST_USERID)
create_new_user(TEST_EMAIL_ADDRESS, 'apekoppie', BLENDER_ID_TEST_USERID)
self._common_user_test(200)
def _common_user_test(self, expected_status_code):
responses.add(responses.POST,
'%s/subclients/validate_token' % self.app.config['BLENDER_ID_ENDPOINT'],
json=BLENDER_ID_USER_RESPONSE,
status=200)
@responses.activate
def test_store_multiple_tokens(self):
scst1 = '%s-1' % TEST_SUBCLIENT_TOKEN
scst2 = '%s-2' % TEST_SUBCLIENT_TOKEN
db_user1 = self._common_user_test(201, scst=scst1)
db_user2 = self._common_user_test(200, scst=scst2)
self.assertEqual(db_user1['_id'], db_user2['_id'])
# Now there should be two tokens.
with self.app.test_request_context():
tokens = self.app.data.driver.db['tokens']
self.assertIsNotNone(tokens.find_one({'user': db_user1['_id'], 'token': scst1}))
self.assertIsNotNone(tokens.find_one({'user': db_user1['_id'], 'token': scst2}))
# There should still be only one auth element for blender-id in the user doc.
self.assertEqual(1, len(db_user1['auth']))
@responses.activate
def test_authenticate_with_scst(self):
# Make sure there is a user and SCST.
db_user = self._common_user_test(201)
# Make a call that's authenticated with the SCST
from application.utils import authentication as auth
subclient_id = self.app.config['BLENDER_ID_SUBCLIENT_ID']
auth_header = self.make_header(TEST_SUBCLIENT_TOKEN, subclient_id)
with self.app.test_request_context(headers={'Authorization': auth_header}):
self.assertTrue(auth.validate_token())
self.assertIsNotNone(g.current_user)
self.assertEqual(db_user['_id'], g.current_user['user_id'])
def _common_user_test(self, expected_status_code, scst=TEST_SUBCLIENT_TOKEN):
self.mock_blenderid_validate_happy()
subclient_id = self.app.config['BLENDER_ID_SUBCLIENT_ID']
resp = self.client.post('/blender_id/store_scst',
data={'user_id': BLENDER_ID_TEST_USERID,
'scst': TEST_SUBCLIENT_TOKEN})
'subclient_id': subclient_id,
'token': scst})
self.assertEqual(expected_status_code, resp.status_code)
user_info = json.loads(resp.data) # {'status': 'success', 'subclient_user_id': '...'}
self.assertEqual('success', user_info['status'])
# Check that the user was correctly updated
with self.app.test_request_context():
# Check that the user was correctly updated
users = self.app.data.driver.db['users']
db_user = users.find_one(ObjectId(user_info['subclient_user_id']))
self.assertIsNotNone(db_user, 'user %r not found' % user_info['subclient_user_id'])
self.assertEqual(TEST_EMAIL, db_user['email'])
self.assertEqual(TEST_EMAIL_ADDRESS, db_user['email'])
self.assertEqual(TEST_FULL_NAME, db_user['full_name'])
self.assertEqual(TEST_SUBCLIENT_TOKEN, db_user['auth'][0]['token'])
# self.assertEqual(TEST_SUBCLIENT_TOKEN, db_user['auth'][0]['token'])
self.assertEqual(str(BLENDER_ID_TEST_USERID), db_user['auth'][0]['user_id'])
self.assertEqual('blender-id', db_user['auth'][0]['provider'])
# Check that the token was succesfully stored.
tokens = self.app.data.driver.db['tokens']
db_token = tokens.find_one({'user': db_user['_id'],
'token': scst})
self.assertIsNotNone(db_token)
return db_user