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
|
Privileged commands
|
||||||
-------------------
|
-------------------
|
||||||
|
|
||||||
Some commands require a valid authentication token to be passed as the ``--auth-token``
|
Some commands require the user to be authenticated (and authorized). Zuul-client
|
||||||
argument. Administrators can generate such a token for users as needed.
|
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
|
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:
|
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
|
:language: ini
|
||||||
|
|
||||||
|
|
||||||
|
@ -16,3 +16,11 @@ auth_token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9
|
|||||||
# the path must be writable by the user.
|
# the path must be writable by the user.
|
||||||
log_file=/path/to/log
|
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
|
self.url = url
|
||||||
if not self.url.endswith('/'):
|
if not self.url.endswith('/'):
|
||||||
self.url += '/'
|
self.url += '/'
|
||||||
self.auth_token = auth_token
|
|
||||||
self.verify = verify
|
self.verify = verify
|
||||||
self.base_url = urllib.parse.urljoin(self.url, 'api/')
|
self.base_url = urllib.parse.urljoin(self.url, 'api/')
|
||||||
self.session = requests.Session()
|
self.session = requests.Session()
|
||||||
self.session.verify = self.verify
|
self.session.verify = self.verify
|
||||||
if self.auth_token:
|
|
||||||
self.session.auth = BearerAuth(self.auth_token)
|
|
||||||
self.info_ = None
|
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
|
@property
|
||||||
def info(self):
|
def info(self):
|
||||||
@ -67,6 +76,17 @@ class ZuulRESTClient(object):
|
|||||||
self.info_ = req.json().get('info', {})
|
self.info_ = req.json().get('info', {})
|
||||||
return self.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):
|
def _check_request_status(self, req):
|
||||||
try:
|
try:
|
||||||
req.raise_for_status()
|
req.raise_for_status()
|
||||||
|
@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import configparser
|
import configparser
|
||||||
|
import getpass
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
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 encrypt_with_openssl
|
||||||
from zuulclient.utils import formatters
|
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'))
|
_HOME = Path(os.path.expandvars('$HOME'))
|
||||||
_XDG_CONFIG_HOME = Path(os.environ.get(
|
_XDG_CONFIG_HOME = Path(os.environ.get(
|
||||||
@ -71,6 +76,19 @@ class ZuulClient():
|
|||||||
default=None,
|
default=None,
|
||||||
help='Authentication Token, required by '
|
help='Authentication Token, required by '
|
||||||
'admin commands')
|
'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',
|
parser.add_argument('--zuul-url', dest='zuul_url',
|
||||||
required=False,
|
required=False,
|
||||||
default=None,
|
default=None,
|
||||||
@ -122,6 +140,9 @@ class ZuulClient():
|
|||||||
):
|
):
|
||||||
raise ArgumentException(
|
raise ArgumentException(
|
||||||
'Either specify --zuul-url or use a config file')
|
'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):
|
if not getattr(self.args, 'func', None):
|
||||||
self.parser.print_help()
|
self.parser.print_help()
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
@ -488,28 +509,35 @@ class ZuulClient():
|
|||||||
r = client.promote(**kwargs)
|
r = client.promote(**kwargs)
|
||||||
return r
|
return r
|
||||||
|
|
||||||
def get_client(self):
|
def get_config_section(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
|
|
||||||
conf_sections = self.config.sections()
|
conf_sections = self.config.sections()
|
||||||
if len(conf_sections) == 1 and self.args.zuul_config is None:
|
if len(conf_sections) == 1 and self.args.zuul_config is None:
|
||||||
zuul_conf = conf_sections[0]
|
zuul_conf = conf_sections[0]
|
||||||
self.log.debug(
|
self.log.debug(
|
||||||
'Using section "%s" found in '
|
'Using section "%s" found in '
|
||||||
'config to instantiate client' % zuul_conf)
|
'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
|
zuul_conf = self.args.zuul_config
|
||||||
else:
|
else:
|
||||||
raise Exception('Unable to find a way to connect to Zuul, '
|
raise Exception('Unable to find a way to connect to Zuul, '
|
||||||
'provide the "--zuul-url" argument or set up a '
|
'provide the "--zuul-url" argument or '
|
||||||
'zuul-client configuration file.')
|
'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,
|
server = get_default(self.config,
|
||||||
zuul_conf, 'url', None)
|
zuul_conf, 'url', None)
|
||||||
|
if server is None:
|
||||||
|
raise Exception('Missing "url" configuration value')
|
||||||
verify = get_default(self.config, zuul_conf,
|
verify = get_default(self.config, zuul_conf,
|
||||||
'verify_ssl',
|
'verify_ssl',
|
||||||
self.args.verify_ssl)
|
self.args.verify_ssl)
|
||||||
@ -518,9 +546,59 @@ class ZuulClient():
|
|||||||
zuul_conf,
|
zuul_conf,
|
||||||
'auth_token',
|
'auth_token',
|
||||||
None)
|
None)
|
||||||
if server is None:
|
|
||||||
raise Exception('Missing "url" configuration value')
|
|
||||||
client = ZuulRESTClient(server, verify, auth_token)
|
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
|
return client
|
||||||
|
|
||||||
def tenant(self):
|
def tenant(self):
|
||||||
|
@ -18,6 +18,8 @@ import os
|
|||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
def get_default(config, section, option, default=None, expand_user=False):
|
def get_default(config, section, option, default=None, expand_user=False):
|
||||||
if config.has_option(section, option):
|
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)
|
raise Exception('openssl failure (Return code %s)' % p.returncode)
|
||||||
ciphertext_chunks.append(base64.b64encode(stdout).decode('utf-8'))
|
ciphertext_chunks.append(base64.b64encode(stdout).decode('utf-8'))
|
||||||
return ciphertext_chunks
|
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