Initial work to support multiple OAuth clients
This commit is contained in:
parent
d48a308cc6
commit
c827dc4ed2
@ -4,15 +4,16 @@ Also contains functionality for other parts of Pillar to perform communication
|
|||||||
with Blender ID.
|
with Blender ID.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
import datetime
|
|
||||||
import requests
|
import requests
|
||||||
from bson import tz_util
|
from bson import tz_util
|
||||||
from flask import Blueprint, request, current_app, jsonify
|
from flask import Blueprint, request, current_app, jsonify
|
||||||
from pillar.api.utils import authentication, remove_private_keys
|
|
||||||
from requests.adapters import HTTPAdapter
|
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__)
|
blender_id = Blueprint('blender_id', __name__)
|
||||||
log = logging.getLogger(__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
|
# Blender ID can be queried without user ID, and will always include the
|
||||||
# correct user ID in its response.
|
# correct user ID in its response.
|
||||||
log.debug('Obtained user info from Blender ID: %s', user_info)
|
log.debug('Obtained user info from Blender ID: %s', user_info)
|
||||||
blender_id_user_id = user_info['id']
|
|
||||||
|
|
||||||
# Store the user info in MongoDB.
|
# Store the user info in MongoDB.
|
||||||
db_user = find_user_in_db(blender_id_user_id, user_info)
|
db_user = find_user_in_db(user_info)
|
||||||
db_id, status = upsert_user(db_user, blender_id_user_id)
|
db_id, status = upsert_user(db_user)
|
||||||
|
|
||||||
# Store the token in MongoDB.
|
# Store the token in MongoDB.
|
||||||
authentication.store_token(db_id, token, token_expiry, oauth_subclient_id)
|
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
|
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):
|
def validate_token(user_id, token, oauth_subclient_id):
|
||||||
"""Verifies a subclient token with Blender 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)
|
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:
|
def fetch_blenderid_user() -> dict:
|
||||||
"""Returns the user info of the currently logged in user from BlenderID.
|
"""Returns the user info of the currently logged in user from BlenderID.
|
||||||
|
|
||||||
|
@ -88,8 +88,8 @@ users_schema = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
'auth': {
|
'auth': {
|
||||||
# Storage of authentication credentials (one will be able to auth with
|
# Storage of authentication credentials (one will be able to auth with multiple providers on
|
||||||
# multiple providers on the same account)
|
# the same account)
|
||||||
'type': 'list',
|
'type': 'list',
|
||||||
'required': True,
|
'required': True,
|
||||||
'schema': {
|
'schema': {
|
||||||
@ -97,13 +97,12 @@ users_schema = {
|
|||||||
'schema': {
|
'schema': {
|
||||||
'provider': {
|
'provider': {
|
||||||
'type': 'string',
|
'type': 'string',
|
||||||
'allowed': ["blender-id", "local"],
|
'allowed': ['blender-id', 'local', 'facebook'],
|
||||||
},
|
},
|
||||||
'user_id': {
|
'user_id': {
|
||||||
'type': 'string'
|
'type': 'string'
|
||||||
},
|
},
|
||||||
# A token is considered a "password" in case the provider is
|
# A token is considered a "password" in case the provider is "local".
|
||||||
# "local".
|
|
||||||
'token': {
|
'token': {
|
||||||
'type': 'string'
|
'type': 'string'
|
||||||
}
|
}
|
||||||
|
@ -11,9 +11,12 @@ import typing
|
|||||||
|
|
||||||
import bson
|
import bson
|
||||||
from bson import tz_util
|
from bson import tz_util
|
||||||
from flask import g
|
from flask import g, current_app
|
||||||
from flask import request
|
from flask import request
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
|
from werkzeug import exceptions as wz_exceptions
|
||||||
|
|
||||||
|
from pillar.api.utils import remove_private_keys
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -49,6 +52,56 @@ def force_cli_user():
|
|||||||
g.current_user = 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():
|
def validate_token():
|
||||||
"""Validate the token provided in the request and populate the current_user
|
"""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
|
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():
|
def validate_token_at_each_request():
|
||||||
validate_token()
|
validate_token()
|
||||||
return None
|
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
72
pillar/auth/oauth.py
Normal 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'),
|
||||||
|
)
|
@ -2,22 +2,22 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
from werkzeug import exceptions as wz_exceptions
|
||||||
from flask import abort, Blueprint, current_app, flash, redirect, render_template, request, session,\
|
from flask import abort, Blueprint, current_app, flash, redirect, render_template, request, session,\
|
||||||
url_for
|
url_for
|
||||||
from flask_login import login_required, logout_user, current_user
|
from flask_login import login_required, logout_user, current_user
|
||||||
from flask_oauthlib.client import OAuthException
|
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 import exceptions as sdk_exceptions
|
||||||
from pillarsdk.users import User
|
from pillarsdk.users import User
|
||||||
from pillarsdk.groups import Group
|
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__)
|
log = logging.getLogger(__name__)
|
||||||
blueprint = Blueprint('users', __name__)
|
blueprint = Blueprint('users', __name__)
|
||||||
@ -28,6 +28,33 @@ def check_oauth_provider(provider):
|
|||||||
return abort(404)
|
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')
|
@blueprint.route('/login')
|
||||||
def login():
|
def login():
|
||||||
check_oauth_provider(current_app.oauth_blender_id)
|
check_oauth_provider(current_app.oauth_blender_id)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user