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:
Joseph Schorr 2017-01-20 15:21:08 -05:00
parent 4755d08677
commit 19f7acf575
26 changed files with 686 additions and 472 deletions

View file

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

View file

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

View file

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