diff --git a/doc/source/commands.rst b/doc/source/commands.rst index eebd397..0f685bd 100644 --- a/doc/source/commands.rst +++ b/doc/source/commands.rst @@ -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 ----- diff --git a/doc/source/configuration.rst b/doc/source/configuration.rst index 584060b..4b911e2 100644 --- a/doc/source/configuration.rst +++ b/doc/source/configuration.rst @@ -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 diff --git a/doc/source/examples/.zuul.conf b/doc/source/examples/zuul.conf similarity index 75% rename from doc/source/examples/.zuul.conf rename to doc/source/examples/zuul.conf index c53251c..4312748 100644 --- a/doc/source/examples/.zuul.conf +++ b/doc/source/examples/zuul.conf @@ -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 \ No newline at end of file +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 \ No newline at end of file diff --git a/zuulclient/api/__init__.py b/zuulclient/api/__init__.py index 4a052f3..66833d5 100644 --- a/zuulclient/api/__init__.py +++ b/zuulclient/api/__init__.py @@ -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() diff --git a/zuulclient/cmd/__init__.py b/zuulclient/cmd/__init__.py index 62980e7..6eec0ef 100644 --- a/zuulclient/cmd/__init__.py +++ b/zuulclient/cmd/__init__.py @@ -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): diff --git a/zuulclient/utils/__init__.py b/zuulclient/utils/__init__.py index acefc34..ced76a1 100644 --- a/zuulclient/utils/__init__.py +++ b/zuulclient/utils/__init__.py @@ -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