diff --git a/README.rst b/README.rst index d1c0fa6..47c87d4 100644 --- a/README.rst +++ b/README.rst @@ -3,14 +3,22 @@ requests GSSAPI authentication library Requests is an HTTP library, written in Python, for human beings. This library adds optional GSSAPI authentication support and supports mutual -authentication. Basic GET usage: +authentication. + +It provides a fully backward-compatible shim for the old +python-requests-kerberos library: simply replace ``import requests_kerberos`` +with ``import requests_gssapi``. A more powerful interface is provided by the +HTTPSPNEGOAuth component, but this is of course not guaranteed to be +compatible. Documentation below is written toward the new interface. + +Basic GET usage: .. code-block:: python >>> import requests - >>> from requests_gssapi import HTTPKerberosAuth - >>> r = requests.get("http://example.org", auth=HTTPKerberosAuth()) + >>> from requests_gssapi import HTTPSPNEGOAuth + >>> r = requests.get("http://example.org", auth=HTTPSPNEGOAuth()) ... The entire ``requests.api`` should be supported. @@ -27,7 +35,7 @@ Mutual Authentication REQUIRED ^^^^^^^^ -By default, ``HTTPKerberosAuth`` will require mutual authentication from the +By default, ``HTTPSPNEGOAuth`` will require mutual authentication from the server, and if a server emits a non-error response which cannot be authenticated, a ``requests_gssapi.errors.MutualAuthenticationError`` will be raised. If a server emits an error which cannot be authenticated, it will @@ -39,8 +47,8 @@ setting ``sanitize_mutual_error_response=False``: .. code-block:: python >>> import requests - >>> from requests_gssapi import HTTPKerberosAuth, REQUIRED - >>> gssapi_auth = HTTPKerberosAuth(mutual_authentication=REQUIRED, sanitize_mutual_error_response=False) + >>> from requests_gssapi import HTTPSPNEGOAuth, REQUIRED + >>> gssapi_auth = HTTPSPNEGOAuth(mutual_authentication=REQUIRED, sanitize_mutual_error_response=False) >>> r = requests.get("https://windows.example.org/wsman", auth=gssapi_auth) ... @@ -49,13 +57,13 @@ OPTIONAL ^^^^^^^^ If you'd prefer to not require mutual authentication, you can set your -preference when constructing your ``HTTPKerberosAuth`` object: +preference when constructing your ``HTTPSPNEGOAuth`` object: .. code-block:: python >>> import requests - >>> from requests_gssapi import HTTPKerberosAuth, OPTIONAL - >>> gssapi_auth = HTTPKerberosAuth(mutual_authentication=OPTIONAL) + >>> from requests_gssapi import HTTPSPNEGOAuth, OPTIONAL + >>> gssapi_auth = HTTPSPNEGOAuth(mutual_authentication=OPTIONAL) >>> r = requests.get("http://example.org", auth=gssapi_auth) ... @@ -72,28 +80,28 @@ authentication, you can do that as well: .. code-block:: python >>> import requests - >>> from requests_gssapi import HTTPKerberosAuth, DISABLED - >>> gssapi_auth = HTTPKerberosAuth(mutual_authentication=DISABLED) + >>> from requests_gssapi import HTTPSPNEGOAuth, DISABLED + >>> gssapi_auth = HTTPSPNEGOAuth(mutual_authentication=DISABLED) >>> r = requests.get("http://example.org", auth=gssapi_auth) ... -Preemptive Authentication -------------------------- +Opportunistic Authentication +---------------------------- -``HTTPKerberosAuth`` can be forced to preemptively initiate the GSSAPI +``HTTPSPNEGOAuth`` can be forced to preemptively initiate the GSSAPI exchange and present a token on the initial request (and all subsequent). By default, authentication only occurs after a ``401 Unauthorized`` response containing a Negotiate challenge is received from the origin server. This can cause mutual authentication failures for hosts that use a persistent connection (eg, Windows/WinRM), as no GSSAPI challenges are sent after the initial auth handshake. This -behavior can be altered by setting ``force_preemptive=True``: +behavior can be altered by setting ``opportunistic_auth=True``: .. code-block:: python >>> import requests - >>> from requests_gssapi import HTTPKerberosAuth, REQUIRED - >>> gssapi_auth = HTTPKerberosAuth(mutual_authentication=REQUIRED, force_preemptive=True) + >>> from requests_gssapi import HTTPSPNEGOAuth, REQUIRED + >>> gssapi_auth = HTTPSPNEGOAuth(mutual_authentication=REQUIRED, opportunistic_authentication=True) >>> r = requests.get("https://windows.example.org/wsman", auth=gssapi_auth) ... @@ -103,31 +111,30 @@ Hostname Override If communicating with a host whose DNS name doesn't match its hostname (eg, behind a content switch or load balancer), the hostname used for the GSSAPI exchange can be overridden by -setting the ``hostname_override`` arg: +passing in a custom name (string or ``gssapi.Name``): .. code-block:: python >>> import requests - >>> from requests_gssapi import HTTPKerberosAuth, REQUIRED - >>> gssapi_auth = HTTPKerberosAuth(hostname_override="internalhost.local") - >>> r = requests.get("https://externalhost.example.org/", auth=kerberos_auth) + >>> from requests_gssapi import HTTPSPNEGOAuth, REQUIRED + >>> gssapi_auth = HTTPSPNEGOAuth(target_name="internalhost.local") + >>> r = requests.get("https://externalhost.example.org/", auth=gssapi_auth) ... Explicit Principal ------------------ -``HTTPKerberosAuth`` normally uses the default principal (ie, the user for -whom you last ran ``kinit`` or ``kswitch``, or an SSO credential if -applicable). However, an explicit principal can be specified, which will -cause GSSAPI to look for a matching credential cache for the named user. -This feature depends on OS support for collection-type credential caches. -An explicit principal can be specified with the ``principal`` arg: +``HTTPSPNEGOAuth`` normally uses the default principal (ie, the user for whom +you last ran ``kinit`` or ``kswitch``, or an SSO credential if +applicable). However, an explicit credential can be in instead, if desired. .. code-block:: python + >>> import gssapi >>> import requests - >>> from requests_gssapi import HTTPKerberosAuth, REQUIRED - >>> gssapi_auth = HTTPKerberosAuth(principal="user@REALM") + >>> from requests_gssapi import HTTPSPNEGOAuth, REQUIRED + >>> creds = gssapi.Credentials(name=gssapi.Name("user@REALM"), usage="initiate") + >>> gssapi_auth = HTTPSPNEGOAuth(creds=creds) >>> r = requests.get("http://example.org", auth=gssapi_auth) ... @@ -136,13 +143,13 @@ Delegation ``requests_gssapi`` supports credential delegation (``GSS_C_DELEG_FLAG``). To enable delegation of credentials to a server that requests delegation, pass -``delegate=True`` to ``HTTPKerberosAuth``: +``delegate=True`` to ``HTTPSPNEGOAuth``: .. code-block:: python >>> import requests - >>> from requests_gssapi import HTTPKerberosAuth - >>> r = requests.get("http://example.org", auth=HTTPKerberosAuth(delegate=True)) + >>> from requests_gssapi import HTTPSPNEGOAuth + >>> r = requests.get("http://example.org", auth=HTTPSPNEGOAuth(delegate=True)) ... Be careful to only allow delegation to servers you trust as they will be able diff --git a/requests_gssapi/__init__.py b/requests_gssapi/__init__.py index 2e43839..4c69401 100644 --- a/requests_gssapi/__init__.py +++ b/requests_gssapi/__init__.py @@ -7,19 +7,19 @@ authentication. Basic GET usage: >>> import requests - >>> from requests_gssapi import HTTPKerberosAuth - >>> r = requests.get("http://example.org", auth=HTTPKerberosAuth()) + >>> from requests_gssapi import HTTPSPNEGOAuth + >>> r = requests.get("http://example.org", auth=HTTPSPNEGOAuth()) The entire `requests.api` should be supported. """ import logging -from .gssapi_ import HTTPKerberosAuth, REQUIRED, OPTIONAL, DISABLED +from .gssapi_ import HTTPSPNEGOAuth, REQUIRED, OPTIONAL, DISABLED # noqa from .exceptions import MutualAuthenticationError -from .compat import NullHandler +from .compat import NullHandler, HTTPKerberosAuth logging.getLogger(__name__).addHandler(NullHandler()) -__all__ = ('HTTPKerberosAuth', 'MutualAuthenticationError', 'REQUIRED', - 'OPTIONAL', 'DISABLED') +__all__ = ('HTTPSPNEGOAuth', 'HTTPKerberosAuth', 'MutualAuthenticationError', + 'REQUIRED', 'OPTIONAL', 'DISABLED') __version__ = '0.11.0' diff --git a/requests_gssapi/compat.py b/requests_gssapi/compat.py index d969fda..ed18142 100644 --- a/requests_gssapi/compat.py +++ b/requests_gssapi/compat.py @@ -1,8 +1,12 @@ """ -Compatibility library for older versions of python +Compatibility library for older versions of python and requests_kerberos """ import sys +import gssapi + +from .gssapi_ import REQUIRED, HTTPSPNEGOAuth, SPNEGOExchangeError, log + # python 2.7 introduced a NullHandler which we want to use, but to support # older versions, we implement our own if needed. if sys.version_info[:2] > (2, 6): @@ -13,3 +17,55 @@ class NullHandler(Handler): def emit(self, record): pass + + +class HTTPKerberosAuth(HTTPSPNEGOAuth): + """Deprecated compat shim; see HTTPSPNEGOAuth instead.""" + def __init__(self, mutual_authentication=REQUIRED, service="HTTP", + delegate=False, force_preemptive=False, principal=None, + hostname_override=None, sanitize_mutual_error_response=True): + # put these here for later + self.principal = principal + self.service = service + self.hostname_override = hostname_override + + HTTPSPNEGOAuth.__init__( + self, + mutual_authentication=mutual_authentication, + target_name=None, + delegate=delegate, + opportunistic_auth=force_preemptive, + creds=None, + sanitize_mutual_error_response=sanitize_mutual_error_response) + + def generate_request_header(self, response, host, is_preemptive=False): + # This method needs to be shimmed because `host` isn't exposed to + # __init__() and we need to derive things from it. Also, __init__() + # can't fail, in the strictest compatability sense. + try: + if self.principal is not None: + gss_stage = "acquiring credentials" + name = gssapi.Name(self.principal) + self.creds = gssapi.Credentials(name=name, usage="initiate") + + # contexts still need to be stored by host, but hostname_override + # allows use of an arbitrary hostname for the GSSAPI exchange (eg, + # in cases of aliased hosts, internal vs external, CNAMEs w/ + # name-based HTTP hosting) + if self.service is not None: + gss_stage = "initiating context" + kerb_host = host + if self.hostname_override: + kerb_host = self.hostname_override + + kerb_spn = "{0}@{1}".format(self.service, kerb_host) + self.target_name = gssapi.Name(kerb_spn) + + return HTTPSPNEGOAuth.generate_request_header(self, response, + host, is_preemptive) + except gssapi.exceptions.GSSError as error: + msg = error.gen_message() + log.exception( + "generate_request_header(): {0} failed:".format(gss_stage)) + log.exception(msg) + raise SPNEGOExchangeError("%s failed: %s" % (gss_stage, msg)) diff --git a/requests_gssapi/exceptions.py b/requests_gssapi/exceptions.py index 15a5f30..a13c400 100644 --- a/requests_gssapi/exceptions.py +++ b/requests_gssapi/exceptions.py @@ -12,5 +12,9 @@ class MutualAuthenticationError(RequestException): """Mutual Authentication Error""" -class KerberosExchangeError(RequestException): - """Kerberos Exchange Failed Error""" +class SPNEGOExchangeError(RequestException): + """SPNEGO Exchange Failed Error""" + + +""" Deprecated compatability shim """ +KerberosExchangeError = SPNEGOExchangeError diff --git a/requests_gssapi/gssapi_.py b/requests_gssapi/gssapi_.py index 001bfcf..1a9bc04 100644 --- a/requests_gssapi/gssapi_.py +++ b/requests_gssapi/gssapi_.py @@ -9,7 +9,7 @@ from requests.structures import CaseInsensitiveDict from requests.cookies import cookiejar_from_dict -from .exceptions import MutualAuthenticationError, KerberosExchangeError +from .exceptions import MutualAuthenticationError, SPNEGOExchangeError log = logging.getLogger(__name__) @@ -78,26 +78,45 @@ def _negotiate_value(response): return None -class HTTPKerberosAuth(AuthBase): - """Attaches HTTP GSSAPI Authentication to the given Request object.""" - def __init__(self, mutual_authentication=REQUIRED, service="HTTP", - delegate=False, force_preemptive=False, principal=None, - hostname_override=None, sanitize_mutual_error_response=True): +class HTTPSPNEGOAuth(AuthBase): + """Attaches HTTP GSSAPI Authentication to the given Request object. + + `mutual_authentication` controls whether GSSAPI should attempt mutual + authentication. It may be `REQUIRED` (default), `OPTIONAL`, or + `DISABLED`. + + `target_name` specifies the remote principal name. It may be either a + GSSAPI name type or a string (default: "HTTP" at the DNS host). + + `delegate` indicates whether we should attempt credential delegation. + Default is `False`. + + `opportunistic_auth` indicates whether we should assume the server will + ask for Negotiation. Defaut is `False`. + + `creds` is GSSAPI credentials (gssapi.Credentials) to use for negotiation. + Default is `None`. + + `sanitize_mutual_error_response` controls whether we should clean up + server responses. See the `SanitizedResponse` class. + """ + def __init__(self, mutual_authentication=REQUIRED, target_name="HTTP", + delegate=False, opportunistic_auth=False, creds=None, + sanitize_mutual_error_response=True): self.context = {} + self.pos = None self.mutual_authentication = mutual_authentication + self.target_name = target_name self.delegate = delegate - self.pos = None - self.service = service - self.force_preemptive = force_preemptive - self.principal = principal - self.hostname_override = hostname_override + self.opportunistic_auth = opportunistic_auth + self.creds = creds self.sanitize_mutual_error_response = sanitize_mutual_error_response def generate_request_header(self, response, host, is_preemptive=False): """ - Generates the GSSAPI authentication token with kerberos. + Generates the GSSAPI authentication token - If any GSSAPI step fails, raise KerberosExchangeError + If any GSSAPI step fails, raise SPNEGOExchangeError with failure detail. """ @@ -108,26 +127,15 @@ def generate_request_header(self, response, host, is_preemptive=False): gssflags.append(gssapi.RequirementFlag.delegate_to_peer) try: - # contexts still need to be stored by host, but hostname_override - # allows use of an arbitrary hostname for the GSSAPI exchange - # (eg, in cases of aliased hosts, internal vs external, CNAMEs - # w/ name-based HTTP hosting) - kerb_host = host - if self.hostname_override: - kerb_host = self.hostname_override - - kerb_spn = "{0}@{1}".format(self.service, kerb_host) - - creds = None - if self.principal: - gss_stage = "acquiring credentials" - creds = gssapi.Credentials(name=gssapi.Name(self.principal), - usage="initiate") - gss_stage = "initiating context" + if type(self.target_name) != gssapi.Name: + if '@' not in self.target_name: + self.target_name = "%s@%s" % (self.target_name, host) + + self.target_name = gssapi.Name(self.target_name) self.context[host] = gssapi.SecurityContext( - usage="initiate", flags=gssflags, name=gssapi.Name(kerb_spn), - creds=creds) + usage="initiate", flags=gssflags, name=self.target_name, + creds=self.creds) gss_stage = "stepping context" if is_preemptive: @@ -143,7 +151,7 @@ def generate_request_header(self, response, host, is_preemptive=False): log.exception( "generate_request_header(): {0} failed:".format(gss_stage)) log.exception(msg) - raise KerberosExchangeError("%s failed: %s" % (gss_stage, msg)) + raise SPNEGOExchangeError("%s failed: %s" % (gss_stage, msg)) def authenticate_user(self, response, **kwargs): """Handles user authentication with GSSAPI""" @@ -152,7 +160,7 @@ def authenticate_user(self, response, **kwargs): try: auth_header = self.generate_request_header(response, host) - except KerberosExchangeError: + except SPNEGOExchangeError: # GSS Failure, return existing response return response @@ -283,7 +291,7 @@ def deregister(self, response): response.request.deregister_hook('response', self.handle_response) def __call__(self, request): - if self.force_preemptive: + if self.opportunistic_auth: # add Authorization header before we receive a 401 # by the 401 handler host = urlparse(request.url).hostname @@ -292,7 +300,7 @@ def __call__(self, request): is_preemptive=True) log.debug( - "HTTPKerberosAuth: Preemptive Authorization header: {0}" + "HTTPSPNEGOAuth: Preemptive Authorization header: {0}" .format(auth_header)) request.headers['Authorization'] = auth_header @@ -301,7 +309,7 @@ def __call__(self, request): try: self.pos = request.body.tell() except AttributeError: - # In the case of HTTPKerberosAuth being reused and the body + # In the case of HTTPSPNEGOAuth being reused and the body # of the previous request was a file-like object, pos has # the file position of the previous body. Ensure it's set to # None. diff --git a/setup.py b/setup.py index ea7bd15..3e97a13 100755 --- a/setup.py +++ b/setup.py @@ -42,7 +42,7 @@ def get_version(): long_description=long_desc, author='Ian Cordasco, Cory Benfield, Michael Komitee, Robbie Harwood', author_email='rharwood@redhat.com', - url='https://github.com/frozencemetery/requests-gssapi', + url='https://github.com/pythongssapi/requests-gssapi', packages=['requests_gssapi'], package_data={'': ['LICENSE', 'AUTHORS']}, include_package_data=True, diff --git a/test_requests_gssapi.py b/test_requests_gssapi.py index a5fd20b..bdc5d31 100644 --- a/test_requests_gssapi.py +++ b/test_requests_gssapi.py @@ -30,7 +30,7 @@ gssdelegflags = gssflags + [gssapi.RequirementFlag.delegate_to_peer] -class KerberosTestCase(unittest.TestCase): +class GSSAPITestCase(unittest.TestCase): def setUp(self): """Setup.""" fake_init.reset_mock() @@ -104,7 +104,7 @@ def test_generate_request_header_init_error(self): response.headers = {'www-authenticate': 'negotiate token'} host = urlparse(response.url).hostname auth = requests_gssapi.HTTPKerberosAuth() - self.assertRaises(requests_gssapi.exceptions.KerberosExchangeError, + self.assertRaises(requests_gssapi.exceptions.SPNEGOExchangeError, auth.generate_request_header, response, host) fake_init.assert_called_with( name=gssapi.Name("HTTP@www.example.org"), @@ -118,7 +118,7 @@ def test_generate_request_header_step_error(self): response.headers = {'www-authenticate': 'negotiate token'} host = urlparse(response.url).hostname auth = requests_gssapi.HTTPKerberosAuth() - self.assertRaises(requests_gssapi.exceptions.KerberosExchangeError, + self.assertRaises(requests_gssapi.exceptions.SPNEGOExchangeError, auth.generate_request_header, response, host) fake_init.assert_called_with( name=gssapi.Name("HTTP@www.example.org"), @@ -560,6 +560,50 @@ def test_realm_override(self): usage="initiate", flags=gssflags, creds=None) fake_resp.assert_called_with("token") + def test_opportunistic_auth(self): + with patch.multiple("gssapi.SecurityContext", __init__=fake_init, + step=fake_resp): + auth = requests_gssapi.HTTPSPNEGOAuth(opportunistic_auth=True) + + request = requests.Request(url="http://www.example.org") + + auth.__call__(request) + + self.assertTrue('Authorization' in request.headers) + self.assertEqual(request.headers.get('Authorization'), + 'Negotiate GSSRESPONSE') + + def test_explicit_creds(self): + with patch.multiple("gssapi.Credentials", __new__=fake_creds), \ + patch.multiple("gssapi.SecurityContext", __init__=fake_init, + step=fake_resp): + response = requests.Response() + response.url = "http://www.example.org/" + response.headers = {'www-authenticate': 'negotiate token'} + host = urlparse(response.url).hostname + creds = gssapi.Credentials() + auth = requests_gssapi.HTTPSPNEGOAuth(creds=creds) + auth.generate_request_header(response, host) + fake_init.assert_called_with( + name=gssapi.Name("HTTP@www.example.org"), + usage="initiate", flags=gssflags, creds="fake creds") + fake_resp.assert_called_with("token") + + def test_target_name(self): + with patch.multiple("gssapi.SecurityContext", __init__=fake_init, + step=fake_resp): + response = requests.Response() + response.url = "http://www.example.org/" + response.headers = {'www-authenticate': 'negotiate token'} + host = urlparse(response.url).hostname + auth = requests_gssapi.HTTPSPNEGOAuth( + target_name="HTTP@otherhost.otherdomain.org") + auth.generate_request_header(response, host) + fake_init.assert_called_with( + name=gssapi.Name("HTTP@otherhost.otherdomain.org"), + usage="initiate", flags=gssflags, creds=None) + fake_resp.assert_called_with("token") + if __name__ == '__main__': unittest.main()