Add support for using OIDC tokens via the Docker CLI

This commit is contained in:
Joseph Schorr 2017-06-08 13:13:22 -04:00
parent 6600b380ca
commit e724125459
16 changed files with 176 additions and 14 deletions

View file

@ -23,7 +23,7 @@ CLIENT_WHITELIST = ['SERVER_HOSTNAME', 'PREFERRED_URL_SCHEME', 'MIXPANEL_KEY',
'CONTACT_INFO', 'AVATAR_KIND', 'LOCAL_OAUTH_HANDLER', 'DOCUMENTATION_LOCATION', 'CONTACT_INFO', 'AVATAR_KIND', 'LOCAL_OAUTH_HANDLER', 'DOCUMENTATION_LOCATION',
'DOCUMENTATION_METADATA', 'SETUP_COMPLETE', 'DEBUG', 'MARKETO_MUNCHKIN_ID', 'DOCUMENTATION_METADATA', 'SETUP_COMPLETE', 'DEBUG', 'MARKETO_MUNCHKIN_ID',
'STATIC_SITE_BUCKET', 'RECAPTCHA_SITE_KEY', 'CHANNEL_COLORS', 'STATIC_SITE_BUCKET', 'RECAPTCHA_SITE_KEY', 'CHANNEL_COLORS',
'TAG_EXPIRATION_OPTIONS'] 'TAG_EXPIRATION_OPTIONS', 'INTERNAL_OIDC_SERVICE_ID']
def frontend_visible_config(config_dict): def frontend_visible_config(config_dict):

View file

@ -10,6 +10,7 @@ from data.users.database import DatabaseUsers
from data.users.externalldap import LDAPUsers from data.users.externalldap import LDAPUsers
from data.users.externaljwt import ExternalJWTAuthN from data.users.externaljwt import ExternalJWTAuthN
from data.users.keystone import get_keystone_users from data.users.keystone import get_keystone_users
from data.users.oidc import OIDCInternalAuth
from util.security.aes import AESCipher from util.security.aes import AESCipher
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -24,6 +25,9 @@ def get_federated_service_name(authentication_type):
if authentication_type == 'Keystone': if authentication_type == 'Keystone':
return 'keystone' return 'keystone'
if authentication_type == 'OIDC':
return 'oidc'
raise Exception('Unknown auth type: %s' % authentication_type) raise Exception('Unknown auth type: %s' % authentication_type)
@ -77,6 +81,13 @@ def get_users_handler(config, _, override_config_dir):
keystone_admin_password, keystone_admin_tenant, timeout, keystone_admin_password, keystone_admin_tenant, timeout,
requires_email=features.MAILING) 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) raise RuntimeError('Unknown authentication type: %s' % authentication_type)
class UserAuthentication(object): class UserAuthentication(object):
@ -160,6 +171,11 @@ class UserAuthentication(object):
""" """
return self.state.federated_service 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): def query_users(self, query, limit=20):
""" Performs a lookup against the user system for the specified query. The returned tuple """ 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, will be of the form (results, federated_login_id, err_msg). If the method is unsupported,

View file

@ -9,6 +9,10 @@ class DatabaseUsers(object):
""" Always assumed to be working. If the DB is broken, other checks will handle it. """ """ Always assumed to be working. If the DB is broken, other checks will handle it. """
return (True, None) return (True, None)
@property
def supports_encrypted_credentials(self):
return True
def verify_credentials(self, username_or_email, password): def verify_credentials(self, username_or_email, password):
""" Simply delegate to the model implementation. """ """ Simply delegate to the model implementation. """
result = model.user.verify_user(username_or_email, password) result = model.user.verify_user(username_or_email, password)

View file

@ -23,6 +23,10 @@ class FederatedUsers(object):
def federated_service(self): def federated_service(self):
return self._federated_service return self._federated_service
@property
def supports_encrypted_credentials(self):
return True
def get_user(self, username_or_email): def get_user(self, username_or_email):
""" Retrieves the user with the given username or email, returning a tuple containing """ Retrieves the user with the given username or email, returning a tuple containing
a UserInformation (if success) and the error message (on failure). a UserInformation (if success) and the error message (on failure).

45
data/users/oidc.py Normal file
View file

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

View file

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

View file

@ -513,6 +513,9 @@ class ClientKey(ApiResource):
@validate_json_request('GenerateClientKey') @validate_json_request('GenerateClientKey')
def post(self): def post(self):
""" Return's the user's private client key. """ """ Return's the user's private client key. """
if not authentication.supports_encrypted_credentials:
raise NotFound()
username = get_authenticated_user().username username = get_authenticated_user().username
password = request.get_json()['password'] password = request.get_json()['password']
(result, error_message) = authentication.confirm_existing_user(username, password) (result, error_message) = authentication.confirm_existing_user(username, password)
@ -728,7 +731,7 @@ class ExternalLoginInformation(ApiResource):
'kind': { 'kind': {
'type': 'string', 'type': 'string',
'description': 'The kind of URL', '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) csrf_token = generate_csrf_token(OAUTH_CSRF_TOKEN_NAME)
kind = request.get_json()['kind'] kind = request.get_json()['kind']
redirect_suffix = '/attach' if kind == 'attach' else '' redirect_suffix = '/' if kind == 'login' else '/' + kind
try: try:
login_scopes = login_service.get_login_scopes() login_scopes = login_service.get_login_scopes()

View file

@ -250,6 +250,24 @@ def _register_service(login_service):
auth_url = login_service.get_auth_url(app.config, '', csrf_token, login_scopes) auth_url = login_service.get_auth_url(app.config, '', csrf_token, login_scopes)
return redirect(auth_url) 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(), oauthlogin.add_url_rule('/%s/callback/captcha' % login_service.service_id(),
'%s_oauth_captcha' % login_service.service_id(), '%s_oauth_captcha' % login_service.service_id(),
@ -266,6 +284,11 @@ def _register_service(login_service):
attach_func, attach_func,
methods=['GET']) 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. # Register the routes for each of the login services.
for current_service in oauth_login.services: for current_service in oauth_login.services:
_register_service(current_service) _register_service(current_service)

View file

@ -267,6 +267,7 @@ def initialize_database():
LoginService.create(name='jwtauthn') LoginService.create(name='jwtauthn')
LoginService.create(name='keystone') LoginService.create(name='keystone')
LoginService.create(name='dex') LoginService.create(name='dex')
LoginService.create(name='oidc')
BuildTriggerService.create(name='github') BuildTriggerService.create(name='github')
BuildTriggerService.create(name='custom-git') BuildTriggerService.create(name='custom-git')

View file

@ -1,7 +1,6 @@
from oauth.services.github import GithubOAuthService from oauth.services.github import GithubOAuthService
from oauth.services.google import GoogleOAuthService from oauth.services.google import GoogleOAuthService
from oauth.oidc import OIDCLoginService from oauth.oidc import OIDCLoginService
from data.users import UserAuthentication
CUSTOM_LOGIN_SERVICES = { CUSTOM_LOGIN_SERVICES = {
'GITHUB_LOGIN_CONFIG': GithubOAuthService, 'GITHUB_LOGIN_CONFIG': GithubOAuthService,

View file

@ -89,7 +89,7 @@ class OIDCLoginService(OAuthService):
'OIDC': True, '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 # Exchange the code for the access token and id_token
try: try:
json_data = self.exchange_code(app_config, http_client, code, 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) logger.debug('Missing id_token in response: %s', json_data)
raise OAuthLoginException('Missing `id_token` in OIDC response') 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. # Decode the id_token.
try: try:
decoded_id_token = self._decode_user_jwt(id_token) decoded_id_token = self.decode_user_jwt(id_token)
except InvalidTokenError as ite: except InvalidTokenError as ite:
logger.exception('Got invalid token error on OIDC decode: %s', ite.message) logger.exception('Got invalid token error on OIDC decode: %s', ite.message)
raise OAuthLoginException('Could not decode OIDC token') 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) logger.exception('Could not parse OIDC discovery for url: %s', discovery_url)
raise DiscoveryFailureException("Could not parse OIDC discovery information") 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 """ 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 exception on an invalid token or a PublicKeyLoadException if the public key could not be
loaded for decoding. loaded for decoding.

0
oauth/test/__init__.py Normal file
View file

View file

@ -60,7 +60,7 @@ def app_config(http_client, mailing_feature):
'SERVER_HOSTNAME': 'localhost', 'SERVER_HOSTNAME': 'localhost',
'FEATURE_MAILING': mailing_feature, 'FEATURE_MAILING': mailing_feature,
'SOMEOIDC_TEST_SERVICE': { 'SOMEOIDC_LOGIN_CONFIG': {
'CLIENT_ID': 'foo', 'CLIENT_ID': 'foo',
'CLIENT_SECRET': 'bar', 'CLIENT_SECRET': 'bar',
'SERVICE_NAME': 'Some Cool Service', 'SERVICE_NAME': 'Some Cool Service',
@ -74,7 +74,7 @@ def app_config(http_client, mailing_feature):
@pytest.fixture() @pytest.fixture()
def oidc_service(app_config): def oidc_service(app_config):
return OIDCLoginService(app_config, 'SOMEOIDC_TEST_SERVICE') return OIDCLoginService(app_config, 'SOMEOIDC_LOGIN_CONFIG')
@pytest.fixture() @pytest.fixture()
def discovery_content(userinfo_supported): def discovery_content(userinfo_supported):

View file

@ -21,7 +21,6 @@ angular.module('quay').directive('externalLoginButton', function () {
$scope.startSignin = function() { $scope.startSignin = function() {
$scope.signInStarted({'service': $scope.provider}); $scope.signInStarted({'service': $scope.provider});
ExternalLoginService.getLoginUrl($scope.provider, $scope.action || 'login', function(url) { 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. // 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(); var redirectURL = $scope.redirectUrl || window.location.toString();
CookieService.putPermanent('quay.redirectAfterLoad', redirectURL); CookieService.putPermanent('quay.redirectAfterLoad', redirectURL);

View file

@ -13,6 +13,8 @@
function UserViewCtrl($scope, $routeParams, $timeout, ApiService, UserService, UIService, AvatarService, Config, ExternalLoginService) { function UserViewCtrl($scope, $routeParams, $timeout, ApiService, UserService, UIService, AvatarService, Config, ExternalLoginService) {
var username = $routeParams.username; var username = $routeParams.username;
$scope.Config = Config;
$scope.showAppsCounter = 0; $scope.showAppsCounter = 0;
$scope.showRobotsCounter = 0; $scope.showRobotsCounter = 0;
$scope.showBillingCounter = 0; $scope.showBillingCounter = 0;
@ -23,7 +25,27 @@
$scope.hasSingleSignin = ExternalLoginService.hasSingleSignin(); $scope.hasSingleSignin = ExternalLoginService.hasSingleSignin();
$scope.context = {}; $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 loadRepositories = function() {
var options = { var options = {

View file

@ -70,8 +70,25 @@
<!-- Settings --> <!-- Settings -->
<cor-tab-pane id="settings"> <cor-tab-pane id="settings">
<!-- OIDC Token -->
<div class="settings-section" ng-if="Config.AUTHENTICATION_TYPE == 'OIDC'">
<h3>Docker CLI Token</h3>
<div>
A generated token is <strong>required</strong> to login via the Docker CLI.
</div>
<table class="co-list-table" style="margin-top: 10px;">
<tr>
<td>CLI Token:</td>
<td>
<span class="external-login-button" is-link="true" action="cli" provider="oidcLoginProvider"></span>
</td>
</tr>
</table>
</div>
<!-- Encrypted Password --> <!-- Encrypted Password -->
<div class="settings-section"> <div class="settings-section" ng-if="Config.AUTHENTICATION_TYPE != 'OIDC'">
<h3>Docker CLI Password</h3> <h3>Docker CLI Password</h3>
<div ng-if="!Features.REQUIRE_ENCRYPTED_BASIC_AUTH"> <div ng-if="!Features.REQUIRE_ENCRYPTED_BASIC_AUTH">
The Docker CLI stores passwords entered on the command line in <strong>plaintext</strong>. It is therefore highly recommended to generate an an encrypted version of your password to use for <code>docker login</code>. The Docker CLI stores passwords entered on the command line in <strong>plaintext</strong>. It is therefore highly recommended to generate an an encrypted version of your password to use for <code>docker login</code>.
@ -185,4 +202,7 @@
<!-- Credentials for encrypted passwords --> <!-- Credentials for encrypted passwords -->
<div class="credentials-dialog" credentials="context.encryptedPasswordCredentials" secret-title="Encrypted Password" entity-title="encrypted password" entity-icon="fa-key"> <div class="credentials-dialog" credentials="context.encryptedPasswordCredentials" secret-title="Encrypted Password" entity-title="encrypted password" entity-icon="fa-key">
<!-- Credentials for ID token -->
<div class="credentials-dialog" credentials="context.idTokenCredentials" secret-title="CLI Token" entity-title="Docker CLI token" entity-icon="fa-key">
</div> </div>