From e724125459257e759f80b3bd2829fae69960ca31 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Thu, 8 Jun 2017 13:13:22 -0400 Subject: [PATCH] Add support for using OIDC tokens via the Docker CLI --- config.py | 2 +- data/users/__init__.py | 20 ++++++++- data/users/database.py | 4 ++ data/users/federated.py | 4 ++ data/users/oidc.py | 45 +++++++++++++++++++ data/users/test/test_oidc.py | 19 ++++++++ endpoints/api/user.py | 7 ++- endpoints/oauth/login.py | 23 ++++++++++ initdb.py | 1 + oauth/loginmanager.py | 1 - oauth/oidc.py | 13 ++++-- oauth/test/__init__.py | 0 oauth/test/test_oidc.py | 4 +- .../js/directives/ui/external-login-button.js | 1 - static/js/pages/user-view.js | 24 +++++++++- static/partials/user-view.html | 22 ++++++++- 16 files changed, 176 insertions(+), 14 deletions(-) create mode 100644 data/users/oidc.py create mode 100644 data/users/test/test_oidc.py create mode 100644 oauth/test/__init__.py diff --git a/config.py b/config.py index 005ea4880..709129d84 100644 --- a/config.py +++ b/config.py @@ -23,7 +23,7 @@ CLIENT_WHITELIST = ['SERVER_HOSTNAME', 'PREFERRED_URL_SCHEME', 'MIXPANEL_KEY', 'CONTACT_INFO', 'AVATAR_KIND', 'LOCAL_OAUTH_HANDLER', 'DOCUMENTATION_LOCATION', 'DOCUMENTATION_METADATA', 'SETUP_COMPLETE', 'DEBUG', 'MARKETO_MUNCHKIN_ID', 'STATIC_SITE_BUCKET', 'RECAPTCHA_SITE_KEY', 'CHANNEL_COLORS', - 'TAG_EXPIRATION_OPTIONS'] + 'TAG_EXPIRATION_OPTIONS', 'INTERNAL_OIDC_SERVICE_ID'] def frontend_visible_config(config_dict): diff --git a/data/users/__init__.py b/data/users/__init__.py index 519947f8d..913823f91 100644 --- a/data/users/__init__.py +++ b/data/users/__init__.py @@ -10,6 +10,7 @@ from data.users.database import DatabaseUsers from data.users.externalldap import LDAPUsers from data.users.externaljwt import ExternalJWTAuthN from data.users.keystone import get_keystone_users +from data.users.oidc import OIDCInternalAuth from util.security.aes import AESCipher logger = logging.getLogger(__name__) @@ -24,6 +25,9 @@ def get_federated_service_name(authentication_type): if authentication_type == 'Keystone': return 'keystone' + if authentication_type == 'OIDC': + return 'oidc' + raise Exception('Unknown auth type: %s' % authentication_type) @@ -74,8 +78,15 @@ def get_users_handler(config, _, override_config_dir): keystone_admin_password = config.get('KEYSTONE_ADMIN_PASSWORD') keystone_admin_tenant = config.get('KEYSTONE_ADMIN_TENANT') return get_keystone_users(auth_version, auth_url, keystone_admin_username, - keystone_admin_password, keystone_admin_tenant, timeout, - requires_email=features.MAILING) + keystone_admin_password, keystone_admin_tenant, timeout, + requires_email=features.MAILING) + + if authentication_type == 'OIDC': + if features.DIRECT_LOGIN: + raise Exception('Direct login feature must be disabled to use OIDC internal auth') + + login_service = config.get('INTERNAL_OIDC_SERVICE_ID') + return OIDCInternalAuth(config, login_service, requires_email=features.MAILING) raise RuntimeError('Unknown authentication type: %s' % authentication_type) @@ -160,6 +171,11 @@ class UserAuthentication(object): """ return self.state.federated_service + @property + def supports_encrypted_credentials(self): + """ Returns whether this auth system supports using encrypted credentials. """ + return self.state.supports_encrypted_credentials + def query_users(self, query, limit=20): """ Performs a lookup against the user system for the specified query. The returned tuple will be of the form (results, federated_login_id, err_msg). If the method is unsupported, diff --git a/data/users/database.py b/data/users/database.py index ce04b3c9d..09a8ccf7f 100644 --- a/data/users/database.py +++ b/data/users/database.py @@ -9,6 +9,10 @@ class DatabaseUsers(object): """ Always assumed to be working. If the DB is broken, other checks will handle it. """ return (True, None) + @property + def supports_encrypted_credentials(self): + return True + def verify_credentials(self, username_or_email, password): """ Simply delegate to the model implementation. """ result = model.user.verify_user(username_or_email, password) diff --git a/data/users/federated.py b/data/users/federated.py index 3c61a5837..944617a23 100644 --- a/data/users/federated.py +++ b/data/users/federated.py @@ -23,6 +23,10 @@ class FederatedUsers(object): def federated_service(self): return self._federated_service + @property + def supports_encrypted_credentials(self): + return True + def get_user(self, username_or_email): """ Retrieves the user with the given username or email, returning a tuple containing a UserInformation (if success) and the error message (on failure). diff --git a/data/users/oidc.py b/data/users/oidc.py new file mode 100644 index 000000000..1014b513a --- /dev/null +++ b/data/users/oidc.py @@ -0,0 +1,45 @@ +import logging + +from data.users.federated import FederatedUsers, UserInformation +from oauth.loginmanager import OAuthLoginManager +from oauth.oidc import PublicKeyLoadException +from util.security.jwtutil import InvalidTokenError + + +logger = logging.getLogger(__name__) + + +class OIDCInternalAuth(FederatedUsers): + """ Handles authentication by delegating authentication to a signed OIDC JWT produced by the + configured OIDC service. + """ + def __init__(self, config, login_service_id, requires_email): + super(OIDCInternalAuth, self).__init__('oidc', requires_email) + login_manager = OAuthLoginManager(config) + self.login_service = login_manager.get_service(login_service_id) + if self.login_service is None: + raise Exception('Unknown OIDC login service %s' % login_service_id) + + @property + def supports_encrypted_credentials(self): + # Since the "password" is already a signed JWT. + return False + + def get_user(self, username_or_email): + return (None, 'Cannot retrieve users for OIDC') + + def query_users(self, query, limit=20): + return (None, 'Cannot query users for OIDC') + + def verify_credentials(self, username_or_email, id_token): + try: + payload = self.login_service.decode_user_jwt(id_token) + except InvalidTokenError as ite: + logger.exception('Got invalid token error on OIDC decode: %s', ite.message) + return (None, 'Could not validate OIDC token') + except PublicKeyLoadException as pke: + logger.exception('Could not load public key during OIDC decode: %s', pke.message) + return (None, 'Could not validate OIDC token') + + user_info = UserInformation(username=payload['sub'], id=payload['sub'], email=None) + return (user_info, None) diff --git a/data/users/test/test_oidc.py b/data/users/test/test_oidc.py new file mode 100644 index 000000000..5d7a8bfd4 --- /dev/null +++ b/data/users/test/test_oidc.py @@ -0,0 +1,19 @@ +from httmock import HTTMock + +from data.users.oidc import OIDCInternalAuth +from oauth.test.test_oidc import (id_token, oidc_service, signing_key, jwks_handler, + discovery_handler, app_config, http_client, + discovery_content) + +def test_oidc_login(app_config, id_token, jwks_handler, discovery_handler): + internal_auth = OIDCInternalAuth(app_config, 'someoidc', False) + with HTTMock(jwks_handler, discovery_handler): + # Try a valid token. + (user, err) = internal_auth.verify_credentials('someusername', id_token) + assert err is None + assert user.username == 'cooluser' + + # Try an invalid token. + (user, err) = internal_auth.verify_credentials('someusername', 'invalidtoken') + assert err is not None + assert user is None diff --git a/endpoints/api/user.py b/endpoints/api/user.py index 90ef38603..db705a806 100644 --- a/endpoints/api/user.py +++ b/endpoints/api/user.py @@ -513,6 +513,9 @@ class ClientKey(ApiResource): @validate_json_request('GenerateClientKey') def post(self): """ Return's the user's private client key. """ + if not authentication.supports_encrypted_credentials: + raise NotFound() + username = get_authenticated_user().username password = request.get_json()['password'] (result, error_message) = authentication.confirm_existing_user(username, password) @@ -728,7 +731,7 @@ class ExternalLoginInformation(ApiResource): 'kind': { 'type': 'string', 'description': 'The kind of URL', - 'enum': ['login', 'attach'], + 'enum': ['login', 'attach', 'cli'], }, }, }, @@ -746,7 +749,7 @@ class ExternalLoginInformation(ApiResource): csrf_token = generate_csrf_token(OAUTH_CSRF_TOKEN_NAME) kind = request.get_json()['kind'] - redirect_suffix = '/attach' if kind == 'attach' else '' + redirect_suffix = '/' if kind == 'login' else '/' + kind try: login_scopes = login_service.get_login_scopes() diff --git a/endpoints/oauth/login.py b/endpoints/oauth/login.py index 0a5e76618..c2080560c 100644 --- a/endpoints/oauth/login.py +++ b/endpoints/oauth/login.py @@ -250,6 +250,24 @@ def _register_service(login_service): auth_url = login_service.get_auth_url(app.config, '', csrf_token, login_scopes) return redirect(auth_url) + @require_session_login + @oauthlogin_csrf_protect + def cli_token_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 the ID token. + code = request.args.get('code') + try: + idtoken, _ = login_service.exchange_code_for_tokens(app.config, client, code, '/cli') + except OAuthLoginException as ole: + return _render_ologin_error(login_service.service_name(), ole.message) + + user_obj = get_authenticated_user() + return redirect(url_for('web.user_view', path=user_obj.username, tab='settings', + idtoken=idtoken)) oauthlogin.add_url_rule('/%s/callback/captcha' % login_service.service_id(), '%s_oauth_captcha' % login_service.service_id(), @@ -266,6 +284,11 @@ def _register_service(login_service): attach_func, methods=['GET']) + oauthlogin.add_url_rule('/%s/callback/cli' % login_service.service_id(), + '%s_oauth_cli' % login_service.service_id(), + cli_token_func, + methods=['GET']) + # Register the routes for each of the login services. for current_service in oauth_login.services: _register_service(current_service) diff --git a/initdb.py b/initdb.py index c6b36e18e..2762751cf 100644 --- a/initdb.py +++ b/initdb.py @@ -267,6 +267,7 @@ def initialize_database(): LoginService.create(name='jwtauthn') LoginService.create(name='keystone') LoginService.create(name='dex') + LoginService.create(name='oidc') BuildTriggerService.create(name='github') BuildTriggerService.create(name='custom-git') diff --git a/oauth/loginmanager.py b/oauth/loginmanager.py index 2504d661b..ea45890ea 100644 --- a/oauth/loginmanager.py +++ b/oauth/loginmanager.py @@ -1,7 +1,6 @@ from oauth.services.github import GithubOAuthService from oauth.services.google import GoogleOAuthService from oauth.oidc import OIDCLoginService -from data.users import UserAuthentication CUSTOM_LOGIN_SERVICES = { 'GITHUB_LOGIN_CONFIG': GithubOAuthService, diff --git a/oauth/oidc.py b/oauth/oidc.py index 2b6e7cbc6..f5c9249a2 100644 --- a/oauth/oidc.py +++ b/oauth/oidc.py @@ -89,7 +89,7 @@ class OIDCLoginService(OAuthService): 'OIDC': True, } - def exchange_code_for_login(self, app_config, http_client, code, redirect_suffix): + def exchange_code_for_tokens(self, app_config, http_client, code, redirect_suffix): # Exchange the code for the access token and id_token try: json_data = self.exchange_code(app_config, http_client, code, @@ -109,9 +109,16 @@ class OIDCLoginService(OAuthService): logger.debug('Missing id_token in response: %s', json_data) raise OAuthLoginException('Missing `id_token` in OIDC response') + return id_token, access_token + + def exchange_code_for_login(self, app_config, http_client, code, redirect_suffix): + # Exchange the code for the access token and id_token + id_token, access_token = self.exchange_code_for_tokens(app_config, http_client, code, + redirect_suffix) + # Decode the id_token. try: - decoded_id_token = self._decode_user_jwt(id_token) + decoded_id_token = self.decode_user_jwt(id_token) except InvalidTokenError as ite: logger.exception('Got invalid token error on OIDC decode: %s', ite.message) raise OAuthLoginException('Could not decode OIDC token') @@ -181,7 +188,7 @@ class OIDCLoginService(OAuthService): logger.exception('Could not parse OIDC discovery for url: %s', discovery_url) raise DiscoveryFailureException("Could not parse OIDC discovery information") - def _decode_user_jwt(self, token): + def decode_user_jwt(self, token): """ Decodes the given JWT under the given provider and returns it. Raises an InvalidTokenError exception on an invalid token or a PublicKeyLoadException if the public key could not be loaded for decoding. diff --git a/oauth/test/__init__.py b/oauth/test/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/oauth/test/test_oidc.py b/oauth/test/test_oidc.py index 98e914638..b1ea38a83 100644 --- a/oauth/test/test_oidc.py +++ b/oauth/test/test_oidc.py @@ -60,7 +60,7 @@ def app_config(http_client, mailing_feature): 'SERVER_HOSTNAME': 'localhost', 'FEATURE_MAILING': mailing_feature, - 'SOMEOIDC_TEST_SERVICE': { + 'SOMEOIDC_LOGIN_CONFIG': { 'CLIENT_ID': 'foo', 'CLIENT_SECRET': 'bar', 'SERVICE_NAME': 'Some Cool Service', @@ -74,7 +74,7 @@ def app_config(http_client, mailing_feature): @pytest.fixture() def oidc_service(app_config): - return OIDCLoginService(app_config, 'SOMEOIDC_TEST_SERVICE') + return OIDCLoginService(app_config, 'SOMEOIDC_LOGIN_CONFIG') @pytest.fixture() def discovery_content(userinfo_supported): diff --git a/static/js/directives/ui/external-login-button.js b/static/js/directives/ui/external-login-button.js index f2abfe6b8..41e6e0bca 100644 --- a/static/js/directives/ui/external-login-button.js +++ b/static/js/directives/ui/external-login-button.js @@ -21,7 +21,6 @@ angular.module('quay').directive('externalLoginButton', function () { $scope.startSignin = function() { $scope.signInStarted({'service': $scope.provider}); ExternalLoginService.getLoginUrl($scope.provider, $scope.action || 'login', function(url) { - // Save the redirect URL in a cookie so that we can redirect back after the service returns to us. var redirectURL = $scope.redirectUrl || window.location.toString(); CookieService.putPermanent('quay.redirectAfterLoad', redirectURL); diff --git a/static/js/pages/user-view.js b/static/js/pages/user-view.js index c97a786b8..b66799879 100644 --- a/static/js/pages/user-view.js +++ b/static/js/pages/user-view.js @@ -13,6 +13,8 @@ function UserViewCtrl($scope, $routeParams, $timeout, ApiService, UserService, UIService, AvatarService, Config, ExternalLoginService) { var username = $routeParams.username; + $scope.Config = Config; + $scope.showAppsCounter = 0; $scope.showRobotsCounter = 0; $scope.showBillingCounter = 0; @@ -23,7 +25,27 @@ $scope.hasSingleSignin = ExternalLoginService.hasSingleSignin(); $scope.context = {}; - UserService.updateUserIn($scope); + $scope.oidcLoginProvider = null; + + if (Config['INTERNAL_OIDC_SERVICE_ID']) { + ExternalLoginService.EXTERNAL_LOGINS.forEach(function(provider) { + if (provider.id == Config['INTERNAL_OIDC_SERVICE_ID']) { + $scope.oidcLoginProvider = provider; + } + }); + } + + UserService.updateUserIn($scope, function(user) { + if (user && user.username) { + if ($scope.oidcLoginProvider && $routeParams['idtoken']) { + $scope.context.idTokenCredentials = { + 'username': UserService.getCLIUsername(), + 'password': $routeParams['idtoken'], + 'namespace': UserService.currentUser().username + }; + } + } + }); var loadRepositories = function() { var options = { diff --git a/static/partials/user-view.html b/static/partials/user-view.html index e9ea15f99..3a2eb5549 100644 --- a/static/partials/user-view.html +++ b/static/partials/user-view.html @@ -70,8 +70,25 @@ + +
+

Docker CLI Token

+
+ A generated token is required to login via the Docker CLI. +
+ + + + + + +
CLI Token: + +
+
+ -
+

Docker CLI Password

The Docker CLI stores passwords entered on the command line in plaintext. It is therefore highly recommended to generate an an encrypted version of your password to use for docker login. @@ -185,4 +202,7 @@
+ + +