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
9
app.py
9
app.py
|
@ -25,13 +25,15 @@ from data.queue import WorkQueue, BuildMetricQueueReporter
|
|||
from data.userevent import UserEventsBuilderModule
|
||||
from data.userfiles import Userfiles
|
||||
from data.users import UserAuthentication
|
||||
from oauth.services.github import GithubOAuthService
|
||||
from oauth.services.gitlab import GitLabOAuthService
|
||||
from oauth.loginmanager import OAuthLoginManager
|
||||
from storage import Storage
|
||||
from util import get_app_url
|
||||
from util.saas.analytics import Analytics
|
||||
from util.saas.useranalytics import UserAnalytics
|
||||
from util.saas.exceptionlog import Sentry
|
||||
from util.names import urn_generator
|
||||
from util.oauth.services import GoogleOAuthService, GithubOAuthService, GitLabOAuthService
|
||||
from util.config.configutil import generate_secret_key
|
||||
from util.config.provider import get_config_provider
|
||||
from util.config.superusermanager import SuperUserManager
|
||||
|
@ -200,12 +202,11 @@ license_validator.start()
|
|||
|
||||
start_cloudwatch_sender(metric_queue, app)
|
||||
|
||||
github_login = GithubOAuthService(app.config, 'GITHUB_LOGIN_CONFIG')
|
||||
github_trigger = GithubOAuthService(app.config, 'GITHUB_TRIGGER_CONFIG')
|
||||
gitlab_trigger = GitLabOAuthService(app.config, 'GITLAB_TRIGGER_CONFIG')
|
||||
google_login = GoogleOAuthService(app.config, 'GOOGLE_LOGIN_CONFIG')
|
||||
|
||||
oauth_apps = [github_login, github_trigger, gitlab_trigger, google_login]
|
||||
oauth_login = OAuthLoginManager(app.config)
|
||||
oauth_apps = [github_trigger, gitlab_trigger]
|
||||
|
||||
image_replication_queue = WorkQueue(app.config['REPLICATION_QUEUE_NAME'], tf, has_namespace=False)
|
||||
dockerfile_build_queue = WorkQueue(app.config['DOCKERFILE_BUILD_QUEUE_NAME'], tf,
|
||||
|
|
|
@ -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.route('/google/callback', methods=['GET'])
|
||||
@route_show_if(features.GOOGLE_LOGIN)
|
||||
@oauthlogin_csrf_protect
|
||||
def google_oauth_callback():
|
||||
def callback_func():
|
||||
# Check for a callback error.
|
||||
error = request.args.get('error', None)
|
||||
if error:
|
||||
return render_ologin_error('Google', error)
|
||||
return _render_ologin_error(login_service.service_name(), error)
|
||||
|
||||
# Exchange the OAuth code for login information.
|
||||
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')
|
||||
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)
|
||||
|
||||
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')
|
||||
|
||||
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.',
|
||||
)
|
||||
|
||||
username = get_email_username(user_data)
|
||||
# Conduct login.
|
||||
metadata = {
|
||||
'service_username': user_data['email']
|
||||
'service_username': lusername
|
||||
}
|
||||
|
||||
return conduct_oauth_login(google_login, user_data['id'], username, user_data['email'],
|
||||
return _conduct_oauth_login(login_service.service_id(), login_service.service_name(), lid,
|
||||
lusername, lemail, metadata=metadata)
|
||||
|
||||
|
||||
@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.
|
||||
metadata = {
|
||||
'service_username': lusername
|
||||
}
|
||||
|
||||
user_obj = get_authenticated_user()
|
||||
|
||||
try:
|
||||
model.user.attach_federated_login(user_obj, login_service.service_id(), lid,
|
||||
metadata=metadata)
|
||||
|
||||
|
||||
@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)
|
||||
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 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)
|
||||
|
|
124
oauth/base.py
Normal file
124
oauth/base.py
Normal file
|
@ -0,0 +1,124 @@
|
|||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class OAuthExchangeCodeException(Exception):
|
||||
""" Exception raised if a code exchange fails. """
|
||||
pass
|
||||
|
||||
class OAuthGetUserInfoException(Exception):
|
||||
""" Exception raised if a call to get user information fails. """
|
||||
pass
|
||||
|
||||
|
||||
class OAuthService(object):
|
||||
""" A base class for defining an external service, exposed via OAuth. """
|
||||
def __init__(self, config, key_name):
|
||||
self.key_name = key_name
|
||||
self.config = config.get(key_name) or {}
|
||||
|
||||
def service_id(self):
|
||||
""" The internal ID for this service. Must match the URL portion for the service, e.g. `github`
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def service_name(self):
|
||||
""" The user-readable name for the service, e.g. `GitHub`"""
|
||||
raise NotImplementedError
|
||||
|
||||
def token_endpoint(self):
|
||||
""" The endpoint at which the OAuth code can be exchanged for a token. """
|
||||
raise NotImplementedError
|
||||
|
||||
def user_endpoint(self):
|
||||
""" The endpoint at which user information can be looked up. """
|
||||
raise NotImplementedError
|
||||
|
||||
def validate_client_id_and_secret(self, http_client, app_config):
|
||||
""" Performs validation of the client ID and secret, raising an exception on failure. """
|
||||
raise NotImplementedError
|
||||
|
||||
def requires_form_encoding(self):
|
||||
""" Returns True if form encoding is necessary for the exchange_code_for_token call. """
|
||||
return False
|
||||
|
||||
def client_id(self):
|
||||
return self.config.get('CLIENT_ID')
|
||||
|
||||
def client_secret(self):
|
||||
return self.config.get('CLIENT_SECRET')
|
||||
|
||||
def get_redirect_uri(self, app_config, redirect_suffix=''):
|
||||
return '%s://%s/oauth2/%s/callback%s' % (app_config['PREFERRED_URL_SCHEME'],
|
||||
app_config['SERVER_HOSTNAME'],
|
||||
self.service_name().lower(),
|
||||
redirect_suffix)
|
||||
|
||||
def get_user_info(self, http_client, token):
|
||||
token_param = {
|
||||
'access_token': token,
|
||||
'alt': 'json',
|
||||
}
|
||||
|
||||
got_user = http_client.get(self.user_endpoint(), params=token_param)
|
||||
if got_user.status_code // 100 != 2:
|
||||
raise OAuthGetUserInfoException('Non-2XX response code for user_info call: %s' %
|
||||
got_user.status_code)
|
||||
|
||||
user_info = got_user.json()
|
||||
if user_info is None:
|
||||
raise OAuthGetUserInfoException()
|
||||
|
||||
return user_info
|
||||
|
||||
def exchange_code_for_token(self, app_config, http_client, code, form_encode=False,
|
||||
redirect_suffix='', client_auth=False):
|
||||
""" Exchanges an OAuth access code for the associated OAuth token. """
|
||||
json_data = self._exchange_code(app_config, http_client, code, form_encode, redirect_suffix,
|
||||
client_auth)
|
||||
|
||||
access_token = json_data.get('access_token', None)
|
||||
if access_token is None:
|
||||
logger.debug('Got successful get_access_token response %s', json_data)
|
||||
raise OAuthExchangeCodeException('Missing `access_token` in OAuth response')
|
||||
|
||||
return access_token
|
||||
|
||||
def _exchange_code(self, app_config, http_client, code, form_encode=False, redirect_suffix='',
|
||||
client_auth=False):
|
||||
payload = {
|
||||
'code': code,
|
||||
'grant_type': 'authorization_code',
|
||||
'redirect_uri': self.get_redirect_uri(app_config, redirect_suffix)
|
||||
}
|
||||
|
||||
headers = {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
|
||||
auth = None
|
||||
if client_auth:
|
||||
auth = (self.client_id(), self.client_secret())
|
||||
else:
|
||||
payload['client_id'] = self.client_id()
|
||||
payload['client_secret'] = self.client_secret()
|
||||
|
||||
token_url = self.token_endpoint()
|
||||
if form_encode:
|
||||
get_access_token = http_client.post(token_url, data=payload, headers=headers, auth=auth)
|
||||
else:
|
||||
get_access_token = http_client.post(token_url, params=payload, headers=headers, auth=auth)
|
||||
|
||||
if get_access_token.status_code // 100 != 2:
|
||||
logger.debug('Got get_access_token response %s', get_access_token.text)
|
||||
raise OAuthExchangeCodeException('Got non-2XX response for code exchange: %s' %
|
||||
get_access_token.status_code)
|
||||
|
||||
json_data = get_access_token.json()
|
||||
if not json_data:
|
||||
raise OAuthExchangeCodeException('Got non-JSON response for code exchange')
|
||||
|
||||
if 'error' in json_data:
|
||||
raise OAuthExchangeCodeException(json_data.get('error_description', json_data['error']))
|
||||
|
||||
return json_data
|
81
oauth/login.py
Normal file
81
oauth/login.py
Normal file
|
@ -0,0 +1,81 @@
|
|||
import logging
|
||||
|
||||
import features
|
||||
|
||||
from oauth.base import OAuthService, OAuthExchangeCodeException, OAuthGetUserInfoException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class OAuthLoginException(Exception):
|
||||
""" Exception raised if a login operation fails. """
|
||||
pass
|
||||
|
||||
class OAuthLoginService(OAuthService):
|
||||
""" A base class for defining an OAuth-compliant service that can be used for, amongst other
|
||||
things, login and authentication. """
|
||||
|
||||
def get_login_service_id(self, user_info):
|
||||
""" Returns the internal ID for the given user under this login service. """
|
||||
raise NotImplementedError
|
||||
|
||||
def get_login_service_username(self, user_info):
|
||||
""" Returns the username for the given user under this login service. """
|
||||
raise NotImplementedError
|
||||
|
||||
def get_verified_user_email(self, app_config, http_client, token, user_info):
|
||||
""" Returns the verified email address for the given user, if any or None if none. """
|
||||
raise NotImplementedError
|
||||
|
||||
def get_icon(self):
|
||||
""" Returns the icon to display for this login service. """
|
||||
raise NotImplementedError
|
||||
|
||||
def get_login_scopes(self):
|
||||
""" Returns the list of scopes for login for this service. """
|
||||
raise NotImplementedError
|
||||
|
||||
def service_verify_user_info_for_login(self, app_config, http_client, token, user_info):
|
||||
""" Performs service-specific verification of user information for login. On failure, a service
|
||||
should raise a OAuthLoginService.
|
||||
"""
|
||||
# By default, does nothing.
|
||||
pass
|
||||
|
||||
def exchange_code_for_login(self, app_config, http_client, code, redirect_suffix):
|
||||
""" Exchanges the given OAuth access code for user information on behalf of a user trying to
|
||||
login or attach their account. Raises a OAuthLoginService exception on failure. Returns
|
||||
a tuple consisting of (service_id, service_username, email)
|
||||
"""
|
||||
|
||||
# Retrieve the token for the OAuth code.
|
||||
try:
|
||||
token = self.exchange_code_for_token(app_config, http_client, code,
|
||||
redirect_suffix=redirect_suffix,
|
||||
form_encode=self.requires_form_encoding())
|
||||
except OAuthExchangeCodeException as oce:
|
||||
raise OAuthLoginException(oce.message)
|
||||
|
||||
# Retrieve the user's information with the token.
|
||||
try:
|
||||
user_info = self.get_user_info(http_client, token)
|
||||
except OAuthGetUserInfoException as oge:
|
||||
raise OAuthLoginException(oge.message)
|
||||
|
||||
if user_info.get('id', None) is None:
|
||||
logger.debug('Got user info response %s', user_info)
|
||||
raise OAuthLoginException('Missing `id` column in returned user information')
|
||||
|
||||
# Perform any custom verification for this login service.
|
||||
self.service_verify_user_info_for_login(app_config, http_client, token, user_info)
|
||||
|
||||
# Retrieve the user's email address (if necessary).
|
||||
email_address = self.get_verified_user_email(app_config, http_client, token, user_info)
|
||||
if features.MAILING and email_address is None:
|
||||
raise OAuthLoginException('A verified email address is required to login with this service')
|
||||
|
||||
service_user_id = self.get_login_service_id(user_info)
|
||||
service_username = self.get_login_service_username(user_info)
|
||||
|
||||
logger.debug('Completed successful exchange for service %s: %s, %s, %s',
|
||||
self.service_id(), service_user_id, service_username, email_address)
|
||||
return (service_user_id, service_username, email_address)
|
19
oauth/loginmanager.py
Normal file
19
oauth/loginmanager.py
Normal file
|
@ -0,0 +1,19 @@
|
|||
import features
|
||||
|
||||
from oauth.services.github import GithubOAuthService
|
||||
from oauth.services.google import GoogleOAuthService
|
||||
|
||||
class OAuthLoginManager(object):
|
||||
""" Helper class which manages all registered OAuth login services. """
|
||||
def __init__(self, config):
|
||||
self.services = []
|
||||
|
||||
# Register the endpoints for each of the OAuth login services.
|
||||
# TODO(jschorr): make this dynamic.
|
||||
if config.get('GITHUB_LOGIN_CONFIG') is not None and features.GITHUB_LOGIN:
|
||||
github_service = GithubOAuthService(config, 'GITHUB_LOGIN_CONFIG')
|
||||
self.services.append(github_service)
|
||||
|
||||
if config.get('GOOGLE_LOGIN_CONFIG') is not None and features.GOOGLE_LOGIN:
|
||||
google_service = GoogleOAuthService(config, 'GOOGLE_LOGIN_CONFIG')
|
||||
self.services.append(google_service)
|
110
oauth/oidc.py
Normal file
110
oauth/oidc.py
Normal file
|
@ -0,0 +1,110 @@
|
|||
import time
|
||||
import json
|
||||
import logging
|
||||
import urlparse
|
||||
|
||||
from cachetools import lru_cache
|
||||
from cachetools.ttl import TTLCache
|
||||
|
||||
from util.oauth.base import OAuthService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def decode_user_jwt(token, oidc_provider):
|
||||
try:
|
||||
return decode(token, oidc_provider.get_public_key(), algorithms=['RS256'],
|
||||
audience=oidc_provider.client_id(),
|
||||
issuer=oidc_provider.issuer)
|
||||
except InvalidTokenError:
|
||||
# Public key may have expired. Try to retrieve an updated public key and use it to decode.
|
||||
return decode(token, oidc_provider.get_public_key(force_refresh=True), algorithms=['RS256'],
|
||||
audience=oidc_provider.client_id(),
|
||||
issuer=oidc_provider.issuer)
|
||||
|
||||
OIDC_WELLKNOWN = ".well-known/openid-configuration"
|
||||
PUBLIC_KEY_CACHE_TTL = 3600 # 1 hour
|
||||
|
||||
class OIDCConfig(OAuthService):
|
||||
def __init__(self, config, key_name):
|
||||
super(OIDCConfig, self).__init__(config, key_name)
|
||||
|
||||
self._public_key_cache = TTLCache(1, PUBLIC_KEY_CACHE_TTL, missing=self._get_public_key)
|
||||
self._config = config
|
||||
self._http_client = config['HTTPCLIENT']
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _oidc_config(self):
|
||||
if self.config.get('OIDC_SERVER'):
|
||||
return self._load_via_discovery(self._config.get('DEBUGGING', False))
|
||||
else:
|
||||
return {}
|
||||
|
||||
def _load_via_discovery(self, is_debugging):
|
||||
oidc_server = self.config['OIDC_SERVER']
|
||||
if not oidc_server.startswith('https://') and not is_debugging:
|
||||
raise Exception('OIDC server must be accessed over SSL')
|
||||
|
||||
discovery_url = urlparse.urljoin(oidc_server, OIDC_WELLKNOWN)
|
||||
discovery = self._http_client.get(discovery_url, timeout=5)
|
||||
|
||||
if discovery.status_code / 100 != 2:
|
||||
raise Exception("Could not load OIDC discovery information")
|
||||
|
||||
try:
|
||||
return json.loads(discovery.text)
|
||||
except ValueError:
|
||||
logger.exception('Could not parse OIDC discovery for url: %s', discovery_url)
|
||||
raise Exception("Could not parse OIDC discovery information")
|
||||
|
||||
def authorize_endpoint(self):
|
||||
return self._oidc_config().get('authorization_endpoint', '') + '?'
|
||||
|
||||
def token_endpoint(self):
|
||||
return self._oidc_config().get('token_endpoint')
|
||||
|
||||
def user_endpoint(self):
|
||||
return None
|
||||
|
||||
def validate_client_id_and_secret(self, http_client, app_config):
|
||||
pass
|
||||
|
||||
def get_public_config(self):
|
||||
return {
|
||||
'CLIENT_ID': self.client_id(),
|
||||
'AUTHORIZE_ENDPOINT': self.authorize_endpoint(),
|
||||
'OIDC': True,
|
||||
}
|
||||
|
||||
@property
|
||||
def issuer(self):
|
||||
return self.config.get('OIDC_ISSUER', self.config['OIDC_SERVER'])
|
||||
|
||||
def get_public_key(self, force_refresh=False):
|
||||
""" Retrieves the public key for this handler. """
|
||||
# If force_refresh is true, we expire all the items in the cache by setting the time to
|
||||
# the current time + the expiration TTL.
|
||||
if force_refresh:
|
||||
self._public_key_cache.expire(time=time.time() + PUBLIC_KEY_CACHE_TTL)
|
||||
|
||||
# Retrieve the public key from the cache. If the cache does not contain the public key,
|
||||
# it will internally call _get_public_key to retrieve it and then save it. The None is
|
||||
# a random key chose to be stored in the cache, and could be anything.
|
||||
return self._public_key_cache[None]
|
||||
|
||||
def _get_public_key(self, _):
|
||||
""" Retrieves the public key for this handler. """
|
||||
keys_url = self._oidc_config()['jwks_uri']
|
||||
|
||||
keys = KEYS()
|
||||
keys.load_from_url(keys_url)
|
||||
|
||||
if not list(keys):
|
||||
raise Exception('No keys provided by OIDC provider')
|
||||
|
||||
rsa_key = list(keys)[0]
|
||||
rsa_key.deserialize()
|
||||
|
||||
# Reload the key so that we can give a key *instance* to PyJWT to work around its weird parsing
|
||||
# issues.
|
||||
return load_der_public_key(rsa_key.key.exportKey('DER'), backend=default_backend())
|
0
oauth/services/__init__.py
Normal file
0
oauth/services/__init__.py
Normal file
|
@ -1,13 +1,32 @@
|
|||
from util import slash_join
|
||||
from util.oauth.base import OAuthService
|
||||
import logging
|
||||
|
||||
class GithubOAuthService(OAuthService):
|
||||
from oauth.login import OAuthLoginService, OAuthLoginException
|
||||
from util import slash_join
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class GithubOAuthService(OAuthLoginService):
|
||||
def __init__(self, config, key_name):
|
||||
super(GithubOAuthService, self).__init__(config, key_name)
|
||||
|
||||
def service_id(self):
|
||||
return 'github'
|
||||
|
||||
def service_name(self):
|
||||
if self.is_enterprise():
|
||||
return 'GitHub Enterprise'
|
||||
|
||||
return 'GitHub'
|
||||
|
||||
def get_icon(self):
|
||||
return 'fa-github'
|
||||
|
||||
def get_login_scopes(self):
|
||||
if self.config.get('ORG_RESTRICT'):
|
||||
return ['user:email', 'read:org']
|
||||
|
||||
return ['user:email']
|
||||
|
||||
def allowed_organizations(self):
|
||||
if not self.config.get('ORG_RESTRICT', False):
|
||||
return None
|
||||
|
@ -25,7 +44,7 @@ class GithubOAuthService(OAuthService):
|
|||
return self.config.get('GITHUB_ENDPOINT', 'https://github.com')
|
||||
|
||||
def is_enterprise(self):
|
||||
return self._endpoint().find('.github.com') < 0
|
||||
return self._api_endpoint().find('.github.com') < 0
|
||||
|
||||
def authorize_endpoint(self):
|
||||
return slash_join(self._endpoint(), '/login/oauth/authorize') + '?'
|
||||
|
@ -95,94 +114,63 @@ class GithubOAuthService(OAuthService):
|
|||
'ORG_RESTRICT': self.config.get('ORG_RESTRICT', False)
|
||||
}
|
||||
|
||||
def get_login_service_id(self, user_info):
|
||||
return user_info['id']
|
||||
|
||||
class GoogleOAuthService(OAuthService):
|
||||
def __init__(self, config, key_name):
|
||||
super(GoogleOAuthService, self).__init__(config, key_name)
|
||||
def get_login_service_username(self, user_info):
|
||||
return user_info['login']
|
||||
|
||||
def service_name(self):
|
||||
return 'Google'
|
||||
|
||||
def authorize_endpoint(self):
|
||||
return 'https://accounts.google.com/o/oauth2/auth?response_type=code&'
|
||||
|
||||
def token_endpoint(self):
|
||||
return 'https://accounts.google.com/o/oauth2/token'
|
||||
|
||||
def user_endpoint(self):
|
||||
return 'https://www.googleapis.com/oauth2/v1/userinfo'
|
||||
|
||||
def validate_client_id_and_secret(self, http_client, app_config):
|
||||
# To verify the Google client ID and secret, we hit the
|
||||
# https://www.googleapis.com/oauth2/v3/token endpoint with an invalid request. If the client
|
||||
# ID or secret are invalid, we get returned a 403 Unauthorized. Otherwise, we get returned
|
||||
# another response code.
|
||||
url = 'https://www.googleapis.com/oauth2/v3/token'
|
||||
data = {
|
||||
'code': 'fakecode',
|
||||
'client_id': self.client_id(),
|
||||
'client_secret': self.client_secret(),
|
||||
'grant_type': 'authorization_code',
|
||||
'redirect_uri': 'http://example.com'
|
||||
def get_verified_user_email(self, app_config, http_client, token, user_info):
|
||||
v3_media_type = {
|
||||
'Accept': 'application/vnd.github.v3'
|
||||
}
|
||||
|
||||
result = http_client.post(url, data=data, timeout=5)
|
||||
return result.status_code != 401
|
||||
|
||||
def get_public_config(self):
|
||||
return {
|
||||
'CLIENT_ID': self.client_id(),
|
||||
'AUTHORIZE_ENDPOINT': self.authorize_endpoint()
|
||||
token_param = {
|
||||
'access_token': token,
|
||||
}
|
||||
|
||||
# Find the e-mail address for the user: we will accept any email, but we prefer the primary
|
||||
get_email = http_client.get(self.email_endpoint(), params=token_param, headers=v3_media_type)
|
||||
if get_email.status_code // 100 != 2:
|
||||
raise OAuthLoginException('Got non-2XX status code for emails endpoint: %s' %
|
||||
get_email.status_code)
|
||||
|
||||
class GitLabOAuthService(OAuthService):
|
||||
def __init__(self, config, key_name):
|
||||
super(GitLabOAuthService, self).__init__(config, key_name)
|
||||
verified_emails = [email for email in get_email.json() if email['verified']]
|
||||
primary_emails = [email for email in get_email.json() if email['primary']]
|
||||
|
||||
def _endpoint(self):
|
||||
return self.config.get('GITLAB_ENDPOINT', 'https://gitlab.com')
|
||||
# Special case: We don't care about whether an e-mail address is "verified" under GHE.
|
||||
if self.is_enterprise() and not verified_emails:
|
||||
verified_emails = primary_emails
|
||||
|
||||
def user_endpoint(self):
|
||||
raise NotImplementedError
|
||||
allowed_emails = (primary_emails or verified_emails or [])
|
||||
return allowed_emails[0]['email'] if len(allowed_emails) > 0 else None
|
||||
|
||||
def api_endpoint(self):
|
||||
return self._endpoint()
|
||||
def service_verify_user_info_for_login(self, app_config, http_client, token, user_info):
|
||||
# Retrieve the user's orgnizations (if organization filtering is turned on)
|
||||
if self.allowed_organizations() is None:
|
||||
return
|
||||
|
||||
def get_public_url(self, suffix):
|
||||
return slash_join(self._endpoint(), suffix)
|
||||
|
||||
def service_name(self):
|
||||
return 'GitLab'
|
||||
|
||||
def authorize_endpoint(self):
|
||||
return slash_join(self._endpoint(), '/oauth/authorize')
|
||||
|
||||
def token_endpoint(self):
|
||||
return slash_join(self._endpoint(), '/oauth/token')
|
||||
|
||||
def validate_client_id_and_secret(self, http_client, app_config):
|
||||
url = self.token_endpoint()
|
||||
redirect_uri = self.get_redirect_uri(app_config, redirect_suffix='trigger')
|
||||
data = {
|
||||
'code': 'fakecode',
|
||||
'client_id': self.client_id(),
|
||||
'client_secret': self.client_secret(),
|
||||
'grant_type': 'authorization_code',
|
||||
'redirect_uri': redirect_uri
|
||||
moondragon_media_type = {
|
||||
'Accept': 'application/vnd.github.moondragon+json'
|
||||
}
|
||||
|
||||
# We validate by checking the error code we receive from this call.
|
||||
result = http_client.post(url, data=data, timeout=5)
|
||||
value = result.json()
|
||||
if not value:
|
||||
return False
|
||||
|
||||
return value.get('error', '') != 'invalid_client'
|
||||
|
||||
def get_public_config(self):
|
||||
return {
|
||||
'CLIENT_ID': self.client_id(),
|
||||
'AUTHORIZE_ENDPOINT': self.authorize_endpoint(),
|
||||
'GITLAB_ENDPOINT': self._endpoint(),
|
||||
token_param = {
|
||||
'access_token': token,
|
||||
}
|
||||
|
||||
get_orgs = http_client.get(self.orgs_endpoint(), params=token_param,
|
||||
headers=moondragon_media_type)
|
||||
|
||||
if get_orgs.status_code // 100 != 2:
|
||||
logger.debug('get_orgs response: %s', get_orgs.json())
|
||||
raise OAuthLoginException('Got non-2XX response for org lookup: %s' %
|
||||
get_orgs.status_code)
|
||||
|
||||
organizations = set([org.get('login').lower() for org in get_orgs.json()])
|
||||
matching_organizations = organizations & set(self.allowed_organizations())
|
||||
if not matching_organizations:
|
||||
logger.debug('Found organizations %s, but expected one of %s', organizations,
|
||||
self.allowed_organizations())
|
||||
err = """You are not a member of an allowed GitHub organization.
|
||||
Please contact your system administrator if you believe this is in error."""
|
||||
raise OAuthLoginException(err)
|
56
oauth/services/gitlab.py
Normal file
56
oauth/services/gitlab.py
Normal file
|
@ -0,0 +1,56 @@
|
|||
from oauth.base import OAuthService
|
||||
from util import slash_join
|
||||
|
||||
class GitLabOAuthService(OAuthService):
|
||||
def __init__(self, config, key_name):
|
||||
super(GitLabOAuthService, self).__init__(config, key_name)
|
||||
|
||||
def service_id(self):
|
||||
return 'gitlab'
|
||||
|
||||
def service_name(self):
|
||||
return 'GitLab'
|
||||
|
||||
def _endpoint(self):
|
||||
return self.config.get('GITLAB_ENDPOINT', 'https://gitlab.com')
|
||||
|
||||
def user_endpoint(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def api_endpoint(self):
|
||||
return self._endpoint()
|
||||
|
||||
def get_public_url(self, suffix):
|
||||
return slash_join(self._endpoint(), suffix)
|
||||
|
||||
def authorize_endpoint(self):
|
||||
return slash_join(self._endpoint(), '/oauth/authorize')
|
||||
|
||||
def token_endpoint(self):
|
||||
return slash_join(self._endpoint(), '/oauth/token')
|
||||
|
||||
def validate_client_id_and_secret(self, http_client, app_config):
|
||||
url = self.token_endpoint()
|
||||
redirect_uri = self.get_redirect_uri(app_config, redirect_suffix='trigger')
|
||||
data = {
|
||||
'code': 'fakecode',
|
||||
'client_id': self.client_id(),
|
||||
'client_secret': self.client_secret(),
|
||||
'grant_type': 'authorization_code',
|
||||
'redirect_uri': redirect_uri
|
||||
}
|
||||
|
||||
# We validate by checking the error code we receive from this call.
|
||||
result = http_client.post(url, data=data, timeout=5)
|
||||
value = result.json()
|
||||
if not value:
|
||||
return False
|
||||
|
||||
return value.get('error', '') != 'invalid_client'
|
||||
|
||||
def get_public_config(self):
|
||||
return {
|
||||
'CLIENT_ID': self.client_id(),
|
||||
'AUTHORIZE_ENDPOINT': self.authorize_endpoint(),
|
||||
'GITLAB_ENDPOINT': self._endpoint(),
|
||||
}
|
76
oauth/services/google.py
Normal file
76
oauth/services/google.py
Normal file
|
@ -0,0 +1,76 @@
|
|||
from oauth.login import OAuthLoginService
|
||||
|
||||
def _get_email_username(email_address):
|
||||
username = email_address
|
||||
at = username.find('@')
|
||||
if at > 0:
|
||||
username = username[0:at]
|
||||
|
||||
return username
|
||||
|
||||
class GoogleOAuthService(OAuthLoginService):
|
||||
def __init__(self, config, key_name):
|
||||
super(GoogleOAuthService, self).__init__(config, key_name)
|
||||
|
||||
def service_id(self):
|
||||
return 'google'
|
||||
|
||||
def service_name(self):
|
||||
return 'Google'
|
||||
|
||||
def get_icon(self):
|
||||
return 'fa-google'
|
||||
|
||||
def get_login_scopes(self):
|
||||
return ['openid', 'email']
|
||||
|
||||
def authorize_endpoint(self):
|
||||
return 'https://accounts.google.com/o/oauth2/auth?response_type=code&'
|
||||
|
||||
def token_endpoint(self):
|
||||
return 'https://accounts.google.com/o/oauth2/token'
|
||||
|
||||
def user_endpoint(self):
|
||||
return 'https://www.googleapis.com/oauth2/v1/userinfo'
|
||||
|
||||
def requires_form_encoding(self):
|
||||
return True
|
||||
|
||||
def validate_client_id_and_secret(self, http_client, app_config):
|
||||
# To verify the Google client ID and secret, we hit the
|
||||
# https://www.googleapis.com/oauth2/v3/token endpoint with an invalid request. If the client
|
||||
# ID or secret are invalid, we get returned a 403 Unauthorized. Otherwise, we get returned
|
||||
# another response code.
|
||||
url = 'https://www.googleapis.com/oauth2/v3/token'
|
||||
data = {
|
||||
'code': 'fakecode',
|
||||
'client_id': self.client_id(),
|
||||
'client_secret': self.client_secret(),
|
||||
'grant_type': 'authorization_code',
|
||||
'redirect_uri': 'http://example.com'
|
||||
}
|
||||
|
||||
result = http_client.post(url, data=data, timeout=5)
|
||||
return result.status_code != 401
|
||||
|
||||
def get_public_config(self):
|
||||
return {
|
||||
'CLIENT_ID': self.client_id(),
|
||||
'AUTHORIZE_ENDPOINT': self.authorize_endpoint()
|
||||
}
|
||||
|
||||
def get_login_service_id(self, user_info):
|
||||
return user_info['id']
|
||||
|
||||
def get_login_service_username(self, user_info):
|
||||
return _get_email_username(user_info['email'])
|
||||
|
||||
def get_verified_user_email(self, app_config, http_client, token, user_info):
|
||||
if not user_info.get('verified_email', False):
|
||||
return None
|
||||
|
||||
return user_info['email']
|
||||
|
||||
def service_verify_user_info_for_login(self, app_config, http_client, token, user_info):
|
||||
# Nothing to do.
|
||||
pass
|
|
@ -1,14 +1,13 @@
|
|||
<span class="external-login-button-element">
|
||||
<a ng-class="isLink ? '' : 'btn btn-primary btn-block'"
|
||||
ng-if="providerInfo.enabled" ng-click="startSignin()" style="margin-bottom: 10px"
|
||||
ng-click="startSignin()" style="margin-bottom: 10px"
|
||||
ng-disabled="signingIn">
|
||||
<img ng-src="{{ providerInfo.icon().url }}" ng-if="providerInfo.icon().url">
|
||||
<i class="fa" ng-class="providerInfo.icon().icon" ng-if="providerInfo.icon().icon"></i>
|
||||
<span class="icon-image-view" value="{{ provider.icon }}"></span>
|
||||
<span class="login-text" ng-if="action != 'attach'" style="vertical-align: middle">
|
||||
<span class="prefix">Sign in with </span><span class="suffix">{{ providerInfo.title() }}</span>
|
||||
<span class="prefix">Sign in with </span><span class="suffix">{{ provider.title }}</span>
|
||||
</span>
|
||||
<span class="login-text" ng-if="action == 'attach'" style="vertical-align: middle">
|
||||
Attach to {{ providerInfo.title() }}
|
||||
Attach to {{ provider.title }}
|
||||
</span>
|
||||
</a>
|
||||
</span>
|
||||
|
|
|
@ -14,27 +14,24 @@
|
|||
|
||||
<tr class="external-auth-provider" ng-repeat="provider in EXTERNAL_LOGINS">
|
||||
<td class="external-auth-provider-title">
|
||||
<img ng-src="{{ provider.icon().url }}" ng-if="provider.icon().url">
|
||||
<i class="fa" ng-class="provider.icon().icon" ng-if="provider.icon().icon"></i>
|
||||
{{ provider.title() }}
|
||||
<span class="icon-image-view" value="{{ provider.icon }}"></span>
|
||||
{{ provider.title }}
|
||||
</td>
|
||||
<td>
|
||||
<span ng-if="externalLoginInfo[provider.id]">
|
||||
Attached to {{ provider.title() }} account
|
||||
<b ng-if="provider.hasUserInfo">
|
||||
<a ng-href="{{ provider.getUserInfo(externalLoginInfo[provider.id]).endpoint }}" ng-safenewtab>
|
||||
{{ provider.getUserInfo(externalLoginInfo[provider.id]).username }}
|
||||
</a>
|
||||
Attached to {{ provider.title }} account
|
||||
<b ng-if="externalLoginInfo[provider.id].metadata.service_username">
|
||||
{{ externalLoginInfo[provider.id].metadata.service_username }}
|
||||
</b>
|
||||
</span>
|
||||
|
||||
<span class="empty" ng-if="!externalLoginInfo[provider.id]">
|
||||
Not attached to {{ provider.title() }}
|
||||
Not attached to {{ provider.title }}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<span class="external-login-button" provider="{{ provider.id }}" action="attach" is-link="true"
|
||||
<span class="external-login-button" provider="provider" action="attach" is-link="true"
|
||||
ng-if="!externalLoginInfo[provider.id]"></span>
|
||||
<a ng-if="externalLoginInfo[provider.id] && Features.DIRECT_LOGIN"
|
||||
ng-click="detachExternalLogin(provider.id)">Detach Account</a>
|
||||
|
|
4
static/directives/icon-image-view.html
Normal file
4
static/directives/icon-image-view.html
Normal file
|
@ -0,0 +1,4 @@
|
|||
<span class="icon-image-view-element">
|
||||
<img ng-src="{{ value }}" ng-if="value.indexOf('http') == 0">
|
||||
<i class="fa" ng-class="value" ng-if="value.indexOf('http') < 0"></i>
|
||||
</span>
|
|
@ -6,7 +6,7 @@
|
|||
</h4>
|
||||
|
||||
<div class="external-logins" quay-show="EXTERNAL_LOGINS.length" ng-class="EXTERNAL_LOGINS.length > 2 ? 'smaller': 'larger'">
|
||||
<div class="external-login-button" provider="{{ provider.id }}" redirect-url="redirectUrl"
|
||||
<div class="external-login-button" provider="provider" redirect-url="redirectUrl"
|
||||
sign-in-started="markStarted()" ng-repeat="provider in EXTERNAL_LOGINS" is-link="true"></div>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -12,12 +12,11 @@ angular.module('quay').directive('externalLoginButton', function () {
|
|||
'signInStarted': '&signInStarted',
|
||||
'redirectUrl': '=redirectUrl',
|
||||
'isLink': '=isLink',
|
||||
'provider': '@provider',
|
||||
'provider': '=provider',
|
||||
'action': '@action'
|
||||
},
|
||||
controller: function($scope, $timeout, $interval, ApiService, KeyService, CookieService, ExternalLoginService) {
|
||||
$scope.signingIn = false;
|
||||
$scope.providerInfo = ExternalLoginService.getProvider($scope.provider);
|
||||
|
||||
$scope.startSignin = function() {
|
||||
$scope.signInStarted({'service': $scope.provider});
|
||||
|
|
|
@ -34,11 +34,11 @@ angular.module('quay').directive('externalLoginsManager', function () {
|
|||
}
|
||||
});
|
||||
|
||||
$scope.detachExternalLogin = function(kind) {
|
||||
$scope.detachExternalLogin = function(service_id) {
|
||||
if (!Features.DIRECT_LOGIN) { return; }
|
||||
|
||||
var params = {
|
||||
'servicename': kind
|
||||
'service_id': service_id
|
||||
};
|
||||
|
||||
ApiService.detachExternalLogin(null, params).then(function() {
|
||||
|
|
18
static/js/directives/ui/icon-image-view.js
Normal file
18
static/js/directives/ui/icon-image-view.js
Normal file
|
@ -0,0 +1,18 @@
|
|||
/**
|
||||
* An element which displays either an icon or an image, depending on the value.
|
||||
*/
|
||||
angular.module('quay').directive('iconImageView', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/icon-image-view.html',
|
||||
replace: false,
|
||||
transclude: true,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'value': '@value'
|
||||
},
|
||||
controller: function($scope, $element) {
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
|
@ -1,19 +1,18 @@
|
|||
/**
|
||||
* Service which exposes the supported external logins.
|
||||
*/
|
||||
angular.module('quay').factory('ExternalLoginService', ['KeyService', 'Features', 'Config',
|
||||
function(KeyService, Features, Config) {
|
||||
angular.module('quay').factory('ExternalLoginService', ['Features', 'Config',
|
||||
function(Features, Config) {
|
||||
var externalLoginService = {};
|
||||
|
||||
externalLoginService.getLoginUrl = function(service, action) {
|
||||
var serviceInfo = externalLoginService.getProvider(service);
|
||||
if (!serviceInfo) { return ''; }
|
||||
externalLoginService.EXTERNAL_LOGINS = window.__external_login;
|
||||
|
||||
var loginUrl = KeyService.getConfiguration(serviceInfo.key, 'AUTHORIZE_ENDPOINT');
|
||||
var clientId = KeyService.getConfiguration(serviceInfo.key, 'CLIENT_ID');
|
||||
externalLoginService.getLoginUrl = function(loginService, action) {
|
||||
var loginUrl = loginService['config']['AUTHORIZE_ENDPOINT'];
|
||||
var clientId = loginService['config']['CLIENT_ID'];
|
||||
|
||||
var scope = serviceInfo.scopes();
|
||||
var redirectUri = Config.getUrl('/oauth2/' + service + '/callback');
|
||||
var scope = loginService.scopes.join(' ');
|
||||
var redirectUri = Config.getUrl('/oauth2/' + loginService['id'] + '/callback');
|
||||
|
||||
if (action == 'attach') {
|
||||
redirectUri += '/attach';
|
||||
|
@ -24,96 +23,6 @@ angular.module('quay').factory('ExternalLoginService', ['KeyService', 'Features'
|
|||
return url;
|
||||
};
|
||||
|
||||
var DEX = {
|
||||
id: 'dex',
|
||||
key: 'DEX_LOGIN_CONFIG',
|
||||
|
||||
title: function() {
|
||||
return KeyService.getConfiguration('DEX_LOGIN_CONFIG', 'OIDC_TITLE');
|
||||
},
|
||||
|
||||
icon: function() {
|
||||
return {'url': KeyService.getConfiguration('DEX_LOGIN_CONFIG', 'OIDC_LOGO') };
|
||||
},
|
||||
|
||||
scopes: function() {
|
||||
return 'openid email profile'
|
||||
},
|
||||
|
||||
enabled: Features.DEX_LOGIN
|
||||
};
|
||||
|
||||
var GITHUB = {
|
||||
id: 'github',
|
||||
key: 'GITHUB_LOGIN_CONFIG',
|
||||
|
||||
title: function() {
|
||||
return KeyService.isEnterprise('github') ? 'GitHub Enterprise' : 'GitHub';
|
||||
},
|
||||
|
||||
icon: function() {
|
||||
return {'icon': 'fa-github'};
|
||||
},
|
||||
|
||||
hasUserInfo: true,
|
||||
getUserInfo: function(service_info) {
|
||||
username = service_info['metadata']['service_username'];
|
||||
return {
|
||||
'username': username,
|
||||
'endpoint': KeyService['githubEndpoint'] + username
|
||||
}
|
||||
},
|
||||
|
||||
scopes: function() {
|
||||
var scopes = 'user:email';
|
||||
if (KeyService.getConfiguration('GITHUB_LOGIN_CONFIG', 'ORG_RESTRICT')) {
|
||||
scopes += ' read:org';
|
||||
}
|
||||
|
||||
return scopes;
|
||||
},
|
||||
|
||||
enabled: Features.GITHUB_LOGIN
|
||||
};
|
||||
|
||||
var GOOGLE = {
|
||||
id: 'google',
|
||||
key: 'GOOGLE_LOGIN_CONFIG',
|
||||
|
||||
title: function() {
|
||||
return 'Google';
|
||||
},
|
||||
|
||||
icon: function() {
|
||||
return {'icon': 'fa-google'};
|
||||
},
|
||||
|
||||
scopes: function() {
|
||||
return 'openid email';
|
||||
},
|
||||
|
||||
enabled: Features.GOOGLE_LOGIN
|
||||
};
|
||||
|
||||
externalLoginService.ALL_EXTERNAL_LOGINS = [
|
||||
DEX, GITHUB, GOOGLE
|
||||
];
|
||||
|
||||
externalLoginService.EXTERNAL_LOGINS = externalLoginService.ALL_EXTERNAL_LOGINS.filter(function(el) {
|
||||
return el.enabled;
|
||||
});
|
||||
|
||||
externalLoginService.getProvider = function(providerId) {
|
||||
for (var i = 0; i < externalLoginService.EXTERNAL_LOGINS.length; ++i) {
|
||||
var current = externalLoginService.EXTERNAL_LOGINS[i];
|
||||
if (current.id == providerId) {
|
||||
return current;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
externalLoginService.hasSingleSignin = function() {
|
||||
return externalLoginService.EXTERNAL_LOGINS.length == 1 && !Features.DIRECT_LOGIN;
|
||||
};
|
||||
|
@ -122,7 +31,7 @@ angular.module('quay').factory('ExternalLoginService', ['KeyService', 'Features'
|
|||
// If there is a single external login service and direct login is disabled,
|
||||
// then redirect to the external login directly.
|
||||
if (externalLoginService.hasSingleSignin()) {
|
||||
return externalLoginService.getLoginUrl(externalLoginService.EXTERNAL_LOGINS[0].id);
|
||||
return externalLoginService.getLoginUrl(externalLoginService.EXTERNAL_LOGINS[0]);
|
||||
}
|
||||
|
||||
return null;
|
||||
|
|
|
@ -39,6 +39,7 @@
|
|||
window.__features = {{ feature_set|tojson|safe }};
|
||||
window.__config = {{ config_set|tojson|safe }};
|
||||
window.__oauth = {{ oauth_set|tojson|safe }};
|
||||
window.__external_login = {{ external_login_set|tojson|safe }};
|
||||
window.__auth_scopes = {{ scope_set|tojson|safe }};
|
||||
window.__vuln_priority = {{ vuln_priority_set|tojson|safe }}
|
||||
window.__token = '{{ csrf_token() }}';
|
||||
|
|
|
@ -507,7 +507,7 @@ class TestSignin(ApiTestCase):
|
|||
class TestDetachExternal(ApiTestCase):
|
||||
def setUp(self):
|
||||
ApiTestCase.setUp(self)
|
||||
self._set_url(DetachExternal, servicename='someservice')
|
||||
self._set_url(DetachExternal, service_id='someservice')
|
||||
|
||||
def test_post_anonymous(self):
|
||||
self._run_test('POST', 401, None, {})
|
||||
|
|
|
@ -16,7 +16,7 @@ from Crypto.PublicKey import RSA
|
|||
from flask import url_for
|
||||
from jwkest.jwk import RSAKey
|
||||
|
||||
from app import app
|
||||
from app import app, oauth_login
|
||||
from data import model
|
||||
from data.database import ServiceKeyApprovalType
|
||||
from endpoints import keyserver
|
||||
|
@ -30,7 +30,7 @@ from initdb import setup_database_for_testing, finished_database_for_testing
|
|||
from test.helpers import assert_action_logged
|
||||
|
||||
try:
|
||||
app.register_blueprint(oauthlogin_bp, url_prefix='/oauth')
|
||||
app.register_blueprint(oauthlogin_bp, url_prefix='/oauth2')
|
||||
except ValueError:
|
||||
# This blueprint was already registered
|
||||
pass
|
||||
|
|
|
@ -76,6 +76,9 @@ class TestConfig(DefaultConfig):
|
|||
|
||||
PROMETHEUS_AGGREGATOR_URL = None
|
||||
|
||||
GITHUB_LOGIN_CONFIG = {}
|
||||
GOOGLE_LOGIN_CONFIG = {}
|
||||
|
||||
FEATURE_GITHUB_LOGIN = True
|
||||
FEATURE_GOOGLE_LOGIN = True
|
||||
FEATURE_DEX_LOGIN = True
|
||||
|
|
|
@ -1,63 +0,0 @@
|
|||
class OAuthService(object):
|
||||
""" A base class for defining an external service, exposed via OAuth. """
|
||||
def __init__(self, config, key_name):
|
||||
self.key_name = key_name
|
||||
self.config = config.get(key_name) or {}
|
||||
|
||||
def service_name(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def token_endpoint(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def user_endpoint(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def validate_client_id_and_secret(self, http_client, app_config):
|
||||
raise NotImplementedError
|
||||
|
||||
def client_id(self):
|
||||
return self.config.get('CLIENT_ID')
|
||||
|
||||
def client_secret(self):
|
||||
return self.config.get('CLIENT_SECRET')
|
||||
|
||||
def get_redirect_uri(self, app_config, redirect_suffix=''):
|
||||
return '%s://%s/oauth2/%s/callback%s' % (app_config['PREFERRED_URL_SCHEME'],
|
||||
app_config['SERVER_HOSTNAME'],
|
||||
self.service_name().lower(),
|
||||
redirect_suffix)
|
||||
|
||||
def exchange_code_for_token(self, app_config, http_client, code, form_encode=False,
|
||||
redirect_suffix='', client_auth=False):
|
||||
payload = {
|
||||
'code': code,
|
||||
'grant_type': 'authorization_code',
|
||||
'redirect_uri': self.get_redirect_uri(app_config, redirect_suffix)
|
||||
}
|
||||
|
||||
headers = {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
|
||||
auth = None
|
||||
if client_auth:
|
||||
auth = (self.client_id(), self.client_secret())
|
||||
else:
|
||||
payload['client_id'] = self.client_id()
|
||||
payload['client_secret'] = self.client_secret()
|
||||
|
||||
token_url = self.token_endpoint()
|
||||
if form_encode:
|
||||
get_access_token = http_client.post(token_url, data=payload, headers=headers, auth=auth)
|
||||
else:
|
||||
get_access_token = http_client.post(token_url, params=payload, headers=headers, auth=auth)
|
||||
|
||||
if get_access_token.status_code / 100 != 2:
|
||||
return None
|
||||
|
||||
json_data = get_access_token.json()
|
||||
if not json_data:
|
||||
return None
|
||||
|
||||
return json_data.get('access_token', None)
|
Reference in a new issue