diff --git a/pillar/application/modules/blender_id.py b/pillar/application/modules/blender_id.py index b9faa493..6362b843 100644 --- a/pillar/application/modules/blender_id.py +++ b/pillar/application/modules/blender_id.py @@ -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 diff --git a/pillar/application/utils/authentication.py b/pillar/application/utils/authentication.py index 7227e6c0..ce4b6ad0 100644 --- a/pillar/application/utils/authentication.py +++ b/pillar/application/utils/authentication.py @@ -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) diff --git a/requirements.txt b/requirements.txt index 67ce0459..aea88870 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/tests/common_test_class.py b/tests/common_test_class.py index f516d8ba..714d66a3 100644 --- a/tests/common_test_class.py +++ b/tests/common_test_class.py @@ -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)) diff --git a/tests/test_auth.py b/tests/test_auth.py index bc0abafc..23184aff 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -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()) diff --git a/tests/test_blender_id_subclient.py b/tests/test_blender_id_subclient.py index 98aa400e..dbe5d7ab 100644 --- a/tests/test_blender_id_subclient.py +++ b/tests/test_blender_id_subclient.py @@ -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