Initial work to support multiple OAuth clients

This commit is contained in:
Francesco Siddi 2017-07-25 17:50:22 +02:00
parent d48a308cc6
commit c827dc4ed2
5 changed files with 232 additions and 111 deletions

View File

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

View File

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

View File

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

72
pillar/auth/oauth.py Normal file
View File

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

View File

@ -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/<provider>')
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/<provider>')
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)