From 4ce2947dcf78b706754ca78172fab245974ca7e9 Mon Sep 17 00:00:00 2001 From: Djebran Lezzoum Date: Wed, 11 Sep 2024 10:46:45 +0200 Subject: [PATCH] Update AWX collection to use basic authentication Update AWX collection to use basic authentication when oauth token not provided, and when username and password provided. --- .../plugins/module_utils/controller_api.py | 121 +++++++++++++----- 1 file changed, 90 insertions(+), 31 deletions(-) diff --git a/awx_collection/plugins/module_utils/controller_api.py b/awx_collection/plugins/module_utils/controller_api.py index 541639306ac2..dabef16d5a72 100644 --- a/awx_collection/plugins/module_utils/controller_api.py +++ b/awx_collection/plugins/module_utils/controller_api.py @@ -12,6 +12,7 @@ from ansible.module_utils.six.moves.http_cookiejar import CookieJar from ansible.module_utils.six.moves.urllib.parse import urlparse, urlencode, quote from ansible.module_utils.six.moves.configparser import ConfigParser, NoOptionError +from base64 import b64encode from socket import getaddrinfo, IPPROTO_TCP import time import re @@ -79,6 +80,11 @@ class ControllerModule(AnsibleModule): version_checked = False error_callback = None warn_callback = None + apps_api_versions = { + "awx": "v2", + "controller": "v2", + "gateway": "v1", + } def __init__(self, argument_spec=None, direct_params=None, error_callback=None, warn_callback=None, **kwargs): full_argspec = {} @@ -144,14 +150,15 @@ def __init__(self, argument_spec=None, direct_params=None, error_callback=None, except Exception as e: self.fail_json(msg="Unable to resolve controller_host ({1}): {0}".format(self.url.hostname, e)) - def build_url(self, endpoint, query_params=None): + def build_url(self, endpoint, query_params=None, app_key=None): # Make sure we start with /api/vX if not endpoint.startswith("/"): endpoint = "/{0}".format(endpoint) hostname_prefix = self.url_prefix.rstrip("/") - api_path = self.api_path() + api_path = self.api_path(app_key=app_key) + api_version = self.apps_api_versions.get(app_key, self.apps_api_versions.get("awx", "v2")) if not endpoint.startswith(hostname_prefix + api_path): - endpoint = hostname_prefix + f"{api_path}v2{endpoint}" + endpoint = hostname_prefix + f"{api_path}{api_version}{endpoint}" if not endpoint.endswith('/') and '?' not in endpoint: endpoint = "{0}/".format(endpoint) @@ -304,6 +311,9 @@ class ControllerAPIModule(ControllerModule): IDENTITY_FIELDS = {'users': 'username', 'workflow_job_template_nodes': 'identifier', 'instances': 'hostname'} ENCRYPTED_STRING = "$encrypted$" + # which app was used to create the oauth_token + oauth_token_app_key = None + def __init__(self, argument_spec, direct_params=None, error_callback=None, warn_callback=None, **kwargs): kwargs['supports_check_mode'] = True @@ -489,11 +499,13 @@ def make_request(self, method, endpoint, *args, **kwargs): # Authenticate to AWX (if we don't have a token and if not already done so) if not self.oauth_token and not self.authenticated: - # This method will set a cookie in the cookie jar for us and also an oauth_token + # This method will set a cookie in the cookie jar for us and also an oauth_token when possible self.authenticate(**kwargs) if self.oauth_token: # If we have a oauth token, we just use a bearer header headers['Authorization'] = 'Bearer {0}'.format(self.oauth_token) + elif self.username and self.password: + headers['Authorization'] = self._get_basic_authorization_header() if method in ['POST', 'PUT', 'PATCH']: headers.setdefault('Content-Type', 'application/json') @@ -604,28 +616,53 @@ def make_request(self, method, endpoint, *args, **kwargs): status_code = response.status return {'status_code': status_code, 'json': response_json} - def api_path(self): + def api_path(self, app_key=None): default_api_path = "/api/" if self._COLLECTION_TYPE != "awx": - default_api_path = "/api/controller/" + if app_key is None: + app_key = "controller" + + default_api_path = "/api/{0}/".format(app_key) + prefix = getenv('CONTROLLER_OPTIONAL_API_URLPATTERN_PREFIX', default_api_path) return prefix - def authenticate(self, **kwargs): + def _get_basic_authorization_header(self): + basic_credentials = b64encode("{0}:{1}".format(self.username, self.password).encode()).decode() + return "Basic {0}".format(basic_credentials) + + def _authenticate_with_basic_auth(self): + if self.username and self.password: + # use api url /api/v2/me to get current user info as a testing request + me_url = self.build_url("me").geturl() + self.session.open( + "GET", + me_url, + validate_certs=self.verify_ssl, + timeout=self.request_timeout, + follow_redirects=True, + headers={ + "Content-Type": "application/json", + "Authorization": self._get_basic_authorization_header(), + }, + ) + + def _authenticate_create_token(self, app_key=None): + # in case of failure and to give a chance to authenticate via other means, should not raise exceptions + # but only warnings if self.username and self.password: - # Attempt to get a token from /api/v2/tokens/ by giving it our username/password combo - # If we have a username and password, we need to get a session cookie + # Attempt to get a token by using basic authentication from: + # /api/v2/tokens/ when app_key is None + # /api/gateway/v1/tokens/ when app_key is gateway + # /api/controller/v2/tokens/ when app_key is controller login_data = { "description": "Automation Platform Controller Module Token", "application": None, "scope": "write", } - # Preserve URL prefix - endpoint = self.url_prefix.rstrip('/') + f'{self.api_path()}v2/tokens/' - # Post to the tokens endpoint with baisc auth to try and get a token - api_token_url = (self.url._replace(path=endpoint)).geturl() + api_token_url = self.build_url("tokens", app_key=app_key).geturl() try: response = self.session.open( 'POST', @@ -633,21 +670,16 @@ def authenticate(self, **kwargs): validate_certs=self.verify_ssl, timeout=self.request_timeout, follow_redirects=True, - force_basic_auth=True, - url_username=self.username, - url_password=self.password, data=dumps(login_data), - headers={'Content-Type': 'application/json'}, + headers={ + "Content-Type": "application/json", + "Authorization": self._get_basic_authorization_header(), + }, ) - except HTTPError as he: - try: - resp = he.read() - except Exception as e: - resp = 'unknown {0}'.format(e) - self.fail_json(msg='Failed to get token: {0}'.format(he), response=resp) - except (Exception) as e: - # Sanity check: Did the server send back some kind of internal error? - self.fail_json(msg='Failed to get token: {0}'.format(e)) + + except Exception as exp: + self.warn("app: {0} - Failed to get token: {1}".format(app_key, exp)) + return token_response = None try: @@ -655,10 +687,35 @@ def authenticate(self, **kwargs): response_json = loads(token_response) self.oauth_token_id = response_json['id'] self.oauth_token = response_json['token'] - except (Exception) as e: - self.fail_json(msg="Failed to extract token information from login response: {0}".format(e), **{'response': token_response}) + # set the app that received the token create request, this is needed when removing the token at logout + self.oauth_token_app_key = app_key + except Exception as exp: + self.warn( + "app: {0} - Failed to extract token information from login response: {1}, response: {2}".format( + app_key, exp, token_response, + ) + ) + return + + return None + + def authenticate(self, **kwargs): + if self._COLLECTION_TYPE != "awx": + # when collection type is not awx try to create an oauth_token via gateway first and then controller + for app_key in ["gateway", "controller"]: + # to give a chance to authenticate via basic authentication in case of failure, + # _authenticate_create_token, should not raise exception but only warnings, + self._authenticate_create_token(app_key=app_key) + if self.oauth_token: + break + + if not self.oauth_token: + # if not having an oauth_token and when collection_type is awx try to login with basic authentication + try: + self._authenticate_with_basic_auth() + except Exception as exp: + self.fail_json(msg='Failed to get user info: {0}'.format(exp)) - # If we have neither of these, then we can try un-authenticated access self.authenticated = True def delete_if_needed(self, existing_item, item_type=None, on_delete=None, auto_exit=True): @@ -1011,8 +1068,10 @@ def logout(self): if self.authenticated and self.oauth_token_id: # Attempt to delete our current token from /api/v2/tokens/ # Post to the tokens endpoint with baisc auth to try and get a token - endpoint = self.url_prefix.rstrip('/') + f'{self.api_path()}v2/tokens/{self.oauth_token_id}/' - api_token_url = (self.url._replace(path=endpoint, query=None)).geturl() # in error cases, fail_json exists before exception handling + api_token_url = "{0}/{1}/".format( + self.build_url("tokens", app_key=self.oauth_token_app_key).geturl(), + self.oauth_token_id + ) try: self.session.open(