From c827dc4ed2a506a4e5564110dcd3db80f572c251 Mon Sep 17 00:00:00 2001 From: Francesco Siddi Date: Tue, 25 Jul 2017 17:50:22 +0200 Subject: [PATCH] Initial work to support multiple OAuth clients --- pillar/api/blender_id.py | 103 ++----------------------- pillar/api/eve_settings.py | 9 +-- pillar/api/utils/authentication.py | 116 ++++++++++++++++++++++++++++- pillar/auth/oauth.py | 72 ++++++++++++++++++ pillar/web/users/routes.py | 43 +++++++++-- 5 files changed, 232 insertions(+), 111 deletions(-) create mode 100644 pillar/auth/oauth.py diff --git a/pillar/api/blender_id.py b/pillar/api/blender_id.py index 61970621..21ecfdea 100644 --- a/pillar/api/blender_id.py +++ b/pillar/api/blender_id.py @@ -4,15 +4,16 @@ Also contains functionality for other parts of Pillar to perform communication with Blender ID. """ +import datetime import logging -import datetime import requests from bson import tz_util from flask import Blueprint, request, current_app, jsonify -from pillar.api.utils import authentication, remove_private_keys from requests.adapters import HTTPAdapter -from werkzeug import exceptions as wz_exceptions + +from pillar.api.utils import authentication +from pillar.api.utils.authentication import find_user_in_db, upsert_user blender_id = Blueprint('blender_id', __name__) log = logging.getLogger(__name__) @@ -64,11 +65,10 @@ def validate_create_user(blender_id_user_id, token, oauth_subclient_id): # Blender ID can be queried without user ID, and will always include the # correct user ID in its response. log.debug('Obtained user info from Blender ID: %s', user_info) - blender_id_user_id = user_info['id'] # Store the user info in MongoDB. - db_user = find_user_in_db(blender_id_user_id, user_info) - db_id, status = upsert_user(db_user, blender_id_user_id) + db_user = find_user_in_db(user_info) + db_id, status = upsert_user(db_user) # Store the token in MongoDB. authentication.store_token(db_id, token, token_expiry, oauth_subclient_id) @@ -76,67 +76,6 @@ def validate_create_user(blender_id_user_id, token, oauth_subclient_id): return db_user, status -def upsert_user(db_user, blender_id_user_id): - """Inserts/updates the user in MongoDB. - - Retries a few times when there are uniqueness issues in the username. - - :returns: the user's database ID and the status of the PUT/POST. - The status is 201 on insert, and 200 on update. - :type: (ObjectId, int) - """ - - if 'subscriber' in db_user.get('groups', []): - log.error('Non-ObjectID string found in user.groups: %s', db_user) - raise wz_exceptions.InternalServerError( - 'Non-ObjectID string found in user.groups: %s' % db_user) - - r = {} - for retry in range(5): - if '_id' in db_user: - # Update the existing user - attempted_eve_method = 'PUT' - db_id = db_user['_id'] - r, _, _, status = current_app.put_internal('users', remove_private_keys(db_user), - _id=db_id) - if status == 422: - log.error('Status %i trying to PUT user %s with values %s, should not happen! %s', - status, db_id, remove_private_keys(db_user), r) - else: - # Create a new user, retry for non-unique usernames. - attempted_eve_method = 'POST' - r, _, _, status = current_app.post_internal('users', db_user) - - if status not in {200, 201}: - log.error('Status %i trying to create user for BlenderID %s with values %s: %s', - status, blender_id_user_id, db_user, r) - raise wz_exceptions.InternalServerError() - - db_id = r['_id'] - db_user.update(r) # update with database/eve-generated fields. - - if status == 422: - # Probably non-unique username, so retry a few times with different usernames. - log.info('Error creating new user: %s', r) - username_issue = r.get('_issues', {}).get('username', '') - if 'not unique' in username_issue: - # Retry - db_user['username'] = authentication.make_unique_username(db_user['email']) - continue - - # Saving was successful, or at least didn't break on a non-unique username. - break - else: - log.error('Unable to create new user %s: %s', db_user, r) - raise wz_exceptions.InternalServerError() - - if status not in (200, 201): - log.error('internal response from %s to Eve: %r %r', attempted_eve_method, status, r) - raise wz_exceptions.InternalServerError() - - return db_id, status - - def validate_token(user_id, token, oauth_subclient_id): """Verifies a subclient token with Blender ID. @@ -211,36 +150,6 @@ def _compute_token_expiry(token_expires_string): 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': str(blender_id_user_id), - 'provider': 'blender-id'}}} - log.debug('Querying: %s', query) - db_user = users.find_one(query) - - if db_user: - log.debug('User blender_id_user_id=%r already in our database, ' - 'updating with info from Blender ID.', blender_id_user_id) - 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( - email=user_info['email'], - user_id=blender_id_user_id, - username=user_info['full_name']) - db_user['username'] = authentication.make_unique_username(user_info['email']) - if not db_user['full_name']: - db_user['full_name'] = db_user['username'] - - return db_user - - def fetch_blenderid_user() -> dict: """Returns the user info of the currently logged in user from BlenderID. diff --git a/pillar/api/eve_settings.py b/pillar/api/eve_settings.py index fb51cc6b..40671d09 100644 --- a/pillar/api/eve_settings.py +++ b/pillar/api/eve_settings.py @@ -88,8 +88,8 @@ users_schema = { } }, 'auth': { - # Storage of authentication credentials (one will be able to auth with - # multiple providers on the same account) + # Storage of authentication credentials (one will be able to auth with multiple providers on + # the same account) 'type': 'list', 'required': True, 'schema': { @@ -97,13 +97,12 @@ users_schema = { 'schema': { 'provider': { 'type': 'string', - 'allowed': ["blender-id", "local"], + 'allowed': ['blender-id', 'local', 'facebook'], }, 'user_id': { 'type': 'string' }, - # A token is considered a "password" in case the provider is - # "local". + # A token is considered a "password" in case the provider is "local". 'token': { 'type': 'string' } diff --git a/pillar/api/utils/authentication.py b/pillar/api/utils/authentication.py index 32d7337b..35486111 100644 --- a/pillar/api/utils/authentication.py +++ b/pillar/api/utils/authentication.py @@ -11,9 +11,12 @@ import typing import bson from bson import tz_util -from flask import g +from flask import g, current_app from flask import request from flask import current_app +from werkzeug import exceptions as wz_exceptions + +from pillar.api.utils import remove_private_keys log = logging.getLogger(__name__) @@ -49,6 +52,56 @@ def force_cli_user(): g.current_user = CLI_USER +def find_user_in_db(user_info: dict, provider='blender-id'): + """Find the user in our database, creating/updating the returned document where needed. + + First, search for the user using its id from the provider, then try to look the user up via the + email address. + + Does NOT update the user in the database. + + :param user_info: Information (id, email and full_name) from the auth provider + :param provider: One of the supported providers + """ + + users = current_app.data.driver.db['users'] + + query = {'$or': [ + {'auth': {'$elemMatch': { + 'user_id': str(user_info['id']), + 'provider': provider}}}, + {'email': user_info['email']}, + ]} + log.debug('Querying: %s', query) + db_user = users.find_one(query) + + if db_user: + log.debug('User with {provider} id {user_id} already in our database, ' + 'updating with info from {provider}.'.format( + provider=provider, user_id=user_info['id'])) + db_user['email'] = user_info['email'] + + # Find out if an auth entry for the current provider already exists + provider_entry = [element for element in db_user['auth'] if element['provider'] == provider] + if not provider_entry: + db_user['auth'].append({ + 'provider': provider, + 'user_id': str(user_info['id']), + 'token': ''}) + else: + log.debug('User %r not yet in our database, create a new one.', user_info['id']) + db_user = create_new_user_document( + email=user_info['email'], + user_id=user_info['id'], + username=user_info['full_name'], + provider=provider) + db_user['username'] = make_unique_username(user_info['email']) + if not db_user['full_name']: + db_user['full_name'] = db_user['username'] + + return db_user + + 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 @@ -270,3 +323,64 @@ def setup_app(app): def validate_token_at_each_request(): validate_token() return None + + +def upsert_user(db_user): + """Inserts/updates the user in MongoDB. + + Retries a few times when there are uniqueness issues in the username. + + :returns: the user's database ID and the status of the PUT/POST. + The status is 201 on insert, and 200 on update. + :type: (ObjectId, int) + """ + + if 'subscriber' in db_user.get('groups', []): + log.error('Non-ObjectID string found in user.groups: %s', db_user) + raise wz_exceptions.InternalServerError( + 'Non-ObjectID string found in user.groups: %s' % db_user) + + r = {} + for retry in range(5): + if '_id' in db_user: + # Update the existing user + attempted_eve_method = 'PUT' + db_id = db_user['_id'] + r, _, _, status = current_app.put_internal('users', remove_private_keys(db_user), + _id=db_id) + if status == 422: + log.error('Status %i trying to PUT user %s with values %s, should not happen! %s', + status, db_id, remove_private_keys(db_user), r) + else: + # Create a new user, retry for non-unique usernames. + attempted_eve_method = 'POST' + r, _, _, status = current_app.post_internal('users', db_user) + + if status not in {200, 201}: + log.error('Status %i trying to create user with values %s: %s', + status, db_user, r) + raise wz_exceptions.InternalServerError() + + db_id = r['_id'] + db_user.update(r) # update with database/eve-generated fields. + + if status == 422: + # Probably non-unique username, so retry a few times with different usernames. + log.info('Error creating new user: %s', r) + username_issue = r.get('_issues', {}).get('username', '') + if 'not unique' in username_issue: + # Retry + db_user['username'] = make_unique_username(db_user['email']) + continue + + # Saving was successful, or at least didn't break on a non-unique username. + break + else: + log.error('Unable to create new user %s: %s', db_user, r) + raise wz_exceptions.InternalServerError() + + if status not in (200, 201): + log.error('internal response from %s to Eve: %r %r', attempted_eve_method, status, r) + raise wz_exceptions.InternalServerError() + + return db_id, status diff --git a/pillar/auth/oauth.py b/pillar/auth/oauth.py new file mode 100644 index 00000000..06ae7a3d --- /dev/null +++ b/pillar/auth/oauth.py @@ -0,0 +1,72 @@ +import json + +from rauth import OAuth2Service +from flask import current_app, url_for, request, redirect, session + + +class OAuthSignIn(object): + providers = None + + def __init__(self, provider_name): + self.provider_name = provider_name + credentials = current_app.config['OAUTH_CREDENTIALS'][provider_name] + self.consumer_id = credentials['id'] + self.consumer_secret = credentials['secret'] + + def authorize(self): + pass + + def callback(self): + pass + + def get_callback_url(self): + return url_for('users.oauth_callback', provider=self.provider_name, + _external=True) + + @classmethod + def get_provider(cls, provider_name): + if cls.providers is None: + cls.providers = {} + for provider_class in cls.__subclasses__(): + provider = provider_class() + cls.providers[provider.provider_name] = provider + return cls.providers[provider_name] + + +class FacebookSignIn(OAuthSignIn): + def __init__(self): + super(FacebookSignIn, self).__init__('facebook') + self.service = OAuth2Service( + name='facebook', + client_id=self.consumer_id, + client_secret=self.consumer_secret, + authorize_url='https://graph.facebook.com/oauth/authorize', + access_token_url='https://graph.facebook.com/oauth/access_token', + base_url='https://graph.facebook.com/' + ) + + def authorize(self): + return redirect(self.service.get_authorize_url( + scope='email', + response_type='code', + redirect_uri=self.get_callback_url()) + ) + + def callback(self): + def decode_json(payload): + return json.loads(payload.decode('utf-8')) + + if 'code' not in request.args: + return None, None, None + oauth_session = self.service.get_auth_session( + data={'code': request.args['code'], + 'grant_type': 'authorization_code', + 'redirect_uri': self.get_callback_url()}, + decoder=decode_json + ) + me = oauth_session.get('me?fields=id,email').json() + # TODO handle case when user chooses not to disclose en email + return ( + me['id'], + me.get('email'), + ) diff --git a/pillar/web/users/routes.py b/pillar/web/users/routes.py index e1470c80..f55b0395 100644 --- a/pillar/web/users/routes.py +++ b/pillar/web/users/routes.py @@ -2,22 +2,22 @@ import json import logging import requests +from werkzeug import exceptions as wz_exceptions from flask import abort, Blueprint, current_app, flash, redirect, render_template, request, session,\ url_for from flask_login import login_required, logout_user, current_user from flask_oauthlib.client import OAuthException -from werkzeug import exceptions as wz_exceptions - -import pillar.api.blender_cloud.subscription -import pillar.auth -from pillar.web import system_util -from pillar.api.local_auth import generate_and_store_token, get_local_user - -from . import forms from pillarsdk import exceptions as sdk_exceptions from pillarsdk.users import User from pillarsdk.groups import Group +import pillar.api.blender_cloud.subscription +import pillar.auth +from pillar.web import system_util +from pillar.api.local_auth import generate_and_store_token, get_local_user +from pillar.api.utils.authentication import find_user_in_db, upsert_user +from pillar.auth.oauth import OAuthSignIn +from . import forms log = logging.getLogger(__name__) blueprint = Blueprint('users', __name__) @@ -28,6 +28,33 @@ def check_oauth_provider(provider): return abort(404) +@blueprint.route('/authorize/') +def oauth_authorize(provider): + if not current_user.is_anonymous: + return redirect(url_for('main.homepage')) + oauth = OAuthSignIn.get_provider(provider) + return oauth.authorize() + + +@blueprint.route('/callback/') +def oauth_callback(provider): + if not current_user.is_anonymous: + return redirect(url_for('main.homepage')) + oauth = OAuthSignIn.get_provider(provider) + social_id, email = oauth.callback() + if social_id is None: + flash('Authentication failed.') + return redirect(url_for('main.homepage')) + # Find or create user + user_info = {'id': social_id, 'email': email, 'full_name': ''} + db_user = find_user_in_db(user_info, provider=provider) + db_id, status = upsert_user(db_user) + token = generate_and_store_token(db_id) + # Login user + pillar.auth.login_user(token['token']) + return redirect(url_for('main.homepage')) + + @blueprint.route('/login') def login(): check_oauth_provider(current_app.oauth_blender_id)