From 3e7937994223f4089ebaae03af28e9169c3015b2 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Wed, 5 Nov 2014 16:43:37 -0500 Subject: [PATCH] - Make the OAuth config system centralized - Add support for Github Enterprise login --- app.py | 6 ++ config.py | 26 ++----- endpoints/callbacks.py | 66 +++++++--------- endpoints/common.py | 14 +++- endpoints/trigger.py | 6 +- static/directives/external-login-button.html | 11 ++- static/js/app.js | 48 +++++++++--- static/js/controllers.js | 14 +--- static/partials/repo-admin.html | 6 +- templates/base.html | 1 + util/oauth.py | 81 ++++++++++++++++++++ 11 files changed, 196 insertions(+), 83 deletions(-) create mode 100644 util/oauth.py diff --git a/app.py b/app.py index 3bca06cd4..33c22d818 100644 --- a/app.py +++ b/app.py @@ -19,6 +19,7 @@ from util.analytics import Analytics from util.exceptionlog import Sentry from util.queuemetrics import QueueMetrics from util.names import urn_generator +from util.oauth import GoogleOAuthConfig, GithubOAuthConfig from data.billing import Billing from data.buildlogs import BuildLogs from data.archivedlogs import LogArchive @@ -131,6 +132,11 @@ queue_metrics = QueueMetrics(app) authentication = UserAuthentication(app) userevents = UserEventsBuilderModule(app) +github_login = GithubOAuthConfig(app, 'GITHUB_LOGIN_CONFIG') +github_trigger = GithubOAuthConfig(app, 'GITHUB_TRIGGER_CONFIG') +google_login = GoogleOAuthConfig(app, 'GOOGLE_LOGIN_CONFIG') +oauth_apps = [github_login, github_trigger, google_login] + tf = app.config['DB_TRANSACTION_FACTORY'] image_diff_queue = WorkQueue(app.config['DIFFS_QUEUE_NAME'], tf) dockerfile_build_queue = WorkQueue(app.config['DOCKERFILE_BUILD_QUEUE_NAME'], tf, diff --git a/config.py b/config.py index 72f262415..ddb6b54f7 100644 --- a/config.py +++ b/config.py @@ -15,11 +15,10 @@ def build_requests_session(): # The set of configuration key names that will be accessible in the client. Since these -# values are set to the frontend, DO NOT PLACE ANY SECRETS OR KEYS in this list. -CLIENT_WHITELIST = ['SERVER_HOSTNAME', 'PREFERRED_URL_SCHEME', 'GITHUB_CLIENT_ID', - 'GITHUB_LOGIN_CLIENT_ID', 'MIXPANEL_KEY', 'STRIPE_PUBLISHABLE_KEY', - 'ENTERPRISE_LOGO_URL', 'SENTRY_PUBLIC_DSN', 'AUTHENTICATION_TYPE', - 'REGISTRY_TITLE', 'REGISTRY_TITLE_SHORT', 'GOOGLE_LOGIN_CLIENT_ID', +# values are sent to the frontend, DO NOT PLACE ANY SECRETS OR KEYS in this list. +CLIENT_WHITELIST = ['SERVER_HOSTNAME', 'PREFERRED_URL_SCHEME', 'MIXPANEL_KEY', + 'STRIPE_PUBLISHABLE_KEY', 'ENTERPRISE_LOGO_URL', 'SENTRY_PUBLIC_DSN', + 'AUTHENTICATION_TYPE', 'REGISTRY_TITLE', 'REGISTRY_TITLE_SHORT', 'CONTACT_INFO'] @@ -108,22 +107,11 @@ class DefaultConfig(object): SENTRY_PUBLIC_DSN = None # Github Config - GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token' - GITHUB_USER_URL = 'https://api.github.com/user' - GITHUB_USER_EMAILS = GITHUB_USER_URL + '/emails' - - GITHUB_CLIENT_ID = '' - GITHUB_CLIENT_SECRET = '' - - GITHUB_LOGIN_CLIENT_ID = '' - GITHUB_LOGIN_CLIENT_SECRET = '' + GITHUB_LOGIN_CONFIG = None + GITHUB_TRIGGER_CONFIG = None # Google Config. - GOOGLE_TOKEN_URL = 'https://accounts.google.com/o/oauth2/token' - GOOGLE_USER_URL = 'https://www.googleapis.com/oauth2/v1/userinfo' - - GOOGLE_LOGIN_CLIENT_ID = '' - GOOGLE_LOGIN_CLIENT_SECRET = '' + GOOGLE_LOGIN_CONFIG = None # Requests based HTTP client with a large request pool HTTPCLIENT = build_requests_session() diff --git a/endpoints/callbacks.py b/endpoints/callbacks.py index 95fdaa5d5..66bb79f08 100644 --- a/endpoints/callbacks.py +++ b/endpoints/callbacks.py @@ -1,10 +1,11 @@ import logging +import requests from flask import request, redirect, url_for, Blueprint from flask.ext.login import current_user from endpoints.common import render_page_template, common_login, route_show_if -from app import app, analytics, get_app_url +from app import app, analytics, get_app_url, github_login, google_login, github_trigger from data import model from util.names import parse_repository_name from util.validation import generate_valid_usernames @@ -29,20 +30,16 @@ def render_ologin_error(service_name, service_url=get_app_url(), user_creation=features.USER_CREATION) -def exchange_code_for_token(code, service_name='GITHUB', for_login=True, form_encode=False, - redirect_suffix=''): +def exchange_code_for_token(code, service, form_encode=False, redirect_suffix=''): code = request.args.get('code') - id_config = service_name + '_LOGIN_CLIENT_ID' if for_login else service_name + '_CLIENT_ID' - secret_config = service_name + '_LOGIN_CLIENT_SECRET' if for_login else service_name + '_CLIENT_SECRET' - payload = { - 'client_id': app.config[id_config], - 'client_secret': app.config[secret_config], + 'client_id': service.client_id(), + 'client_secret': service.client_secret(), 'code': code, 'grant_type': 'authorization_code', 'redirect_uri': '%s://%s/oauth2/%s/callback%s' % (app.config['PREFERRED_URL_SCHEME'], app.config['SERVER_HOSTNAME'], - service_name.lower(), + service.service_name().lower(), redirect_suffix) } @@ -50,12 +47,11 @@ def exchange_code_for_token(code, service_name='GITHUB', for_login=True, form_en 'Accept': 'application/json' } + token_url = service.token_endpoint() if form_encode: - get_access_token = client.post(app.config[service_name + '_TOKEN_URL'], - data=payload, headers=headers) + get_access_token = client.post(token_url, data=payload, headers=headers) else: - get_access_token = client.post(app.config[service_name + '_TOKEN_URL'], - params=payload, headers=headers) + get_access_token = client.post(token_url, params=payload, headers=headers) json_data = get_access_token.json() if not json_data: @@ -65,25 +61,20 @@ def exchange_code_for_token(code, service_name='GITHUB', for_login=True, form_en return token -def get_github_user(token): - token_param = { - 'access_token': token, - } - get_user = client.get(app.config['GITHUB_USER_URL'], params=token_param) - - return get_user.json() - - -def get_google_user(token): +def get_user(service, token): token_param = { 'access_token': token, 'alt': 'json', } + get_user = client.get(service.user_endpoint(), params=token_param) + if get_user.status_code != requests.codes.ok: + return {} - get_user = client.get(app.config['GOOGLE_USER_URL'], params=token_param) return get_user.json() -def conduct_oauth_login(service_name, user_id, username, email, metadata={}): + +def conduct_oauth_login(service, user_id, username, email, metadata={}): + service_name = service.service_name() to_login = model.verify_federated_login(service_name.lower(), user_id) if not to_login: # See if we can create a new user. @@ -138,8 +129,8 @@ def google_oauth_callback(): if error: return render_ologin_error('Google', error) - token = exchange_code_for_token(request.args.get('code'), service_name='GOOGLE', form_encode=True) - user_data = get_google_user(token) + token = exchange_code_for_token(request.args.get('code'), google_login, form_encode=True) + 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') @@ -148,7 +139,7 @@ def google_oauth_callback(): 'service_username': user_data['email'] } - return conduct_oauth_login('Google', user_data['id'], username, user_data['email'], + return conduct_oauth_login(google_login, user_data['id'], username, user_data['email'], metadata=metadata) @@ -159,8 +150,8 @@ def github_oauth_callback(): if error: return render_ologin_error('GitHub', error) - token = exchange_code_for_token(request.args.get('code'), service_name='GITHUB') - user_data = get_github_user(token) + token = exchange_code_for_token(request.args.get('code'), github_login) + user_data = get_user(github_login, token) if not user_data or not 'login' in user_data: return render_ologin_error('GitHub') @@ -174,7 +165,7 @@ def github_oauth_callback(): token_param = { 'access_token': token, } - get_email = client.get(app.config['GITHUB_USER_EMAILS'], params=token_param, + get_email = client.get(github_login.email_endpoint(), params=token_param, headers=v3_media_type) # We will accept any email, but we prefer the primary @@ -188,17 +179,17 @@ def github_oauth_callback(): 'service_username': username } - return conduct_oauth_login('github', github_id, username, found_email, metadata=metadata) + return conduct_oauth_login(github_login, github_id, username, found_email, metadata=metadata) @callback.route('/google/callback/attach', methods=['GET']) @route_show_if(features.GOOGLE_LOGIN) @require_session_login def google_oauth_attach(): - token = exchange_code_for_token(request.args.get('code'), service_name='GOOGLE', + token = exchange_code_for_token(request.args.get('code'), google_login, redirect_suffix='/attach', form_encode=True) - user_data = get_google_user(token) + user_data = get_user(google_login, token) if not user_data or not user_data.get('id', None): return render_ologin_error('Google') @@ -224,8 +215,8 @@ def google_oauth_attach(): @route_show_if(features.GITHUB_LOGIN) @require_session_login def github_oauth_attach(): - token = exchange_code_for_token(request.args.get('code'), service_name='GITHUB') - user_data = get_github_user(token) + token = exchange_code_for_token(request.args.get('code'), github_login) + user_data = get_user(github_login, token) if not user_data: return render_ologin_error('GitHub') @@ -255,8 +246,7 @@ def github_oauth_attach(): def attach_github_build_trigger(namespace, repository): permission = AdministerRepositoryPermission(namespace, repository) if permission.can(): - token = exchange_code_for_token(request.args.get('code'), service_name='GITHUB', - for_login=False) + token = exchange_code_for_token(request.args.get('code'), github_trigger) repo = model.get_repository(namespace, repository) if not repo: msg = 'Invalid repository: %s/%s' % (namespace, repository) diff --git a/endpoints/common.py b/endpoints/common.py index 647bf9a51..1fa5ae522 100644 --- a/endpoints/common.py +++ b/endpoints/common.py @@ -11,7 +11,8 @@ from random import SystemRandom from data import model from data.database import db -from app import app, login_manager, dockerfile_build_queue, notification_queue +from app import app, login_manager, dockerfile_build_queue, notification_queue, oauth_apps + from auth.permissions import QuayDeferredPermissionUser from auth import scopes from endpoints.api.discovery import swagger_route_data @@ -176,6 +177,16 @@ def render_page_template(name, **kwargs): external_styles = get_external_css(local=not app.config.get('USE_CDN', True)) external_scripts = get_external_javascript(local=not app.config.get('USE_CDN', True)) + def get_oauth_config(): + oauth_config = {} + for oauth_app in oauth_apps: + oauth_config[oauth_app.key_name] = { + 'CLIENT_ID': oauth_app.client_id(), + 'AUTHORIZE_ENDPOINT': oauth_app.authorize_endpoint() + } + + return oauth_config + contact_href = None if len(app.config.get('CONTACT_INFO', [])) == 1: contact_href = app.config['CONTACT_INFO'][0] @@ -189,6 +200,7 @@ def render_page_template(name, **kwargs): library_scripts=library_scripts, feature_set=json.dumps(features.get_features()), config_set=json.dumps(getFrontendVisibleConfig(app.config)), + oauth_set=json.dumps(get_oauth_config()), mixpanel_key=app.config.get('MIXPANEL_KEY', ''), google_analytics_key=app.config.get('GOOGLE_ANALYTICS_KEY', ''), sentry_public_dsn=app.config.get('SENTRY_PUBLIC_DSN', ''), diff --git a/endpoints/trigger.py b/endpoints/trigger.py index 289eb5937..ec4434858 100644 --- a/endpoints/trigger.py +++ b/endpoints/trigger.py @@ -8,7 +8,7 @@ import re from github import Github, UnknownObjectException, GithubException from tempfile import SpooledTemporaryFile -from app import app, userfiles as user_files +from app import app, userfiles as user_files, github_trigger from util.tarfileappender import TarfileAppender @@ -150,8 +150,8 @@ def raise_unsupported(): class GithubBuildTrigger(BuildTrigger): @staticmethod def _get_client(auth_token): - return Github(auth_token, client_id=app.config['GITHUB_CLIENT_ID'], - client_secret=app.config['GITHUB_CLIENT_SECRET']) + return Github(auth_token, client_id=github_trigger.client_id(), + client_secret=github_trigger.client_secret()) @classmethod def service_name(cls): diff --git a/static/directives/external-login-button.html b/static/directives/external-login-button.html index d241089d6..1afe0c71d 100644 --- a/static/directives/external-login-button.html +++ b/static/directives/external-login-button.html @@ -2,8 +2,15 @@ - Sign In with GitHub - Attach to GitHub Account + + Sign In with GitHub + Enterprise + + + Attach to GitHub + Enterprise + Account + diff --git a/static/js/app.js b/static/js/app.js index a689425f9..3e4ea32e9 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -620,7 +620,8 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading }]); - $provide.factory('TriggerService', ['UtilService', '$sanitize', function(UtilService, $sanitize) { + $provide.factory('TriggerService', ['UtilService', '$sanitize', 'KeyService', + function(UtilService, $sanitize, KeyService) { var triggerService = {}; var triggerTypes = { @@ -639,10 +640,29 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading 'type': 'option', 'name': 'branch_name' } - ] + ], + + 'get_redirect_url': function(namespace, repository) { + var redirect_uri = KeyService['githubRedirectUri'] + '/trigger/' + + namespace + '/' + repository; + + var authorize_url = KeyService['githubTriggerAuthorizeUrl']; + var client_id = KeyService['githubTriggerClientId']; + + return authorize_url + 'client_id=' + client_id + + '&scope=repo,user:email&redirect_uri=' + redirect_uri; + } } } + triggerService.getRedirectUrl = function(name, namespace, repository) { + var type = triggerTypes[name]; + if (!type) { + return ''; + } + return type['get_redirect_url'](namespace, repository); + }; + triggerService.getDescription = function(name, config) { var type = triggerTypes[name]; if (!type) { @@ -1693,21 +1713,29 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading $provide.factory('KeyService', ['$location', 'Config', function($location, Config) { var keyService = {} + var oauth = window.__oauth; keyService['stripePublishableKey'] = Config['STRIPE_PUBLISHABLE_KEY']; - keyService['githubClientId'] = Config['GITHUB_CLIENT_ID']; - keyService['githubLoginClientId'] = Config['GITHUB_LOGIN_CLIENT_ID']; - keyService['githubRedirectUri'] = Config.getUrl('/oauth2/github/callback'); + keyService['githubTriggerClientId'] = oauth['GITHUB_TRIGGER_CONFIG']['CLIENT_ID']; + keyService['githubLoginClientId'] = oauth['GITHUB_LOGIN_CONFIG']['CLIENT_ID']; + keyService['googleLoginClientId'] = oauth['GOOGLE_LOGIN_CONFIG']['CLIENT_ID']; - keyService['googleLoginClientId'] = Config['GOOGLE_LOGIN_CLIENT_ID']; + keyService['githubRedirectUri'] = Config.getUrl('/oauth2/github/callback'); keyService['googleRedirectUri'] = Config.getUrl('/oauth2/google/callback'); - keyService['googleLoginUrl'] = 'https://accounts.google.com/o/oauth2/auth?response_type=code&'; - keyService['githubLoginUrl'] = Config['GITHUB_LOGIN_URL'] + '?'; + keyService['githubLoginUrl'] = oauth['GITHUB_LOGIN_CONFIG']['AUTHORIZE_ENDPOINT']; + keyService['googleLoginUrl'] = oauth['GOOGLE_LOGIN_CONFIG']['AUTHORIZE_ENDPOINT']; + + keyService['githubTriggerAuthorizeUrl'] = oauth['GITHUB_LOGIN_CONFIG']['AUTHORIZE_ENDPOINT']; - keyService['googleLoginScope'] = 'openid email'; keyService['githubLoginScope'] = 'user:email'; + keyService['googleLoginScope'] = 'openid email'; + + keyService.isEnterprise = function(service) { + var isGithubEnterprise = keyService['githubLoginUrl'].indexOf('https://github.com/') < 0; + return service == 'github' && isGithubEnterprise; + }; keyService.getExternalLoginUrl = function(service, action) { var state_clause = ''; @@ -2688,6 +2716,8 @@ quayApp.directive('externalLoginButton', function () { }, controller: function($scope, $timeout, $interval, ApiService, KeyService, CookieService, Features, Config) { $scope.signingIn = false; + $scope.isEnterprise = KeyService.isEnterprise; + $scope.startSignin = function(service) { $scope.signInStarted({'service': service}); diff --git a/static/js/controllers.js b/static/js/controllers.js index aa02ed388..3a44a0e3c 100644 --- a/static/js/controllers.js +++ b/static/js/controllers.js @@ -1330,15 +1330,13 @@ function RepoAdminCtrl($scope, Restangular, ApiService, KeyService, TriggerServi var name = $routeParams.name; $scope.Features = Features; + $scope.TriggerService = TriggerService; + $scope.permissions = {'team': [], 'user': [], 'loading': 2}; $scope.logsShown = 0; $scope.deleting = false; $scope.permissionCache = {}; - - $scope.githubRedirectUri = KeyService.githubRedirectUri; - $scope.githubClientId = KeyService.githubClientId; - $scope.showTriggerSetupCounter = 0; $scope.getBadgeFormat = function(format, repo) { @@ -2030,12 +2028,10 @@ function V1Ctrl($scope, $location, UserService) { UserService.updateUserIn($scope); } -function NewRepoCtrl($scope, $location, $http, $timeout, UserService, ApiService, PlanService, KeyService, Features) { +function NewRepoCtrl($scope, $location, $http, $timeout, UserService, ApiService, PlanService, TriggerService, Features) { UserService.updateUserIn($scope); $scope.Features = Features; - $scope.githubRedirectUri = KeyService.githubRedirectUri; - $scope.githubClientId = KeyService.githubClientId; $scope.repo = { 'is_public': 0, @@ -2114,9 +2110,7 @@ function NewRepoCtrl($scope, $location, $http, $timeout, UserService, ApiService // Conduct the Github redirect if applicable. if ($scope.repo.initialize == 'github') { - window.location = 'https://github.com/login/oauth/authorize?client_id=' + $scope.githubClientId + - '&scope=repo,user:email&redirect_uri=' + $scope.githubRedirectUri + '/trigger/' + - repo.namespace + '/' + repo.name; + window.location = TriggerService.getRedirectUrl('github', repo.namespace, repo.name); return; } diff --git a/static/partials/repo-admin.html b/static/partials/repo-admin.html index 748f35715..27e4c8d64 100644 --- a/static/partials/repo-admin.html +++ b/static/partials/repo-admin.html @@ -306,7 +306,11 @@ diff --git a/templates/base.html b/templates/base.html index 2bb2ab618..317a3683e 100644 --- a/templates/base.html +++ b/templates/base.html @@ -44,6 +44,7 @@ window.__endpoints = {{ route_data|safe }}.apis; window.__features = {{ feature_set|safe }}; window.__config = {{ config_set|safe }}; + window.__oauth = {{ oauth_set|safe }}; window.__token = '{{ csrf_token() }}'; diff --git a/util/oauth.py b/util/oauth.py new file mode 100644 index 000000000..29c462bd2 --- /dev/null +++ b/util/oauth.py @@ -0,0 +1,81 @@ +import urlparse + +class OAuthConfig(object): + def __init__(self, app, key_name): + self.key_name = key_name + self.config = app.config.get(key_name, {}) + + def service_name(self): + raise NotImplementedError + + def token_endpoint(self): + raise NotImplementedError + + def user_endpoint(self): + raise NotImplementedError + + def login_endpoint(self): + raise NotImplementedError + + def client_id(self): + return self.config.get('CLIENT_ID') + + def client_secret(self): + return self.config.get('CLIENT_SECRET') + + def _get_url(self, endpoint, *args): + if not endpoint: + raise Exception('Missing endpoint configuration for OAuth config %s', self.key_name) + + for arg in args: + endpoint = urlparse.urljoin(endpoint, arg) + + return endpoint + + +class GithubOAuthConfig(OAuthConfig): + def __init__(self, app, key_name): + super(GithubOAuthConfig, self).__init__(app, key_name) + + def service_name(self): + return 'GitHub' + + def authorize_endpoint(self): + endpoint = self.config.get('GITHUB_ENDPOINT') + return self._get_url(endpoint, '/login/oauth/authorize') + '?' + + def token_endpoint(self): + endpoint = self.config.get('GITHUB_ENDPOINT') + return self._get_url(endpoint, '/login/oauth/access_token') + + def _api_endpoint(self): + endpoint = self.config.get('GITHUB_ENDPOINT') + return self.config.get('API_ENDPOINT', self._get_url(endpoint, '/api/v3/')) + + def user_endpoint(self): + api_endpoint = self._api_endpoint() + return self._get_url(api_endpoint, 'user') + + def email_endpoint(self): + api_endpoint = self._api_endpoint() + return self._get_url(api_endpoint, 'user/emails') + + +class GoogleOAuthConfig(OAuthConfig): + def __init__(self, app, key_name): + super(GoogleOAuthConfig, self).__init__(app, key_name) + + 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' + + +