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.
|
||||
"""
|
||||
|
||||
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.
|
||||
|
||||
|
@ -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'
|
||||
}
|
||||
|
@ -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
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 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)
|
||||
|
Loading…
x
Reference in New Issue
Block a user