diff --git a/changelogs/fragments/52031-gssapi-for-identity-ipa.yaml b/changelogs/fragments/52031-gssapi-for-identity-ipa.yaml new file mode 100644 index 00000000000..fca71e65fb5 --- /dev/null +++ b/changelogs/fragments/52031-gssapi-for-identity-ipa.yaml @@ -0,0 +1,5 @@ +minor_changes: +- identity - Added support for GSSAPI authentication for the FreeIPA modules. + This is enabled by either using the KRB5CCNAME or the KRB5_CLIENT_KTNAME + environment variables when calling the ansible playbook. Note that to enable + this feature, one has to install the urllib_gssapi python library. diff --git a/lib/ansible/module_utils/ipa.py b/lib/ansible/module_utils/ipa.py index 0d3eb1d3b45..738d90e0eef 100644 --- a/lib/ansible/module_utils/ipa.py +++ b/lib/ansible/module_utils/ipa.py @@ -28,13 +28,15 @@ # USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. import json +import os import socket +import uuid import re from ansible.module_utils._text import to_bytes, to_native, to_text from ansible.module_utils.six import PY3 from ansible.module_utils.six.moves.urllib.parse import quote -from ansible.module_utils.urls import fetch_url +from ansible.module_utils.urls import fetch_url, HAS_GSSAPI from ansible.module_utils.basic import env_fallback, AnsibleFallbackNotFound @@ -60,6 +62,7 @@ class IPAClient(object): self.module = module self.headers = None self.timeout = module.params.get('ipa_timeout') + self.use_gssapi = False def get_base_url(self): return '%s://%s/ipa' % (self.protocol, self.host) @@ -68,23 +71,38 @@ class IPAClient(object): return '%s/session/json' % self.get_base_url() def login(self, username, password): - url = '%s/session/login_password' % self.get_base_url() - data = 'user=%s&password=%s' % (quote(username, safe=''), quote(password, safe='')) - headers = {'referer': self.get_base_url(), - 'Content-Type': 'application/x-www-form-urlencoded', - 'Accept': 'text/plain'} - try: - resp, info = fetch_url(module=self.module, url=url, data=to_bytes(data), headers=headers, timeout=self.timeout) - status_code = info['status'] - if status_code not in [200, 201, 204]: - self._fail('login', info['msg']) - - self.headers = {'referer': self.get_base_url(), - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'Cookie': resp.info().get('Set-Cookie')} - except Exception as e: - self._fail('login', to_native(e)) + if 'KRB5CCNAME' in os.environ and HAS_GSSAPI: + self.use_gssapi = True + elif 'KRB5_CLIENT_KTNAME' in os.environ and HAS_GSSAPI: + ccache = "MEMORY:" + str(uuid.uuid4()) + os.environ['KRB5CCNAME'] = ccache + self.use_gssapi = True + else: + if not password: + self._fail('login', 'Password is required if not using ' + 'GSSAPI. To use GSSAPI, please set the ' + 'KRB5_CLIENT_KTNAME or KRB5CCNAME (or both) ' + ' environment variables.') + url = '%s/session/login_password' % self.get_base_url() + data = 'user=%s&password=%s' % (quote(username, safe=''), quote(password, safe='')) + headers = {'referer': self.get_base_url(), + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'text/plain'} + try: + resp, info = fetch_url(module=self.module, url=url, data=to_bytes(data), headers=headers, timeout=self.timeout) + status_code = info['status'] + if status_code not in [200, 201, 204]: + self._fail('login', info['msg']) + + self.headers = {'Cookie': resp.info().get('Set-Cookie')} + except Exception as e: + self._fail('login', to_native(e)) + if not self.headers: + self.headers = dict() + self.headers.update({ + 'referer': self.get_base_url(), + 'Content-Type': 'application/json', + 'Accept': 'application/json'}) def _fail(self, msg, e): if 'message' in e: @@ -120,7 +138,8 @@ class IPAClient(object): data['params'] = [[name], item] try: - resp, info = fetch_url(module=self.module, url=url, data=to_bytes(json.dumps(data)), headers=self.headers, timeout=self.timeout) + resp, info = fetch_url(module=self.module, url=url, data=to_bytes(json.dumps(data)), + headers=self.headers, timeout=self.timeout, use_gssapi=self.use_gssapi) status_code = info['status'] if status_code not in [200, 201, 204]: self._fail(method, info['msg']) @@ -199,7 +218,7 @@ def ipa_argument_spec(): ipa_host=dict(type='str', default='ipa.example.com', fallback=(_env_then_dns_fallback, ['IPA_HOST'])), ipa_port=dict(type='int', default=443, fallback=(env_fallback, ['IPA_PORT'])), ipa_user=dict(type='str', default='admin', fallback=(env_fallback, ['IPA_USER'])), - ipa_pass=dict(type='str', required=True, no_log=True, fallback=(env_fallback, ['IPA_PASS'])), + ipa_pass=dict(type='str', required=not HAS_GSSAPI, no_log=True, fallback=(env_fallback, ['IPA_PASS'])), ipa_timeout=dict(type='int', default=10, fallback=(env_fallback, ['IPA_TIMEOUT'])), validate_certs=dict(type='bool', default=True), ) diff --git a/lib/ansible/module_utils/urls.py b/lib/ansible/module_utils/urls.py index a286fa5be12..418479ba4a3 100644 --- a/lib/ansible/module_utils/urls.py +++ b/lib/ansible/module_utils/urls.py @@ -151,6 +151,13 @@ except ImportError: except ImportError: HAS_MATCH_HOSTNAME = False + +try: + import urllib_gssapi + HAS_GSSAPI = True +except ImportError: + HAS_GSSAPI = False + if not HAS_MATCH_HOSTNAME: # The following block of code is under the terms and conditions of the # Python Software Foundation License @@ -894,7 +901,7 @@ class Request: force=None, last_mod_time=None, timeout=None, validate_certs=None, url_username=None, url_password=None, http_agent=None, force_basic_auth=None, follow_redirects=None, - client_cert=None, client_key=None, cookies=None): + client_cert=None, client_key=None, cookies=None, use_gssapi=False): """ Sends a request via HTTP(S) or FTP using urllib2 (Python2) or urllib (Python3) @@ -928,6 +935,7 @@ class Request: authentication. If client_cert contains both the certificate and key, this option is not required :kwarg cookies: (optional) CookieJar object to send with the request + :kwarg use_gssapi: (optional) Use GSSAPI handler of requests. :returns: HTTPResponse """ @@ -956,6 +964,8 @@ class Request: ssl_handler = maybe_add_ssl_handler(url, validate_certs) if ssl_handler: handlers.append(ssl_handler) + if HAS_GSSAPI and use_gssapi: + handlers.append(urllib_gssapi.HTTPSPNEGOAuthHandler()) parsed = generic_urlparse(urlparse(url)) if parsed.scheme != 'ftp': @@ -1153,7 +1163,8 @@ def open_url(url, data=None, headers=None, method=None, use_proxy=True, force=False, last_mod_time=None, timeout=10, validate_certs=True, url_username=None, url_password=None, http_agent=None, force_basic_auth=False, follow_redirects='urllib2', - client_cert=None, client_key=None, cookies=None): + client_cert=None, client_key=None, cookies=None, + use_gssapi=False): ''' Sends a request via HTTP(S) or FTP using urllib2 (Python2) or urllib (Python3) @@ -1164,7 +1175,8 @@ def open_url(url, data=None, headers=None, method=None, use_proxy=True, force=force, last_mod_time=last_mod_time, timeout=timeout, validate_certs=validate_certs, url_username=url_username, url_password=url_password, http_agent=http_agent, force_basic_auth=force_basic_auth, follow_redirects=follow_redirects, - client_cert=client_cert, client_key=client_key, cookies=cookies) + client_cert=client_cert, client_key=client_key, cookies=cookies, + use_gssapi=use_gssapi) # @@ -1199,7 +1211,8 @@ def url_argument_spec(): def fetch_url(module, url, data=None, headers=None, method=None, - use_proxy=True, force=False, last_mod_time=None, timeout=10): + use_proxy=True, force=False, last_mod_time=None, timeout=10, + use_gssapi=False): """Sends a request via HTTP(S) or FTP (needs the module as parameter) :arg module: The AnsibleModule (used to get username, password etc. (s.b.). @@ -1212,6 +1225,7 @@ def fetch_url(module, url, data=None, headers=None, method=None, :kwarg boolean force: If True: Do not get a cached copy (Default: False) :kwarg last_mod_time: Default: None :kwarg int timeout: Default: 10 + :kwarg boolean use_gssapi: Default: False :returns: A tuple of (**response**, **info**). Use ``response.read()`` to read the data. The **info** contains the 'status' and other meta data. When a HttpError (status > 400) @@ -1261,7 +1275,7 @@ def fetch_url(module, url, data=None, headers=None, method=None, validate_certs=validate_certs, url_username=username, url_password=password, http_agent=http_agent, force_basic_auth=force_basic_auth, follow_redirects=follow_redirects, client_cert=client_cert, - client_key=client_key, cookies=cookies) + client_key=client_key, cookies=cookies, use_gssapi=use_gssapi) # Lowercase keys, to conform to py2 behavior, so that py3 and py2 are predictable info.update(dict((k.lower(), v) for k, v in r.info().items())) diff --git a/lib/ansible/plugins/doc_fragments/ipa.py b/lib/ansible/plugins/doc_fragments/ipa.py index 20f4c81a17d..10ba7cb6d52 100644 --- a/lib/ansible/plugins/doc_fragments/ipa.py +++ b/lib/ansible/plugins/doc_fragments/ipa.py @@ -34,9 +34,11 @@ options: description: - Password of administrative user. - If the value is not specified in the task, the value of environment variable C(IPA_PASS) will be used instead. - - If both the environment variable C(IPA_PASS) and the value are not specified in the task, then default value is set. + - Note that if the 'urllib_gssapi' library is available, it is possible to use GSSAPI to authenticate to FreeIPA. + - If the environment variable C(KRB5CCNAME) is available, the module will use this kerberos credentials cache to authenticate to the FreeIPA server. + - If the environment variable C(KRB5_CLIENT_KTNAME) is available, and C(KRB5CCNAME) is not; the module will use this kerberos keytab to authenticate. + - If GSSAPI is not available, the usage of 'ipa_pass' is required. - 'Environment variable fallback mechanism is added in version 2.5.' - required: true ipa_prot: description: - Protocol used by IPA server. diff --git a/test/units/module_utils/urls/test_Request.py b/test/units/module_utils/urls/test_Request.py index aad4de4b404..f8ca7939901 100644 --- a/test/units/module_utils/urls/test_Request.py +++ b/test/units/module_utils/urls/test_Request.py @@ -399,4 +399,4 @@ def test_open_url(urlopen_mock, install_opener_mock, mocker): force=False, last_mod_time=None, timeout=10, validate_certs=True, url_username=None, url_password=None, http_agent=None, force_basic_auth=False, follow_redirects='urllib2', - client_cert=None, client_key=None, cookies=None) + client_cert=None, client_key=None, cookies=None, use_gssapi=False) diff --git a/test/units/module_utils/urls/test_fetch_url.py b/test/units/module_utils/urls/test_fetch_url.py index 49373571ecc..f983b1e8be8 100644 --- a/test/units/module_utils/urls/test_fetch_url.py +++ b/test/units/module_utils/urls/test_fetch_url.py @@ -67,7 +67,7 @@ def test_fetch_url(open_url_mock, fake_ansible_module): open_url_mock.assert_called_once_with('http://ansible.com/', client_cert=None, client_key=None, cookies=kwargs['cookies'], data=None, follow_redirects='urllib2', force=False, force_basic_auth='', headers=None, http_agent='ansible-httpget', last_mod_time=None, method=None, timeout=10, url_password='', url_username='', - use_proxy=True, validate_certs=True) + use_proxy=True, validate_certs=True, use_gssapi=False) def test_fetch_url_params(open_url_mock, fake_ansible_module): @@ -89,7 +89,7 @@ def test_fetch_url_params(open_url_mock, fake_ansible_module): open_url_mock.assert_called_once_with('http://ansible.com/', client_cert='client.pem', client_key='client.key', cookies=kwargs['cookies'], data=None, follow_redirects='all', force=False, force_basic_auth=True, headers=None, http_agent='ansible-test', last_mod_time=None, method=None, timeout=10, url_password='passwd', url_username='user', - use_proxy=True, validate_certs=False) + use_proxy=True, validate_certs=False, use_gssapi=False) def test_fetch_url_cookies(mocker, fake_ansible_module):