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:
Matthieu Huin 2021-11-24 15:50:12 +01:00
parent 21382fb18a
commit ced7a372a7
6 changed files with 182 additions and 30 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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