mirror of
https://github.com/ansible/awx.git
synced 2026-01-09 23:12:08 -03:30
Update AWX collection to use basic authentication (#15554)
Update AWX collection to use basic authentication when oauth token not provided, and when username and password provided.
This commit is contained in:
parent
ece21b15d0
commit
579c2b7229
@ -12,6 +12,7 @@ from ansible.module_utils.six.moves.urllib.error import HTTPError
|
||||
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
|
||||
@ -35,6 +36,8 @@ try:
|
||||
except ImportError:
|
||||
HAS_YAML = False
|
||||
|
||||
CONTROLLER_BASE_PATH_ENV_VAR = "CONTROLLER_OPTIONAL_API_URLPATTERN_PREFIX"
|
||||
|
||||
|
||||
class ConfigFileException(Exception):
|
||||
pass
|
||||
@ -79,6 +82,10 @@ class ControllerModule(AnsibleModule):
|
||||
version_checked = False
|
||||
error_callback = None
|
||||
warn_callback = None
|
||||
apps_api_versions = {
|
||||
"awx": "v2",
|
||||
"gateway": "v1",
|
||||
}
|
||||
|
||||
def __init__(self, argument_spec=None, direct_params=None, error_callback=None, warn_callback=None, **kwargs):
|
||||
full_argspec = {}
|
||||
@ -144,14 +151,15 @@ class ControllerModule(AnsibleModule):
|
||||
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 +312,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 +500,13 @@ class ControllerAPIModule(ControllerModule):
|
||||
|
||||
# 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 +617,65 @@ class ControllerAPIModule(ControllerModule):
|
||||
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/"
|
||||
prefix = getenv('CONTROLLER_OPTIONAL_API_URLPATTERN_PREFIX', default_api_path)
|
||||
if self._COLLECTION_TYPE != "awx" or app_key is not None:
|
||||
if app_key is None:
|
||||
app_key = "controller"
|
||||
|
||||
default_api_path = "/api/{0}/".format(app_key)
|
||||
|
||||
prefix = default_api_path
|
||||
if app_key is None or app_key == "controller":
|
||||
# if the env variable exists use it only when app is not defined or controller
|
||||
controller_base_path = getenv(CONTROLLER_BASE_PATH_ENV_VAR)
|
||||
if controller_base_path:
|
||||
self.warn(
|
||||
"using controller base path from environment variable:"
|
||||
" {0} = {1}".format(CONTROLLER_BASE_PATH_ENV_VAR, controller_base_path)
|
||||
)
|
||||
prefix = controller_base_path
|
||||
|
||||
if not prefix.startswith('/'):
|
||||
prefix = "/{0}".format(prefix)
|
||||
|
||||
if not prefix.endswith('/'):
|
||||
prefix = "{0}/".format(prefix)
|
||||
|
||||
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
|
||||
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 +683,16 @@ class ControllerAPIModule(ControllerModule):
|
||||
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("url: {0} - Failed to get token: {1}".format(api_token_url, exp))
|
||||
return
|
||||
|
||||
token_response = None
|
||||
try:
|
||||
@ -655,10 +700,37 @@ class ControllerAPIModule(ControllerModule):
|
||||
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(
|
||||
"url: {0} - Failed to extract token information from login response: {1}, response: {2}".format(
|
||||
api_token_url, exp, token_response,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
return None
|
||||
|
||||
def authenticate(self, **kwargs):
|
||||
# As a temporary solution for version 4.6 try to get a token by using basic authentication from:
|
||||
# /api/gateway/v1/tokens/ when app_key is gateway
|
||||
# /api/v2/tokens/ when app_key is None and _COLLECTION_TYPE = "awx"
|
||||
# /api/controller/v2/tokens/ when app_key is None and _COLLECTION_TYPE != "awx"
|
||||
for app_key in ["gateway", None]:
|
||||
# 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 +1083,10 @@ class ControllerAPIModule(ControllerModule):
|
||||
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 = self.build_url(
|
||||
"tokens/{0}/".format(self.oauth_token_id),
|
||||
app_key=self.oauth_token_app_key,
|
||||
).geturl()
|
||||
|
||||
try:
|
||||
self.session.open(
|
||||
@ -1021,11 +1095,12 @@ class ControllerAPIModule(ControllerModule):
|
||||
validate_certs=self.verify_ssl,
|
||||
timeout=self.request_timeout,
|
||||
follow_redirects=True,
|
||||
force_basic_auth=True,
|
||||
url_username=self.username,
|
||||
url_password=self.password,
|
||||
headers={
|
||||
"Authorization": self._get_basic_authorization_header(),
|
||||
}
|
||||
)
|
||||
self.oauth_token_id = None
|
||||
self.oauth_token = None
|
||||
self.authenticated = False
|
||||
except HTTPError as he:
|
||||
try:
|
||||
|
||||
55
awx_collection/test/awx/test_build_url.py
Normal file
55
awx_collection/test/awx/test_build_url.py
Normal file
@ -0,0 +1,55 @@
|
||||
from __future__ import absolute_import, division, print_function
|
||||
|
||||
import os
|
||||
from unittest import mock
|
||||
|
||||
__metaclass__ = type
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"collection_type, env_prefix, controller_host, app_key, endpoint, expected",
|
||||
[
|
||||
# without CONTROLLER_OPTIONAL_API_URLPATTERN_PREFIX env variable
|
||||
["awx", None, "https://localhost:8043", None, "jobs", "https://localhost:8043/api/v2/jobs/"],
|
||||
["awx", None, "https://localhost:8043", None, "jobs/209", "https://localhost:8043/api/v2/jobs/209/"],
|
||||
["awx", None, "https://localhost:8043", None, "organizations", "https://localhost:8043/api/v2/organizations/"],
|
||||
["awx", None, "https://localhost", "controller", "jobs", "https://localhost/api/controller/v2/jobs/"],
|
||||
["awx", None, "https://localhost", "controller", "jobs/1", "https://localhost/api/controller/v2/jobs/1/"],
|
||||
["awx", None, "https://localhost", "gateway", "tokens", "https://localhost/api/gateway/v1/tokens/"],
|
||||
["awx", None, "https://localhost", "gateway", "tokens/199", "https://localhost/api/gateway/v1/tokens/199/"],
|
||||
["controller", None, "https://localhost", None, "jobs", "https://localhost/api/controller/v2/jobs/"],
|
||||
["controller", None, "https://localhost", None, "jobs/209", "https://localhost/api/controller/v2/jobs/209/"],
|
||||
["controller", None, "https://localhost", None, "organizations", "https://localhost/api/controller/v2/organizations/"],
|
||||
["controller", None, "https://localhost", "controller", "jobs", "https://localhost/api/controller/v2/jobs/"],
|
||||
["controller", None, "https://localhost", "controller", "jobs/1", "https://localhost/api/controller/v2/jobs/1/"],
|
||||
["controller", None, "https://localhost", "gateway", "tokens", "https://localhost/api/gateway/v1/tokens/"],
|
||||
["controller", None, "https://localhost", "gateway", "tokens/199", "https://localhost/api/gateway/v1/tokens/199/"],
|
||||
# with CONTROLLER_OPTIONAL_API_URLPATTERN_PREFIX env variable
|
||||
["awx", "api/controller", "https://localhost", None, "jobs", "https://localhost/api/controller/v2/jobs/"],
|
||||
["awx", "api/controller", "https://localhost", None, "jobs/209", "https://localhost/api/controller/v2/jobs/209/"],
|
||||
["awx", "api/controller", "https://localhost", None, "organizations", "https://localhost/api/controller/v2/organizations/"],
|
||||
["awx", "api/controller", "https://localhost", "controller", "jobs", "https://localhost/api/controller/v2/jobs/"],
|
||||
["awx", "api/controller", "https://localhost", "controller", "jobs/1", "https://localhost/api/controller/v2/jobs/1/"],
|
||||
["awx", "api/controller", "https://localhost", "gateway", "tokens", "https://localhost/api/gateway/v1/tokens/"],
|
||||
["awx", "api/controller", "https://localhost", "gateway", "tokens/199", "https://localhost/api/gateway/v1/tokens/199/"],
|
||||
["controller", "api/controller", "https://localhost", None, "jobs", "https://localhost/api/controller/v2/jobs/"],
|
||||
["controller", "api/controller", "https://localhost", None, "jobs/209", "https://localhost/api/controller/v2/jobs/209/"],
|
||||
["controller", "api/controller", "https://localhost", "controller", "jobs", "https://localhost/api/controller/v2/jobs/"],
|
||||
["controller", "api/controller", "https://localhost", "controller", "jobs/1", "https://localhost/api/controller/v2/jobs/1/"],
|
||||
["controller", "api/controller", "https://localhost", "gateway", "tokens", "https://localhost/api/gateway/v1/tokens/"],
|
||||
["controller", "api/controller", "https://localhost", "gateway", "tokens/199", "https://localhost/api/gateway/v1/tokens/199/"],
|
||||
]
|
||||
)
|
||||
def test_controller_api_build_url(collection_import, collection_type, env_prefix, controller_host, app_key, endpoint, expected):
|
||||
controller_api_class = collection_import('plugins.module_utils.controller_api').ControllerAPIModule
|
||||
controller_api = controller_api_class(argument_spec={}, direct_params=dict(controller_host=controller_host))
|
||||
controller_api._COLLECTION_TYPE = collection_type
|
||||
if env_prefix:
|
||||
with mock.patch.dict(os.environ, {"CONTROLLER_OPTIONAL_API_URLPATTERN_PREFIX": env_prefix}):
|
||||
request_url = controller_api.build_url(endpoint, app_key=app_key).geturl()
|
||||
else:
|
||||
request_url = controller_api.build_url(endpoint, app_key=app_key).geturl()
|
||||
|
||||
assert request_url == expected
|
||||
Loading…
x
Reference in New Issue
Block a user