Support for "basic" authentication
If Zuul's identity provider supports Direct Access Grants, the user can provide their credentials to zuul-client instead of an auth token. The credentials can be passed as the --username and --password CLI arguments, or as fields in the configuration file. If no password is provided, zuul-client will prompt the user to provide one. Change-Id: Ief1a1e8d8f763239357d926dd10407a4ed5d8f37
This commit is contained in:
parent
21382fb18a
commit
ced7a372a7
@ -6,8 +6,15 @@ Commands
|
||||
Privileged commands
|
||||
-------------------
|
||||
|
||||
Some commands require a valid authentication token to be passed as the ``--auth-token``
|
||||
argument. Administrators can generate such a token for users as needed.
|
||||
Some commands require the user to be authenticated (and authorized). Zuul-client
|
||||
supports two forms of authentication:
|
||||
|
||||
* **user/password** - Zuul-client will exchange the credentials for an access token
|
||||
on behalf of the user. This requires the identity provider to enable OpenID
|
||||
Connect's "password" grant type (also known as Direct Access Grant).
|
||||
* **raw JWT** - The access token can be passed directly to zuul-client as the ``--auth-token``
|
||||
argument. Administrators can generate such a token for users as needed; an authenticated
|
||||
user on Zuul's Web UI can also fetch their currently valid token on the UI's user page.
|
||||
|
||||
Usage
|
||||
-----
|
||||
|
@ -13,7 +13,7 @@ A default tenant can also be set with the ``tenant`` attribute.
|
||||
|
||||
Here is an example of a configuration file that can be used with zuul-client:
|
||||
|
||||
.. literalinclude:: /examples/.zuul.conf
|
||||
.. literalinclude:: /examples/zuul.conf
|
||||
:language: ini
|
||||
|
||||
|
||||
|
@ -15,4 +15,12 @@ tenant=mytenant
|
||||
auth_token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9
|
||||
# the path must be writable by the user.
|
||||
log_file=/path/to/log
|
||||
log_level=DEBUG
|
||||
log_level=DEBUG
|
||||
|
||||
[example2]
|
||||
url=https://example2.com/zuul/
|
||||
tenant=myothertenant
|
||||
# If the identity provider allows Direct Access Grants, a user can exchange their
|
||||
# credentials for an access token.
|
||||
username=user1
|
||||
# password=s3cr3t
|
@ -45,14 +45,23 @@ class ZuulRESTClient(object):
|
||||
self.url = url
|
||||
if not self.url.endswith('/'):
|
||||
self.url += '/'
|
||||
self.auth_token = auth_token
|
||||
self.verify = verify
|
||||
self.base_url = urllib.parse.urljoin(self.url, 'api/')
|
||||
self.session = requests.Session()
|
||||
self.session.verify = self.verify
|
||||
if self.auth_token:
|
||||
self.session.auth = BearerAuth(self.auth_token)
|
||||
self.info_ = None
|
||||
self._auth_token = None
|
||||
self.auth_token = auth_token
|
||||
|
||||
@property
|
||||
def auth_token(self):
|
||||
return self._auth_token
|
||||
|
||||
@auth_token.setter
|
||||
def auth_token(self, token):
|
||||
self._auth_token = token
|
||||
if self._auth_token:
|
||||
self.session.auth = BearerAuth(self.auth_token)
|
||||
|
||||
@property
|
||||
def info(self):
|
||||
@ -67,6 +76,17 @@ class ZuulRESTClient(object):
|
||||
self.info_ = req.json().get('info', {})
|
||||
return self.info_
|
||||
|
||||
def tenant_info(self, tenant):
|
||||
if self.info.get('tenant'):
|
||||
self._check_scope(tenant)
|
||||
return self.info
|
||||
url = urllib.parse.urljoin(
|
||||
self.base_url,
|
||||
'tenant/%s/info' % tenant)
|
||||
req = self.session.get(url)
|
||||
self._check_request_status(req)
|
||||
return req.json().get('info', {})
|
||||
|
||||
def _check_request_status(self, req):
|
||||
try:
|
||||
req.raise_for_status()
|
||||
|
@ -14,6 +14,7 @@
|
||||
|
||||
import argparse
|
||||
import configparser
|
||||
import getpass
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
@ -27,6 +28,10 @@ from zuulclient.utils import get_default
|
||||
from zuulclient.utils import encrypt_with_openssl
|
||||
from zuulclient.utils import formatters
|
||||
|
||||
from zuulclient.utils import get_oidc_config
|
||||
from zuulclient.utils import is_direct_grant_allowed
|
||||
from zuulclient.utils import get_token
|
||||
|
||||
|
||||
_HOME = Path(os.path.expandvars('$HOME'))
|
||||
_XDG_CONFIG_HOME = Path(os.environ.get(
|
||||
@ -71,6 +76,19 @@ class ZuulClient():
|
||||
default=None,
|
||||
help='Authentication Token, required by '
|
||||
'admin commands')
|
||||
parser.add_argument('--username', dest='username',
|
||||
required=False,
|
||||
default=None,
|
||||
help='User name, can be used to fetch an '
|
||||
'authentication token if the identity '
|
||||
'provider supports direct '
|
||||
'access grants')
|
||||
parser.add_argument('--password', dest='password',
|
||||
required=False,
|
||||
default=None,
|
||||
help='Password matching the user name. If only '
|
||||
'--username is provided, the user will be '
|
||||
'prompted for a password')
|
||||
parser.add_argument('--zuul-url', dest='zuul_url',
|
||||
required=False,
|
||||
default=None,
|
||||
@ -122,6 +140,9 @@ class ZuulClient():
|
||||
):
|
||||
raise ArgumentException(
|
||||
'Either specify --zuul-url or use a config file')
|
||||
if self.args.username and self.args.auth_token:
|
||||
raise ArgumentException(
|
||||
'Either specify a token or credentials')
|
||||
if not getattr(self.args, 'func', None):
|
||||
self.parser.print_help()
|
||||
sys.exit(1)
|
||||
@ -488,39 +509,96 @@ class ZuulClient():
|
||||
r = client.promote(**kwargs)
|
||||
return r
|
||||
|
||||
def get_client(self):
|
||||
if self.args.zuul_url:
|
||||
self.log.debug(
|
||||
'Using Zuul URL provided as argument to instantiate client')
|
||||
client = ZuulRESTClient(self.args.zuul_url,
|
||||
self.args.verify_ssl,
|
||||
self.args.auth_token)
|
||||
return client
|
||||
def get_config_section(self):
|
||||
conf_sections = self.config.sections()
|
||||
if len(conf_sections) == 1 and self.args.zuul_config is None:
|
||||
zuul_conf = conf_sections[0]
|
||||
self.log.debug(
|
||||
'Using section "%s" found in '
|
||||
'config to instantiate client' % zuul_conf)
|
||||
elif self.args.zuul_config and self.args.zuul_config in conf_sections:
|
||||
elif (self.args.zuul_config
|
||||
and self.args.zuul_config in conf_sections):
|
||||
zuul_conf = self.args.zuul_config
|
||||
else:
|
||||
raise Exception('Unable to find a way to connect to Zuul, '
|
||||
'provide the "--zuul-url" argument or set up a '
|
||||
'zuul-client configuration file.')
|
||||
server = get_default(self.config,
|
||||
zuul_conf, 'url', None)
|
||||
verify = get_default(self.config, zuul_conf,
|
||||
'verify_ssl',
|
||||
self.args.verify_ssl)
|
||||
# Allow token override by CLI argument
|
||||
auth_token = self.args.auth_token or get_default(self.config,
|
||||
zuul_conf,
|
||||
'auth_token',
|
||||
None)
|
||||
if server is None:
|
||||
raise Exception('Missing "url" configuration value')
|
||||
'provide the "--zuul-url" argument or '
|
||||
'set up a zuul-client configuration file.')
|
||||
return zuul_conf
|
||||
|
||||
def get_client(self):
|
||||
if self.args.zuul_url:
|
||||
self.log.debug(
|
||||
'Using Zuul URL provided as argument to instantiate client')
|
||||
server = self.args.zuul_url
|
||||
verify = self.args.verify_ssl
|
||||
auth_token = self.args.auth_token
|
||||
else:
|
||||
zuul_conf = self.get_config_section()
|
||||
server = get_default(self.config,
|
||||
zuul_conf, 'url', None)
|
||||
if server is None:
|
||||
raise Exception('Missing "url" configuration value')
|
||||
verify = get_default(self.config, zuul_conf,
|
||||
'verify_ssl',
|
||||
self.args.verify_ssl)
|
||||
# Allow token override by CLI argument
|
||||
auth_token = self.args.auth_token or get_default(self.config,
|
||||
zuul_conf,
|
||||
'auth_token',
|
||||
None)
|
||||
client = ZuulRESTClient(server, verify, auth_token)
|
||||
# Override token with user credentials if provided
|
||||
if self.config:
|
||||
zuul_conf = self.get_config_section()
|
||||
username = self.args.username or get_default(self.config,
|
||||
zuul_conf,
|
||||
'username',
|
||||
None)
|
||||
password = self.args.password or get_default(self.config,
|
||||
zuul_conf,
|
||||
'password',
|
||||
None)
|
||||
else:
|
||||
username = self.args.username
|
||||
password = self.args.password
|
||||
if username:
|
||||
if password is None:
|
||||
password = getpass.getpass('Password for %s: ' % username)
|
||||
self._check_tenant_scope(client)
|
||||
tenant = self.tenant()
|
||||
tenant_info = client.tenant_info(tenant)
|
||||
auth_config = tenant_info['capabilities'].get('auth')
|
||||
default_realm = auth_config.get('default_realm', None)
|
||||
if default_realm is None:
|
||||
raise Exception(
|
||||
'No auth configuration for this tenant'
|
||||
)
|
||||
self.log.debug('Authenticating against realm %s' % default_realm)
|
||||
realm_config = auth_config['realms'][default_realm]
|
||||
if realm_config['driver'] != 'OpenIDConnect':
|
||||
raise Exception(
|
||||
'Unsupported auth protocol: %s' % realm_config['driver']
|
||||
)
|
||||
authority = realm_config['authority']
|
||||
client_id = realm_config['client_id']
|
||||
scope = realm_config['scope']
|
||||
oidc_config = get_oidc_config(authority, verify)
|
||||
if is_direct_grant_allowed(oidc_config):
|
||||
auth_token = get_token(
|
||||
username,
|
||||
password,
|
||||
client_id,
|
||||
oidc_config,
|
||||
scope,
|
||||
verify
|
||||
)
|
||||
self.log.debug('Fetched access token: %s' % auth_token)
|
||||
client.auth_token = auth_token
|
||||
else:
|
||||
raise Exception(
|
||||
'The identity provider does not allow direct '
|
||||
'access grants. You need to provide an access token.'
|
||||
)
|
||||
return client
|
||||
|
||||
def tenant(self):
|
||||
|
@ -18,6 +18,8 @@ import os
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
def get_default(config, section, option, default=None, expand_user=False):
|
||||
if config.has_option(section, option):
|
||||
@ -95,3 +97,40 @@ def encrypt_with_openssl(pubkey_path, plaintext, logger=None):
|
||||
raise Exception('openssl failure (Return code %s)' % p.returncode)
|
||||
ciphertext_chunks.append(base64.b64encode(stdout).decode('utf-8'))
|
||||
return ciphertext_chunks
|
||||
|
||||
|
||||
def get_oidc_config(authority, verify=True):
|
||||
_authority = authority
|
||||
if not _authority.endswith('/'):
|
||||
_authority += ('/')
|
||||
oidc_config = requests.get(
|
||||
_authority + '.well-known/openid-configuration',
|
||||
verify=verify
|
||||
)
|
||||
oidc_config.raise_for_status()
|
||||
return oidc_config.json()
|
||||
|
||||
|
||||
def is_direct_grant_allowed(oidc_config):
|
||||
return 'password' in oidc_config.get('grant_types_supported', [])
|
||||
|
||||
|
||||
def get_token(username, password, client_id, oidc_config,
|
||||
scope=None, verify=True):
|
||||
token_endpoint = oidc_config.get('token_endpoint')
|
||||
_data = {
|
||||
'client_id': client_id,
|
||||
'username': username,
|
||||
'password': password,
|
||||
'grant_type': 'password',
|
||||
}
|
||||
if scope:
|
||||
_data['scope'] = scope
|
||||
response = requests.post(
|
||||
token_endpoint,
|
||||
verify=verify,
|
||||
data=_data
|
||||
)
|
||||
response.raise_for_status()
|
||||
token = response.json()['access_token']
|
||||
return token
|
||||
|
Loading…
Reference in New Issue
Block a user