Lay foundation for truly dynamic external logins
Moves all the external login services into a set of classes that share as much code as possible. These services are then registered on both the client and server, allowing us in the followup change to dynamically register new handlers
This commit is contained in:
parent
4755d08677
commit
19f7acf575
26 changed files with 686 additions and 472 deletions
|
@ -702,16 +702,16 @@ class GenerateExternalToken(ApiResource):
|
|||
return {'token': generate_csrf_token(OAUTH_CSRF_TOKEN_NAME)}
|
||||
|
||||
|
||||
@resource('/v1/detachexternal/<servicename>')
|
||||
@resource('/v1/detachexternal/<service_id>')
|
||||
@show_if(features.DIRECT_LOGIN)
|
||||
@internal_only
|
||||
class DetachExternal(ApiResource):
|
||||
""" Resource for detaching an external login. """
|
||||
@require_user_admin
|
||||
@nickname('detachExternalLogin')
|
||||
def post(self, servicename):
|
||||
def post(self, service_id):
|
||||
""" Request that the current user be detached from the external login service. """
|
||||
model.user.detach_external_login(get_authenticated_user(), servicename)
|
||||
model.user.detach_external_login(get_authenticated_user(), service_id)
|
||||
return {'success': True}
|
||||
|
||||
|
||||
|
|
|
@ -16,7 +16,7 @@ from flask_principal import identity_changed
|
|||
import endpoints.decorated # Register the various exceptions via decorators.
|
||||
import features
|
||||
|
||||
from app import app, oauth_apps, LoginWrappedDBUser, user_analytics, license_validator
|
||||
from app import app, oauth_apps, oauth_login, LoginWrappedDBUser, user_analytics, license_validator
|
||||
from auth import scopes
|
||||
from auth.permissions import QuayDeferredPermissionUser
|
||||
from config import frontend_visible_config
|
||||
|
@ -189,6 +189,19 @@ def render_page_template(name, route_data=None, **kwargs):
|
|||
cache_buster = cachebusters.get(filename, random_string()) if not debugging else 'debugging'
|
||||
yield (filename, cache_buster)
|
||||
|
||||
def get_external_login_config():
|
||||
login_config = []
|
||||
for login_service in oauth_login.services:
|
||||
login_config.append({
|
||||
'id': login_service.service_id(),
|
||||
'title': login_service.service_name(),
|
||||
'config': login_service.get_public_config(),
|
||||
'icon': login_service.get_icon(),
|
||||
'scopes': login_service.get_login_scopes(),
|
||||
})
|
||||
|
||||
return login_config
|
||||
|
||||
def get_oauth_config():
|
||||
oauth_config = {}
|
||||
for oauth_app in oauth_apps:
|
||||
|
@ -215,6 +228,7 @@ def render_page_template(name, route_data=None, **kwargs):
|
|||
feature_set=features.get_features(),
|
||||
config_set=frontend_visible_config(app.config),
|
||||
oauth_set=get_oauth_config(),
|
||||
external_login_set=get_external_login_config(),
|
||||
scope_set=scopes.app_scopes(app.config),
|
||||
vuln_priority_set=PRIORITY_LEVELS,
|
||||
enterprise_logo=app.config.get('ENTERPRISE_LOGO_URL', ''),
|
||||
|
|
|
@ -1,19 +1,18 @@
|
|||
import logging
|
||||
import requests
|
||||
|
||||
from flask import request, redirect, url_for, Blueprint
|
||||
from flask_login import current_user
|
||||
from peewee import IntegrityError
|
||||
|
||||
import features
|
||||
|
||||
from app import app, analytics, get_app_url, github_login, google_login
|
||||
from app import app, analytics, get_app_url, oauth_login
|
||||
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, route_show_if
|
||||
from endpoints.common import common_login
|
||||
from endpoints.web import index
|
||||
from endpoints.csrf import csrf_protect, OAUTH_CSRF_TOKEN_NAME
|
||||
from util.security.jwtutil import decode, InvalidTokenError
|
||||
from oauth.login import OAuthLoginException
|
||||
from util.validation import generate_valid_usernames
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
@ -22,7 +21,9 @@ oauthlogin = Blueprint('oauthlogin', __name__)
|
|||
|
||||
oauthlogin_csrf_protect = csrf_protect(OAUTH_CSRF_TOKEN_NAME, 'state', all_methods=True)
|
||||
|
||||
def render_ologin_error(service_name, error_message=None, register_redirect=False):
|
||||
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',
|
||||
|
@ -37,27 +38,15 @@ def render_ologin_error(service_name, error_message=None, register_redirect=Fals
|
|||
resp.status_code = 400
|
||||
return resp
|
||||
|
||||
def _conduct_oauth_login(service_id, service_name, user_id, username, email, metadata=None):
|
||||
""" Conducts login from the result of an OAuth service's login flow. """
|
||||
|
||||
def get_user(service, token):
|
||||
token_param = {
|
||||
'access_token': token,
|
||||
'alt': 'json',
|
||||
}
|
||||
got_user = client.get(service.user_endpoint(), params=token_param)
|
||||
if got_user.status_code != requests.codes.ok:
|
||||
return {}
|
||||
|
||||
return got_user.json()
|
||||
|
||||
|
||||
def conduct_oauth_login(service, user_id, username, email, metadata=None):
|
||||
service_name = service.service_name()
|
||||
to_login = model.user.verify_federated_login(service_name.lower(), user_id)
|
||||
to_login = model.user.verify_federated_login(service_id, user_id)
|
||||
if not to_login:
|
||||
# See if we can create a new user.
|
||||
if not features.USER_CREATION:
|
||||
error_message = 'User creation is disabled. Please contact your administrator'
|
||||
return render_ologin_error(service_name, error_message)
|
||||
return _render_ologin_error(service_name, error_message)
|
||||
|
||||
# Try to create the user
|
||||
try:
|
||||
|
@ -70,7 +59,7 @@ def conduct_oauth_login(service, user_id, username, email, metadata=None):
|
|||
break
|
||||
|
||||
prompts = model.user.get_default_user_prompts(features)
|
||||
to_login = model.user.create_federated_user(new_username, email, service_name.lower(),
|
||||
to_login = model.user.create_federated_user(new_username, email, service_id,
|
||||
user_id, set_password_notification=True,
|
||||
metadata=metadata or {},
|
||||
prompts=prompts)
|
||||
|
@ -84,10 +73,10 @@ def conduct_oauth_login(service, user_id, username, email, metadata=None):
|
|||
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 render_ologin_error(service_name, message, register_redirect=True)
|
||||
return _render_ologin_error(service_name, message, register_redirect=True)
|
||||
|
||||
except model.DataModelException as ex:
|
||||
return render_ologin_error(service_name, ex.message)
|
||||
return _render_ologin_error(service_name, ex.message)
|
||||
|
||||
if common_login(to_login):
|
||||
if model.user.has_user_prompts(to_login):
|
||||
|
@ -95,189 +84,78 @@ def conduct_oauth_login(service, user_id, username, email, metadata=None):
|
|||
else:
|
||||
return redirect(url_for('web.index'))
|
||||
|
||||
return render_ologin_error(service_name)
|
||||
return _render_ologin_error(service_name)
|
||||
|
||||
|
||||
def get_email_username(user_data):
|
||||
username = user_data['email']
|
||||
at = username.find('@')
|
||||
if at > 0:
|
||||
username = username[0:at]
|
||||
def _register_service(login_service):
|
||||
""" Registers the given login service, adding its callback and attach routes to the blueprint. """
|
||||
|
||||
return username
|
||||
@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:
|
||||
return _render_ologin_error(login_service.service_name(), ole.message)
|
||||
|
||||
# Conduct login.
|
||||
metadata = {
|
||||
'service_username': lusername
|
||||
}
|
||||
|
||||
return _conduct_oauth_login(login_service.service_id(), login_service.service_name(), lid,
|
||||
lusername, lemail, metadata=metadata)
|
||||
|
||||
|
||||
@oauthlogin.route('/google/callback', methods=['GET'])
|
||||
@route_show_if(features.GOOGLE_LOGIN)
|
||||
@oauthlogin_csrf_protect
|
||||
def google_oauth_callback():
|
||||
error = request.args.get('error', None)
|
||||
if error:
|
||||
return render_ologin_error('Google', error)
|
||||
@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)
|
||||
|
||||
code = request.args.get('code')
|
||||
token = google_login.exchange_code_for_token(app.config, client, code, form_encode=True)
|
||||
if token is None:
|
||||
return render_ologin_error('Google')
|
||||
# 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)
|
||||
|
||||
user_data = get_user(google_login, token)
|
||||
if not user_data or not user_data.get('id', None) or not user_data.get('email', None):
|
||||
return render_ologin_error('Google')
|
||||
# Conduct attach.
|
||||
metadata = {
|
||||
'service_username': lusername
|
||||
}
|
||||
|
||||
if not user_data.get('verified_email', False):
|
||||
return render_ologin_error(
|
||||
'Google',
|
||||
'A verified e-mail address is required for login. Please verify your ' +
|
||||
'e-mail address in Google and try again.',
|
||||
)
|
||||
user_obj = get_authenticated_user()
|
||||
|
||||
username = get_email_username(user_data)
|
||||
metadata = {
|
||||
'service_username': user_data['email']
|
||||
}
|
||||
try:
|
||||
model.user.attach_federated_login(user_obj, login_service.service_id(), lid,
|
||||
metadata=metadata)
|
||||
except IntegrityError:
|
||||
err = '%s account %s is already attached to a %s account' % (
|
||||
login_service.service_name(), lusername, app.config['REGISTRY_TITLE_SHORT'])
|
||||
return _render_ologin_error(login_service.service_name(), err)
|
||||
|
||||
return conduct_oauth_login(google_login, user_data['id'], username, user_data['email'],
|
||||
metadata=metadata)
|
||||
return redirect(url_for('web.user_view', path=user_obj.username, tab='external'))
|
||||
|
||||
|
||||
@oauthlogin.route('/github/callback', methods=['GET'])
|
||||
@route_show_if(features.GITHUB_LOGIN)
|
||||
@oauthlogin_csrf_protect
|
||||
def github_oauth_callback():
|
||||
error = request.args.get('error', None)
|
||||
if error:
|
||||
return render_ologin_error('GitHub', error)
|
||||
|
||||
# Exchange the OAuth code.
|
||||
code = request.args.get('code')
|
||||
token = github_login.exchange_code_for_token(app.config, client, code)
|
||||
if token is None:
|
||||
return render_ologin_error('GitHub')
|
||||
|
||||
# Retrieve the user's information.
|
||||
user_data = get_user(github_login, token)
|
||||
if not user_data or 'login' not in user_data:
|
||||
return render_ologin_error('GitHub')
|
||||
|
||||
username = user_data['login']
|
||||
github_id = user_data['id']
|
||||
|
||||
v3_media_type = {
|
||||
'Accept': 'application/vnd.github.v3'
|
||||
}
|
||||
|
||||
token_param = {
|
||||
'access_token': token,
|
||||
}
|
||||
|
||||
# Retrieve the user's orgnizations (if organization filtering is turned on)
|
||||
if github_login.allowed_organizations() is not None:
|
||||
get_orgs = client.get(github_login.orgs_endpoint(), params=token_param,
|
||||
headers={'Accept': 'application/vnd.github.moondragon+json'})
|
||||
|
||||
organizations = set([org.get('login').lower() for org in get_orgs.json()])
|
||||
matching_organizations = organizations & set(github_login.allowed_organizations())
|
||||
if not matching_organizations:
|
||||
err = """You are not a member of an allowed GitHub organization.
|
||||
Please contact your system administrator if you believe this is in error."""
|
||||
return render_ologin_error('GitHub', err)
|
||||
|
||||
# Find the e-mail address for the user: we will accept any email, but we prefer the primary
|
||||
get_email = client.get(github_login.email_endpoint(), params=token_param,
|
||||
headers=v3_media_type)
|
||||
if get_email.status_code / 100 != 2:
|
||||
return render_ologin_error('GitHub')
|
||||
|
||||
found_email = None
|
||||
for user_email in get_email.json():
|
||||
if not github_login.is_enterprise() and not user_email['verified']:
|
||||
continue
|
||||
|
||||
found_email = user_email['email']
|
||||
if user_email['primary']:
|
||||
break
|
||||
|
||||
if found_email is None:
|
||||
err = 'There is no verified e-mail address attached to the GitHub account.'
|
||||
return render_ologin_error('GitHub', err)
|
||||
|
||||
metadata = {
|
||||
'service_username': username
|
||||
}
|
||||
|
||||
return conduct_oauth_login(github_login, github_id, username, found_email, metadata=metadata)
|
||||
|
||||
|
||||
@oauthlogin.route('/google/callback/attach', methods=['GET'])
|
||||
@route_show_if(features.GOOGLE_LOGIN)
|
||||
@require_session_login
|
||||
@oauthlogin_csrf_protect
|
||||
def google_oauth_attach():
|
||||
code = request.args.get('code')
|
||||
token = google_login.exchange_code_for_token(app.config, client, code,
|
||||
redirect_suffix='/attach', form_encode=True)
|
||||
if token is None:
|
||||
return render_ologin_error('Google')
|
||||
|
||||
user_data = get_user(google_login, token)
|
||||
if not user_data or not user_data.get('id', None):
|
||||
return render_ologin_error('Google')
|
||||
|
||||
if not user_data.get('verified_email', False):
|
||||
return render_ologin_error(
|
||||
'Google',
|
||||
'A verified e-mail address is required for login. Please verify your ' +
|
||||
'e-mail address in Google and try again.',
|
||||
)
|
||||
|
||||
google_id = user_data['id']
|
||||
user_obj = current_user.db_user()
|
||||
|
||||
username = get_email_username(user_data)
|
||||
metadata = {
|
||||
'service_username': user_data['email']
|
||||
}
|
||||
|
||||
try:
|
||||
model.user.attach_federated_login(user_obj, 'google', google_id, metadata=metadata)
|
||||
except IntegrityError:
|
||||
err = 'Google account %s is already attached to a %s account' % (
|
||||
username, app.config['REGISTRY_TITLE_SHORT'])
|
||||
return render_ologin_error('Google', err)
|
||||
|
||||
return redirect(url_for('web.user_view', path=user_obj.username, tab='external'))
|
||||
|
||||
|
||||
@oauthlogin.route('/github/callback/attach', methods=['GET'])
|
||||
@route_show_if(features.GITHUB_LOGIN)
|
||||
@require_session_login
|
||||
@oauthlogin_csrf_protect
|
||||
def github_oauth_attach():
|
||||
code = request.args.get('code')
|
||||
token = github_login.exchange_code_for_token(app.config, client, code)
|
||||
if token is None:
|
||||
return render_ologin_error('GitHub')
|
||||
|
||||
user_data = get_user(github_login, token)
|
||||
if not user_data:
|
||||
return render_ologin_error('GitHub')
|
||||
|
||||
github_id = user_data['id']
|
||||
user_obj = current_user.db_user()
|
||||
|
||||
username = user_data['login']
|
||||
metadata = {
|
||||
'service_username': username
|
||||
}
|
||||
|
||||
try:
|
||||
model.user.attach_federated_login(user_obj, 'github', github_id, metadata=metadata)
|
||||
except IntegrityError:
|
||||
err = 'Github account %s is already attached to a %s account' % (
|
||||
username, app.config['REGISTRY_TITLE_SHORT'])
|
||||
|
||||
return render_ologin_error('GitHub', err)
|
||||
|
||||
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)
|
||||
|
|
Reference in a new issue