From 2a2d35827cd6440aed3f00e0bd2bf3dcc74222e6 Mon Sep 17 00:00:00 2001 From: Francesco Siddi Date: Tue, 26 Apr 2016 12:33:48 +0200 Subject: [PATCH] Added local accounts --- pillar/application/__init__.py | 2 + pillar/application/modules/local_auth.py | 84 ++++++++++++++++++++++ pillar/application/utils/authentication.py | 17 +++-- pillar/manage.py | 5 ++ pillar/settings.py | 7 +- tests/test_local_auth.py | 76 ++++++++++++++++++++ 6 files changed, 182 insertions(+), 9 deletions(-) create mode 100644 pillar/application/modules/local_auth.py create mode 100644 tests/test_local_auth.py diff --git a/pillar/application/__init__.py b/pillar/application/__init__.py index e3086f7e..77302b22 100644 --- a/pillar/application/__init__.py +++ b/pillar/application/__init__.py @@ -387,7 +387,9 @@ file_storage.setup_app(app, url_prefix='/storage') from modules.encoding import encoding from modules.blender_id import blender_id from modules import projects +from modules import local_auth app.register_blueprint(encoding, url_prefix='/encoding') app.register_blueprint(blender_id, url_prefix='/blender_id') projects.setup_app(app, url_prefix='/p') +local_auth.setup_app(app, url_prefix='/auth') diff --git a/pillar/application/modules/local_auth.py b/pillar/application/modules/local_auth.py new file mode 100644 index 00000000..408f6d9c --- /dev/null +++ b/pillar/application/modules/local_auth.py @@ -0,0 +1,84 @@ +import base64 +import datetime +import hashlib +import logging +import rsa +import bcrypt +from bson import tz_util +from eve.methods.post import post_internal + +from flask import abort, Blueprint, current_app, jsonify, request + +from application.utils.authentication import store_token +from application.utils.authentication import create_new_user_document +from application.utils.authentication import make_unique_username + +blueprint = Blueprint('authentication', __name__) +log = logging.getLogger(__name__) + + +def get_auth_credentials(user, provider): + return next((credentials for credentials in user['auth'] if 'provider' + in credentials and credentials['provider'] == provider), None) + + +def create_local_user(email, password): + """For internal user only. Given username and password, create a user.""" + # Hash the password + hashed_password = hash_password(password, bcrypt.gensalt()) + db_user = create_new_user_document(email, '', email, provider='local', + token=hashed_password) + # Make username unique + db_user['username'] = make_unique_username(email) + # Create the user + r, _, _, status = post_internal('users', db_user) + if status != 201: + log.error('internal response: %r %r', status, r) + return abort(500) + # Return user ID + return r['_id'] + + +@blueprint.route('/make-token', methods=['POST']) +def make_token(): + """Direct login for a user, without OAuth, using local database. Generates + a token that is passed back to Pillar Web and used in subsequent + transactions. + + :return: a token string + """ + username = request.form['username'] + password = request.form['password'] + + # Look up user in db + users_collection = current_app.data.driver.db['users'] + user = users_collection.find_one({'username': username}) + if not user: + return abort(403) + # Check if user has "local" auth type + credentials = get_auth_credentials(user, 'local') + if not credentials: + return abort(403) + # Verify password + salt = credentials['token'] + hashed_password = hash_password(password, salt) + if hashed_password != credentials['token']: + return abort(403) + # Generate Token + token = base64.b64encode(rsa.randnum.read_random_bits(256)) + # TODO look into alternative implementations + token_expiry = datetime.datetime.now(tz=tz_util.utc) + datetime.timedelta( + days=15) + store_token(user['_id'], token, token_expiry) + return jsonify(token=token) + + +def hash_password(password, salt): + if isinstance(salt, unicode): + salt = salt.encode('utf-8') + encoded_password = base64.b64encode(hashlib.sha256(password).digest()) + return bcrypt.hashpw(encoded_password, salt) + + +def setup_app(app, url_prefix): + app.register_blueprint(blueprint, url_prefix=url_prefix) diff --git a/pillar/application/utils/authentication.py b/pillar/application/utils/authentication.py index 303e0cca..254143e9 100644 --- a/pillar/application/utils/authentication.py +++ b/pillar/application/utils/authentication.py @@ -83,7 +83,7 @@ def find_token(token, is_subclient_token=False, **extra_filters): return db_token -def store_token(user_id, token, token_expiry, oauth_subclient_id): +def store_token(user_id, token, token_expiry, oauth_subclient_id=False): """Stores an authentication token. :returns: the token document from MongoDB @@ -92,9 +92,11 @@ def store_token(user_id, token, token_expiry, oauth_subclient_id): token_data = { 'user': user_id, 'token': token, - 'is_subclient_token': bool(oauth_subclient_id), 'expire_time': token_expiry, } + if oauth_subclient_id: + token_data['is_subclient_token'] = True + r, _, _, status = post_internal('tokens', token_data) if status not in {200, 201}: @@ -120,17 +122,20 @@ def create_new_user(email, username, user_id): return user_id -def create_new_user_document(email, user_id, username): - """Creates a new user document, without storing it in MongoDB.""" +def create_new_user_document(email, user_id, username, provider='blender-id', + token=''): + """Creates a new user document, without storing it in MongoDB. The token + parameter is a password in case provider is "local". + """ user_data = { 'full_name': username, 'username': username, 'email': email, 'auth': [{ - 'provider': 'blender-id', + 'provider': provider, 'user_id': str(user_id), - 'token': ''}], # TODO: remove 'token' field altogether. + 'token': token}], 'settings': { 'email_communications': 1 }, diff --git a/pillar/manage.py b/pillar/manage.py index ee8b7568..c0420b71 100755 --- a/pillar/manage.py +++ b/pillar/manage.py @@ -774,5 +774,10 @@ def expire_all_project_links(project_uuid): print('Expired %i links' % result.matched_count) +@manager.command +def register_local_user(email, password): + from application.modules.local_auth import create_local_user + create_local_user(email, password) + if __name__ == '__main__': manager.run() diff --git a/pillar/settings.py b/pillar/settings.py index a0ed5ce2..44581452 100644 --- a/pillar/settings.py +++ b/pillar/settings.py @@ -85,11 +85,13 @@ users_schema = { 'schema': { 'provider': { 'type': 'string', - 'allowed': ["blender-id",], + 'allowed': ["blender-id", "local"], }, - 'user_id' : { + 'user_id': { 'type': 'string' }, + # A token is considered a "password" in case the provider is + # "local". 'token': { 'type': 'string' } @@ -360,7 +362,6 @@ tokens_schema = { 'is_subclient_token': { 'type': 'boolean', 'required': False, - 'default': False, } } diff --git a/tests/test_local_auth.py b/tests/test_local_auth.py new file mode 100644 index 00000000..62301cf4 --- /dev/null +++ b/tests/test_local_auth.py @@ -0,0 +1,76 @@ +import json +import datetime + +from bson import tz_util + +from common_test_class import AbstractPillarTest + + +class LocalAuthTest(AbstractPillarTest): + def create_test_user(self): + from application.modules import local_auth + with self.app.test_request_context(): + user_id = local_auth.create_local_user('koro@example.com', 'oti') + return user_id + + def test_create_local_user(self): + user_id = self.create_test_user() + + with self.app.test_request_context(): + users = self.app.data.driver.db['users'] + db_user = users.find_one(user_id) + self.assertIsNotNone(db_user) + + def test_login_existing_user(self): + user_id = self.create_test_user() + + resp = self.client.post('/auth/make-token', + data={'username': 'koro', + 'password': 'oti'}) + self.assertEqual(200, resp.status_code, resp.data) + + token_info = json.loads(resp.data) + token = token_info['token'] + + headers = {'Authorization': self.make_header(token)} + resp = self.client.get('/users/%s' % user_id, + headers=headers) + self.assertEqual(200, resp.status_code, resp.data) + + def test_login_expired_token(self): + user_id = self.create_test_user() + + resp = self.client.post('/auth/make-token', + data={'username': 'koro', + 'password': 'oti'}) + self.assertEqual(200, resp.status_code, resp.data) + + token_info = json.loads(resp.data) + token = token_info['token'] + + with self.app.test_request_context(): + tokens = self.app.data.driver.db['tokens'] + + exp = datetime.datetime.now(tz=tz_util.utc) - datetime.timedelta(1) + result = tokens.update_one({'token': token}, + {'$set': {'expire_time': exp}}) + self.assertEqual(1, result.modified_count) + + headers = {'Authorization': self.make_header(token)} + resp = self.client.get('/users/%s' % user_id, + headers=headers) + self.assertEqual(403, resp.status_code, resp.data) + + def test_login_nonexistant_user(self): + resp = self.client.post('/auth/make-token', + data={'username': 'proog', + 'password': 'oti'}) + + self.assertEqual(403, resp.status_code, resp.data) + + def test_login_bad_pwd(self): + resp = self.client.post('/auth/make-token', + data={'username': 'koro', + 'password': 'koro'}) + + self.assertEqual(403, resp.status_code, resp.data)