identity: Add GSSAPI suport for FreeIPA authentication (#52031)
* identity: Add GSSAPI suport for FreeIPA authentication This enables the usage of GSSAPI for authentication, instead of having to pass the username and password as part of the playbook run. If there is GSSAPI support, this makes the password optional, and will be able to use the KRB5_CLIENT_KTNAME or the KRB5CCNAME environment variables; which are standard when using kerberos authentication. Note that this depends on the urllib_gssapi library, and will only enable this if that library is available. * identity: Add documentation for GSSAPI authentication for FreeIPA This documentation describes how to use GSSAPI authentication with the IPA identity modules. * identity: Add changelog for GSSAPI support for IPA This adds the changelog entry for the GSSAPI authentication feature for the IPA identity module.
This commit is contained in:
parent
7d55dc1a38
commit
9f081ca04f
6 changed files with 69 additions and 29 deletions
5
changelogs/fragments/52031-gssapi-for-identity-ipa.yaml
Normal file
5
changelogs/fragments/52031-gssapi-for-identity-ipa.yaml
Normal file
|
@ -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.
|
|
@ -28,13 +28,15 @@
|
||||||
# USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
# USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import socket
|
import socket
|
||||||
|
import uuid
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from ansible.module_utils._text import to_bytes, to_native, to_text
|
from ansible.module_utils._text import to_bytes, to_native, to_text
|
||||||
from ansible.module_utils.six import PY3
|
from ansible.module_utils.six import PY3
|
||||||
from ansible.module_utils.six.moves.urllib.parse import quote
|
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
|
from ansible.module_utils.basic import env_fallback, AnsibleFallbackNotFound
|
||||||
|
|
||||||
|
|
||||||
|
@ -60,6 +62,7 @@ class IPAClient(object):
|
||||||
self.module = module
|
self.module = module
|
||||||
self.headers = None
|
self.headers = None
|
||||||
self.timeout = module.params.get('ipa_timeout')
|
self.timeout = module.params.get('ipa_timeout')
|
||||||
|
self.use_gssapi = False
|
||||||
|
|
||||||
def get_base_url(self):
|
def get_base_url(self):
|
||||||
return '%s://%s/ipa' % (self.protocol, self.host)
|
return '%s://%s/ipa' % (self.protocol, self.host)
|
||||||
|
@ -68,23 +71,38 @@ class IPAClient(object):
|
||||||
return '%s/session/json' % self.get_base_url()
|
return '%s/session/json' % self.get_base_url()
|
||||||
|
|
||||||
def login(self, username, password):
|
def login(self, username, password):
|
||||||
url = '%s/session/login_password' % self.get_base_url()
|
if 'KRB5CCNAME' in os.environ and HAS_GSSAPI:
|
||||||
data = 'user=%s&password=%s' % (quote(username, safe=''), quote(password, safe=''))
|
self.use_gssapi = True
|
||||||
headers = {'referer': self.get_base_url(),
|
elif 'KRB5_CLIENT_KTNAME' in os.environ and HAS_GSSAPI:
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
ccache = "MEMORY:" + str(uuid.uuid4())
|
||||||
'Accept': 'text/plain'}
|
os.environ['KRB5CCNAME'] = ccache
|
||||||
try:
|
self.use_gssapi = True
|
||||||
resp, info = fetch_url(module=self.module, url=url, data=to_bytes(data), headers=headers, timeout=self.timeout)
|
else:
|
||||||
status_code = info['status']
|
if not password:
|
||||||
if status_code not in [200, 201, 204]:
|
self._fail('login', 'Password is required if not using '
|
||||||
self._fail('login', info['msg'])
|
'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 = {'referer': self.get_base_url(),
|
self.headers = {'Cookie': resp.info().get('Set-Cookie')}
|
||||||
'Content-Type': 'application/json',
|
except Exception as e:
|
||||||
'Accept': 'application/json',
|
self._fail('login', to_native(e))
|
||||||
'Cookie': resp.info().get('Set-Cookie')}
|
if not self.headers:
|
||||||
except Exception as e:
|
self.headers = dict()
|
||||||
self._fail('login', to_native(e))
|
self.headers.update({
|
||||||
|
'referer': self.get_base_url(),
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json'})
|
||||||
|
|
||||||
def _fail(self, msg, e):
|
def _fail(self, msg, e):
|
||||||
if 'message' in e:
|
if 'message' in e:
|
||||||
|
@ -120,7 +138,8 @@ class IPAClient(object):
|
||||||
data['params'] = [[name], item]
|
data['params'] = [[name], item]
|
||||||
|
|
||||||
try:
|
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']
|
status_code = info['status']
|
||||||
if status_code not in [200, 201, 204]:
|
if status_code not in [200, 201, 204]:
|
||||||
self._fail(method, info['msg'])
|
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_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_port=dict(type='int', default=443, fallback=(env_fallback, ['IPA_PORT'])),
|
||||||
ipa_user=dict(type='str', default='admin', fallback=(env_fallback, ['IPA_USER'])),
|
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'])),
|
ipa_timeout=dict(type='int', default=10, fallback=(env_fallback, ['IPA_TIMEOUT'])),
|
||||||
validate_certs=dict(type='bool', default=True),
|
validate_certs=dict(type='bool', default=True),
|
||||||
)
|
)
|
||||||
|
|
|
@ -151,6 +151,13 @@ except ImportError:
|
||||||
except ImportError:
|
except ImportError:
|
||||||
HAS_MATCH_HOSTNAME = False
|
HAS_MATCH_HOSTNAME = False
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
import urllib_gssapi
|
||||||
|
HAS_GSSAPI = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_GSSAPI = False
|
||||||
|
|
||||||
if not HAS_MATCH_HOSTNAME:
|
if not HAS_MATCH_HOSTNAME:
|
||||||
# The following block of code is under the terms and conditions of the
|
# The following block of code is under the terms and conditions of the
|
||||||
# Python Software Foundation License
|
# Python Software Foundation License
|
||||||
|
@ -894,7 +901,7 @@ class Request:
|
||||||
force=None, last_mod_time=None, timeout=None, validate_certs=None,
|
force=None, last_mod_time=None, timeout=None, validate_certs=None,
|
||||||
url_username=None, url_password=None, http_agent=None,
|
url_username=None, url_password=None, http_agent=None,
|
||||||
force_basic_auth=None, follow_redirects=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)
|
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
|
authentication. If client_cert contains both the certificate and key, this option is not required
|
||||||
:kwarg cookies: (optional) CookieJar object to send with the
|
:kwarg cookies: (optional) CookieJar object to send with the
|
||||||
request
|
request
|
||||||
|
:kwarg use_gssapi: (optional) Use GSSAPI handler of requests.
|
||||||
:returns: HTTPResponse
|
:returns: HTTPResponse
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@ -956,6 +964,8 @@ class Request:
|
||||||
ssl_handler = maybe_add_ssl_handler(url, validate_certs)
|
ssl_handler = maybe_add_ssl_handler(url, validate_certs)
|
||||||
if ssl_handler:
|
if ssl_handler:
|
||||||
handlers.append(ssl_handler)
|
handlers.append(ssl_handler)
|
||||||
|
if HAS_GSSAPI and use_gssapi:
|
||||||
|
handlers.append(urllib_gssapi.HTTPSPNEGOAuthHandler())
|
||||||
|
|
||||||
parsed = generic_urlparse(urlparse(url))
|
parsed = generic_urlparse(urlparse(url))
|
||||||
if parsed.scheme != 'ftp':
|
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,
|
force=False, last_mod_time=None, timeout=10, validate_certs=True,
|
||||||
url_username=None, url_password=None, http_agent=None,
|
url_username=None, url_password=None, http_agent=None,
|
||||||
force_basic_auth=False, follow_redirects='urllib2',
|
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)
|
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,
|
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,
|
url_username=url_username, url_password=url_password, http_agent=http_agent,
|
||||||
force_basic_auth=force_basic_auth, follow_redirects=follow_redirects,
|
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,
|
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)
|
"""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.).
|
: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 boolean force: If True: Do not get a cached copy (Default: False)
|
||||||
:kwarg last_mod_time: Default: None
|
:kwarg last_mod_time: Default: None
|
||||||
:kwarg int timeout: Default: 10
|
:kwarg int timeout: Default: 10
|
||||||
|
:kwarg boolean use_gssapi: Default: False
|
||||||
|
|
||||||
:returns: A tuple of (**response**, **info**). Use ``response.read()`` to read the data.
|
: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)
|
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,
|
validate_certs=validate_certs, url_username=username,
|
||||||
url_password=password, http_agent=http_agent, force_basic_auth=force_basic_auth,
|
url_password=password, http_agent=http_agent, force_basic_auth=force_basic_auth,
|
||||||
follow_redirects=follow_redirects, client_cert=client_cert,
|
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
|
# 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()))
|
info.update(dict((k.lower(), v) for k, v in r.info().items()))
|
||||||
|
|
||||||
|
|
|
@ -34,9 +34,11 @@ options:
|
||||||
description:
|
description:
|
||||||
- Password of administrative user.
|
- 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 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.'
|
- 'Environment variable fallback mechanism is added in version 2.5.'
|
||||||
required: true
|
|
||||||
ipa_prot:
|
ipa_prot:
|
||||||
description:
|
description:
|
||||||
- Protocol used by IPA server.
|
- Protocol used by IPA server.
|
||||||
|
|
|
@ -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,
|
force=False, last_mod_time=None, timeout=10, validate_certs=True,
|
||||||
url_username=None, url_password=None, http_agent=None,
|
url_username=None, url_password=None, http_agent=None,
|
||||||
force_basic_auth=False, follow_redirects='urllib2',
|
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)
|
||||||
|
|
|
@ -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,
|
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,
|
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='',
|
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):
|
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,
|
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,
|
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',
|
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):
|
def test_fetch_url_cookies(mocker, fake_ansible_module):
|
||||||
|
|
Loading…
Reference in a new issue