This feature is subtle but very important: Currently, when a user logs in via an "external" auth system (such as Github), they are either logged into an existing bound account or a new account is created for them in the database. While this normally works jut fine, it hits a roadblock when the *internal* auth system configured is not the database, but instead something like LDAP. In that case, *most* Enterprise customers will prefer that logging in via external auth (like OIDC) will also *automatically* bind the newly created account to the backing *internal* auth account. For example, login via PingFederate OIDC (backed by LDAP) should also bind the new QE account to the associated LDAP account, via either username or email. This change allows for this binding field to be specified, and thereafter will perform the proper lookups and bindings.
228 lines
9.1 KiB
Python
228 lines
9.1 KiB
Python
import logging
|
|
|
|
from collections import namedtuple
|
|
from flask import request, redirect, url_for, Blueprint
|
|
from peewee import IntegrityError
|
|
|
|
import features
|
|
|
|
from app import app, analytics, get_app_url, oauth_login, authentication
|
|
from auth.auth_context import get_authenticated_user
|
|
from auth.process import require_session_login
|
|
from data import model
|
|
from endpoints.common import common_login
|
|
from endpoints.web import index
|
|
from endpoints.csrf import csrf_protect, OAUTH_CSRF_TOKEN_NAME
|
|
from oauth.login import OAuthLoginException
|
|
from util.validation import generate_valid_usernames
|
|
|
|
logger = logging.getLogger(__name__)
|
|
client = app.config['HTTPCLIENT']
|
|
oauthlogin = Blueprint('oauthlogin', __name__)
|
|
|
|
oauthlogin_csrf_protect = csrf_protect(OAUTH_CSRF_TOKEN_NAME, 'state', all_methods=True)
|
|
|
|
|
|
OAuthResult = namedtuple('oauthresult', ['to_login', 'service_name', 'error_message',
|
|
'register_redirect'])
|
|
|
|
def _oauthresult(to_login=None, service_name=None, error_message=None, register_redirect=False):
|
|
return OAuthResult(to_login, service_name, error_message, register_redirect)
|
|
|
|
def _get_response(result):
|
|
if result.error_message is not None:
|
|
return _render_ologin_error(result.service_name, result.error_message, result.register_redirect)
|
|
|
|
return _perform_login(result.to_login, result.service_name)
|
|
|
|
def _conduct_oauth_login(auth_system, login_service, lid, lusername, lemail, metadata=None):
|
|
""" Conducts login from the result of an OAuth service's login flow and returns
|
|
the status of the login, as well as the followup step. """
|
|
service_id = login_service.service_id()
|
|
service_name = login_service.service_name()
|
|
|
|
# Check for an existing account *bound to this service*. If found, conduct login of that account
|
|
# and redirect.
|
|
to_login = model.user.verify_federated_login(service_id, lid)
|
|
if to_login is not None:
|
|
return _oauthresult(to_login=to_login, service_name=service_name)
|
|
|
|
# If the login service has a bound field name, and we have a defined internal auth type that is
|
|
# not the database, then search for an existing account with that matching field. This allows
|
|
# users to setup SSO while also being backed by something like LDAP.
|
|
bound_field_name = login_service.login_binding_field()
|
|
if auth_system.federated_service is not None and bound_field_name is not None:
|
|
# Perform lookup.
|
|
logger.debug('Got oauth bind field name of "%s"', bound_field_name)
|
|
lookup_value = None
|
|
if bound_field_name == 'username':
|
|
lookup_value = lusername
|
|
elif bound_field_name == 'email':
|
|
lookup_value = lemail
|
|
|
|
if lookup_value is None:
|
|
logger.error('Missing lookup value for OAuth login')
|
|
return _oauthresult(service_name=service_name,
|
|
error_message='Configuration error in this provider')
|
|
|
|
(user_obj, err) = auth_system.link_user(lookup_value)
|
|
if err is not None:
|
|
logger.debug('%s %s not found: %s', bound_field_name, lookup_value, err)
|
|
msg = '%s %s not found in backing auth system' % (bound_field_name, lookup_value)
|
|
return _oauthresult(service_name=service_name, error_message=msg)
|
|
|
|
# Found an existing user. Bind their internal auth account to this service as well.
|
|
result = _attach_service(login_service, user_obj, lid, lusername)
|
|
if result.error_message is not None:
|
|
return result
|
|
|
|
return _oauthresult(to_login=user_obj, service_name=service_name)
|
|
|
|
# Otherwise, we need to create a new user account.
|
|
if not features.USER_CREATION:
|
|
error_message = 'User creation is disabled. Please contact your administrator'
|
|
return _oauthresult(service_name=service_name, error_message=error_message)
|
|
|
|
# Try to create the user
|
|
try:
|
|
# Generate a valid username.
|
|
new_username = None
|
|
for valid in generate_valid_usernames(lusername):
|
|
if model.user.get_user_or_org(valid):
|
|
continue
|
|
|
|
new_username = valid
|
|
break
|
|
|
|
prompts = model.user.get_default_user_prompts(features)
|
|
to_login = model.user.create_federated_user(new_username, lemail, service_id, lid,
|
|
set_password_notification=True,
|
|
metadata=metadata or {},
|
|
prompts=prompts,
|
|
email_required=features.MAILING)
|
|
|
|
# Success, tell analytics
|
|
analytics.track(to_login.username, 'register', {'service': service_name.lower()})
|
|
return _oauthresult(to_login=to_login, service_name=service_name)
|
|
|
|
except model.InvalidEmailAddressException:
|
|
message = "The e-mail address %s is already associated " % (lemail, )
|
|
message = message + "with an existing %s account." % (app.config['REGISTRY_TITLE_SHORT'], )
|
|
message = message + "\nPlease log in with your username and password and "
|
|
message = message + "associate your %s account to use it in the future." % (service_name, )
|
|
return _oauthresult(service_name=service_name, error_message=message,
|
|
register_redirect=True)
|
|
|
|
except model.DataModelException as ex:
|
|
return _oauthresult(service_name=service_name, error_message=ex.message)
|
|
|
|
def _render_ologin_error(service_name, error_message=None, register_redirect=False):
|
|
""" Returns a Flask response indicating an OAuth error. """
|
|
|
|
user_creation = bool(features.USER_CREATION and features.DIRECT_LOGIN)
|
|
error_info = {
|
|
'reason': 'ologinerror',
|
|
'service_name': service_name,
|
|
'error_message': error_message or 'Could not load user data. The token may have expired',
|
|
'service_url': get_app_url(),
|
|
'user_creation': user_creation,
|
|
'register_redirect': register_redirect,
|
|
}
|
|
|
|
resp = index('', error_info=error_info)
|
|
resp.status_code = 400
|
|
return resp
|
|
|
|
def _perform_login(to_login, service_name):
|
|
""" Attempts to login the given user, returning the Flask result of whether the login succeeded.
|
|
"""
|
|
if common_login(to_login):
|
|
if model.user.has_user_prompts(to_login):
|
|
return redirect(url_for('web.updateuser'))
|
|
else:
|
|
return redirect(url_for('web.index'))
|
|
else:
|
|
return _render_ologin_error(service_name, 'Could not login. Account may be disabled')
|
|
|
|
def _attach_service(login_service, user_obj, lid, lusername):
|
|
""" Attaches the given user account to the given service, with the given service user ID and
|
|
service username.
|
|
"""
|
|
metadata = {
|
|
'service_username': lusername
|
|
}
|
|
|
|
try:
|
|
model.user.attach_federated_login(user_obj, login_service.service_id(), lid,
|
|
metadata=metadata)
|
|
return _oauthresult(to_login=user_obj)
|
|
except IntegrityError:
|
|
err = '%s account %s is already attached to a %s account' % (
|
|
login_service.service_name(), lusername, app.config['REGISTRY_TITLE_SHORT'])
|
|
return _oauthresult(service_name=login_service.service_name(), error_message=err)
|
|
|
|
def _register_service(login_service):
|
|
""" Registers the given login service, adding its callback and attach routes to the blueprint. """
|
|
|
|
@oauthlogin_csrf_protect
|
|
def callback_func():
|
|
# Check for a callback error.
|
|
error = request.args.get('error', None)
|
|
if error:
|
|
return _render_ologin_error(login_service.service_name(), error)
|
|
|
|
# Exchange the OAuth code for login information.
|
|
code = request.args.get('code')
|
|
try:
|
|
lid, lusername, lemail = login_service.exchange_code_for_login(app.config, client, code, '')
|
|
except OAuthLoginException as ole:
|
|
logger.exception('Got login exception')
|
|
return _render_ologin_error(login_service.service_name(), ole.message)
|
|
|
|
# Conduct login.
|
|
metadata = {
|
|
'service_username': lusername
|
|
}
|
|
|
|
result = _conduct_oauth_login(authentication, login_service, lid, lusername, lemail,
|
|
metadata=metadata)
|
|
return _get_response(result)
|
|
|
|
|
|
@require_session_login
|
|
@oauthlogin_csrf_protect
|
|
def attach_func():
|
|
# Check for a callback error.
|
|
error = request.args.get('error', None)
|
|
if error:
|
|
return _render_ologin_error(login_service.service_name(), error)
|
|
|
|
# Exchange the OAuth code for login information.
|
|
code = request.args.get('code')
|
|
try:
|
|
lid, lusername, _ = login_service.exchange_code_for_login(app.config, client, code, '/attach')
|
|
except OAuthLoginException as ole:
|
|
return _render_ologin_error(login_service.service_name(), ole.message)
|
|
|
|
# Conduct attach.
|
|
user_obj = get_authenticated_user()
|
|
result = _attach_service(login_service, user_obj, lid, lusername)
|
|
if result.error_message is not None:
|
|
return _get_response(result)
|
|
|
|
return redirect(url_for('web.user_view', path=user_obj.username, tab='external'))
|
|
|
|
|
|
oauthlogin.add_url_rule('/%s/callback' % login_service.service_id(),
|
|
'%s_oauth_callback' % login_service.service_id(),
|
|
callback_func,
|
|
methods=['GET'])
|
|
|
|
oauthlogin.add_url_rule('/%s/callback/attach' % login_service.service_id(),
|
|
'%s_oauth_attach' % login_service.service_id(),
|
|
attach_func,
|
|
methods=['GET'])
|
|
|
|
# Register the routes for each of the login services.
|
|
for current_service in oauth_login.services:
|
|
_register_service(current_service)
|