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

View file

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

View file

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

View file

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

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')
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()

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

@ -70,8 +70,25 @@
<!-- 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 -->
<div class="settings-section">
<div class="settings-section" ng-if="Config.AUTHENTICATION_TYPE != 'OIDC'">
<h3>Docker CLI Password</h3>
<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>.
@ -185,4 +202,7 @@
<!-- Credentials for encrypted passwords -->
<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>