diff --git a/docs/html/reference/pip_install.rst b/docs/html/reference/pip_install.rst index f56dc9a0e9f..785b1bb2f8f 100644 --- a/docs/html/reference/pip_install.rst +++ b/docs/html/reference/pip_install.rst @@ -513,6 +513,21 @@ SSL Certificate Verification Starting with v1.3, pip provides SSL certificate verification over https, to prevent man-in-the-middle attacks against PyPI downloads. +.. _`Kerberos Authentication`: + +Kerberos Authentication +++++++++++++++++++++++++++++ + +Starting with v10.0, pip supports using a Kerberos ticket to authenticate +with servers. This feature requires that ``pykerberos`` or ``winkerberos`` +is installed in the same environment as pip. + +If you wish to ignore Kerberos authenticated (index) servers for bootstrapping +the installation of ``pykerberos`` or ``winkerberos`` or are not authenticated +for all servers by default pip will ask for input. To change this behaviour +to ignore those servers use the ``--no-input`` command line option. Your system +administrator can also set this in the config files or an environment variable, +see :ref:`Configuration`. .. _`Caching`: diff --git a/news/4854.feature b/news/4854.feature new file mode 100644 index 00000000000..ddd5c6ecaa5 --- /dev/null +++ b/news/4854.feature @@ -0,0 +1 @@ +Add kerberos support to possible authenticators, when available. Vendor in requests_kerberos 0.11.0. diff --git a/news/requests_kerberos.vendor b/news/requests_kerberos.vendor new file mode 100644 index 00000000000..cf17078720b --- /dev/null +++ b/news/requests_kerberos.vendor @@ -0,0 +1 @@ +Vendored requests_kerberos at requests_kerberos==0.11.0 diff --git a/src/pip/_internal/download.py b/src/pip/_internal/download.py index b681ccb0681..567b3874569 100644 --- a/src/pip/_internal/download.py +++ b/src/pip/_internal/download.py @@ -101,6 +101,14 @@ ) +try: + from pip._vendor.requests_kerberos import HTTPKerberosAuth + from pip._vendor.requests_kerberos import kerberos_ as ik + _KERBEROS_AVAILABLE = True + +except ImportError: + _KERBEROS_AVAILABLE = False + __all__ = ['get_file_content', 'is_url', 'url_to_path', 'path_to_url', 'is_archive_file', 'unpack_vcs_link', @@ -494,6 +502,47 @@ def save_credentials(self, resp, **kwargs): logger.exception('Failed to save credentials') +class MultiAuth(AuthBase): + def __init__(self, initial_auth=None, *auths): + if initial_auth is None: + self.initial_auth = MultiDomainBasicAuth(prompting=False) + else: + self.initial_auth = initial_auth + + self.auths = auths + + def __call__(self, req): + req = self.initial_auth(req) + self._register_hook(req, 0) # register hook after auth itself + return req + + def _register_hook(self, req, i): + if i >= len(self.auths): + return + + def hook(resp, **kwargs): + self.handle_response(resp, i, **kwargs) + + req.register_hook("response", hook) + + def handle_response(self, resp, i, **kwargs): + if resp.status_code != 401: # authorization required + return resp + + # clear response + resp.content + resp.raw.release_conn() + + req = self.auths[i](resp.request) # deletegate to ith auth + logger.info('registering hook {}'.format(i + 1)) + self._register_hook(req, i + 1) # register hook after auth itself + + new_resp = resp.connection.send(req, **kwargs) + new_resp.history.append(resp) + + return new_resp + + class LocalFSAdapter(BaseAdapter): def send(self, request, stream=None, timeout=None, verify=None, cert=None, @@ -579,8 +628,10 @@ def __init__(self, *args, **kwargs): """ retries = kwargs.pop("retries", 0) cache = kwargs.pop("cache", None) + trusted_hosts = kwargs.pop("trusted_hosts", []) # type: List[str] index_urls = kwargs.pop("index_urls", None) + prompting = kwargs.pop("prompting", True) super(PipSession, self).__init__(*args, **kwargs) @@ -592,7 +643,21 @@ def __init__(self, *args, **kwargs): self.headers["User-Agent"] = user_agent() # Attach our Authentication handler to the session - self.auth = MultiDomainBasicAuth(index_urls=index_urls) + no_prompt = MultiDomainBasicAuth(prompting=False) + prompt = MultiDomainBasicAuth(prompting=True) + prompt.passwords = no_prompt.passwords # share same dict of passwords + + if _KERBEROS_AVAILABLE and prompting: + auths = [no_prompt, HTTPKerberosAuth(ik.REQUIRED), prompt] + elif _KERBEROS_AVAILABLE and not prompting: + auths = [no_prompt, HTTPKerberosAuth(ik.REQUIRED)] + else: + auths = [MultiDomainBasicAuth( + prompting=prompting, + index_urls=index_urls + )] + + self.auth = MultiAuth(*auths) # Create our urllib3.Retry instance which will allow us to customize # how we handle retries. diff --git a/src/pip/_internal/utils/logging.py b/src/pip/_internal/utils/logging.py index 3fbec712709..e577077eb63 100644 --- a/src/pip/_internal/utils/logging.py +++ b/src/pip/_internal/utils/logging.py @@ -304,6 +304,13 @@ def setup_logging(verbosity, no_color, user_log_file): # enabled for vendored libraries. vendored_log_level = "WARNING" if level in ["INFO", "ERROR"] else "DEBUG" + # Similar for vendored Kerberos, which is a bit trigger happy. + logging.addLevelName(logging.CRITICAL + 1, "SUPERCRITICAL") + kerberos_log_level = ( + "SUPERCRITICAL" if level in ["INFO", "ERROR"] else + "DEBUG" + ) + # Shorthands for clarity log_streams = { "stdout": "ext://sys.stdout", @@ -387,8 +394,11 @@ def setup_logging(verbosity, no_color, user_log_file): "loggers": { "pip._vendor": { "level": vendored_log_level + }, + "pip._vendor.requests_kerberos.kerberos_": { + "level": kerberos_log_level } - }, + } }) return level_number diff --git a/src/pip/_vendor/README.rst b/src/pip/_vendor/README.rst index 539634204cf..7401d97378b 100644 --- a/src/pip/_vendor/README.rst +++ b/src/pip/_vendor/README.rst @@ -104,6 +104,7 @@ Modifications * ``CacheControl`` has been modified to import its dependencies from ``pip._vendor`` * ``requests`` has been modified to import its other dependencies from ``pip._vendor`` and to *not* load ``simplejson`` (all platforms) and ``pyopenssl`` (Windows). +* ``requests_kerberos`` has been modified to import its dependencies from ``pip._vendor`` Automatic Vendoring diff --git a/src/pip/_vendor/requests_kerberos/__init__.py b/src/pip/_vendor/requests_kerberos/__init__.py new file mode 100644 index 00000000000..d8dea418e7b --- /dev/null +++ b/src/pip/_vendor/requests_kerberos/__init__.py @@ -0,0 +1,25 @@ +""" +requests Kerberos/GSSAPI authentication library +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Requests is an HTTP library, written in Python, for human beings. This library +adds optional Kerberos/GSSAPI authentication support and supports mutual +authentication. Basic GET usage: + + >>> import pip._vendor.requests + >>> from pip._vendor.requests_kerberos import HTTPKerberosAuth + >>> r = pip._vendor.requests.get("http://example.org", auth=HTTPKerberosAuth()) + +The entire `requests.api` should be supported. +""" +import logging + +from .kerberos_ import HTTPKerberosAuth, REQUIRED, OPTIONAL, DISABLED +from .exceptions import MutualAuthenticationError +from .compat import NullHandler + +logging.getLogger(__name__).addHandler(NullHandler()) + +__all__ = ('HTTPKerberosAuth', 'MutualAuthenticationError', 'REQUIRED', + 'OPTIONAL', 'DISABLED') +__version__ = '0.11.0' diff --git a/src/pip/_vendor/requests_kerberos/compat.py b/src/pip/_vendor/requests_kerberos/compat.py new file mode 100644 index 00000000000..01b75009805 --- /dev/null +++ b/src/pip/_vendor/requests_kerberos/compat.py @@ -0,0 +1,14 @@ +""" +Compatibility library for older versions of python +""" +import sys + +# 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): + from logging import NullHandler +else: + from logging import Handler + class NullHandler(Handler): + def emit(self, record): + pass diff --git a/src/pip/_vendor/requests_kerberos/exceptions.py b/src/pip/_vendor/requests_kerberos/exceptions.py new file mode 100644 index 00000000000..db1ca771495 --- /dev/null +++ b/src/pip/_vendor/requests_kerberos/exceptions.py @@ -0,0 +1,15 @@ +""" +requests_kerberos.exceptions +~~~~~~~~~~~~~~~~~~~ + +This module contains the set of exceptions. + +""" +from pip._vendor.requests.exceptions import RequestException + + +class MutualAuthenticationError(RequestException): + """Mutual Authentication Error""" + +class KerberosExchangeError(RequestException): + """Kerberos Exchange Failed Error""" diff --git a/src/pip/_vendor/requests_kerberos/kerberos_.py b/src/pip/_vendor/requests_kerberos/kerberos_.py new file mode 100644 index 00000000000..353187af234 --- /dev/null +++ b/src/pip/_vendor/requests_kerberos/kerberos_.py @@ -0,0 +1,323 @@ +try: + import kerberos +except ImportError: + import winkerberos as kerberos +import re +import logging + +from pip._vendor.requests.auth import AuthBase +from pip._vendor.requests.models import Response +from pip._vendor.requests.compat import urlparse, StringIO +from pip._vendor.requests.structures import CaseInsensitiveDict +from pip._vendor.requests.cookies import cookiejar_from_dict + +from .exceptions import MutualAuthenticationError, KerberosExchangeError + +log = logging.getLogger(__name__) + +# Different types of mutual authentication: +# with mutual_authentication set to REQUIRED, all responses will be +# authenticated with the exception of errors. Errors will have their contents +# and headers stripped. If a non-error response cannot be authenticated, a +# MutualAuthenticationError exception will be raised. +# with mutual_authentication set to OPTIONAL, mutual authentication will be +# attempted if supported, and if supported and failed, a +# MutualAuthenticationError exception will be raised. Responses which do not +# support mutual authentication will be returned directly to the user. +# with mutual_authentication set to DISABLED, mutual authentication will not be +# attempted, even if supported. +REQUIRED = 1 +OPTIONAL = 2 +DISABLED = 3 + +class SanitizedResponse(Response): + """The :class:`Response ` object, which contains a server's + response to an HTTP request. + + This differs from `requests.models.Response` in that it's headers and + content have been sanitized. This is only used for HTTP Error messages + which do not support mutual authentication when mutual authentication is + required.""" + + def __init__(self, response): + super(SanitizedResponse, self).__init__() + self.status_code = response.status_code + self.encoding = response.encoding + self.raw = response.raw + self.reason = response.reason + self.url = response.url + self.request = response.request + self.connection = response.connection + self._content_consumed = True + + self._content = "" + self.cookies = cookiejar_from_dict({}) + self.headers = CaseInsensitiveDict() + self.headers['content-length'] = '0' + for header in ('date', 'server'): + if header in response.headers: + self.headers[header] = response.headers[header] + + +def _negotiate_value(response): + """Extracts the gssapi authentication token from the appropriate header""" + if hasattr(_negotiate_value, 'regex'): + regex = _negotiate_value.regex + else: + # There's no need to re-compile this EVERY time it is called. Compile + # it once and you won't have the performance hit of the compilation. + regex = re.compile('(?:.*,)*\s*Negotiate\s*([^,]*),?', re.I) + _negotiate_value.regex = regex + + authreq = response.headers.get('www-authenticate', None) + + if authreq: + match_obj = regex.search(authreq) + if match_obj: + return match_obj.group(1) + + return None + + +class HTTPKerberosAuth(AuthBase): + """Attaches HTTP GSSAPI/Kerberos 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): + self.context = {} + self.mutual_authentication = mutual_authentication + self.delegate = delegate + self.pos = None + self.service = service + self.force_preemptive = force_preemptive + self.principal = principal + self.hostname_override = hostname_override + 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. + + If any GSSAPI step fails, raise KerberosExchangeError + with failure detail. + + """ + + # Flags used by kerberos module. + gssflags = kerberos.GSS_C_MUTUAL_FLAG | kerberos.GSS_C_SEQUENCE_FLAG + if self.delegate: + gssflags |= kerberos.GSS_C_DELEG_FLAG + + try: + kerb_stage = "authGSSClientInit()" + # contexts still need to be stored by host, but hostname_override + # allows use of an arbitrary hostname for the kerberos exchange + # (eg, in cases of aliased hosts, internal vs external, CNAMEs + # w/ name-based HTTP hosting) + kerb_host = self.hostname_override if self.hostname_override is not None else host + kerb_spn = "{0}@{1}".format(self.service, kerb_host) + + result, self.context[host] = kerberos.authGSSClientInit(kerb_spn, + gssflags=gssflags, principal=self.principal) + + if result < 1: + raise EnvironmentError(result, kerb_stage) + + # if we have a previous response from the server, use it to continue + # the auth process, otherwise use an empty value + negotiate_resp_value = '' if is_preemptive else _negotiate_value(response) + + kerb_stage = "authGSSClientStep()" + result = kerberos.authGSSClientStep(self.context[host], + negotiate_resp_value) + + if result < 0: + raise EnvironmentError(result, kerb_stage) + + kerb_stage = "authGSSClientResponse()" + gss_response = kerberos.authGSSClientResponse(self.context[host]) + + return "Negotiate {0}".format(gss_response) + + except kerberos.GSSError as error: + log.exception( + "generate_request_header(): {0} failed:".format(kerb_stage)) + log.exception(error) + raise KerberosExchangeError("%s failed: %s" % (kerb_stage, str(error.args))) + + except EnvironmentError as error: + # ensure we raised this for translation to KerberosExchangeError + # by comparing errno to result, re-raise if not + if error.errno != result: + raise + message = "{0} failed, result: {1}".format(kerb_stage, result) + log.error("generate_request_header(): {0}".format(message)) + raise KerberosExchangeError(message) + + def authenticate_user(self, response, **kwargs): + """Handles user authentication with gssapi/kerberos""" + + host = urlparse(response.url).hostname + + try: + auth_header = self.generate_request_header(response, host) + except KerberosExchangeError: + # GSS Failure, return existing response + return response + + log.debug("authenticate_user(): Authorization header: {0}".format( + auth_header)) + response.request.headers['Authorization'] = auth_header + + # Consume the content so we can reuse the connection for the next + # request. + response.content + response.raw.release_conn() + + _r = response.connection.send(response.request, **kwargs) + _r.history.append(response) + + log.debug("authenticate_user(): returning {0}".format(_r)) + return _r + + def handle_401(self, response, **kwargs): + """Handles 401's, attempts to use gssapi/kerberos authentication""" + + log.debug("handle_401(): Handling: 401") + if _negotiate_value(response) is not None: + _r = self.authenticate_user(response, **kwargs) + log.debug("handle_401(): returning {0}".format(_r)) + return _r + else: + log.debug("handle_401(): Kerberos is not supported") + log.debug("handle_401(): returning {0}".format(response)) + return response + + def handle_other(self, response): + """Handles all responses with the exception of 401s. + + This is necessary so that we can authenticate responses if requested""" + + log.debug("handle_other(): Handling: %d" % response.status_code) + + if self.mutual_authentication in (REQUIRED, OPTIONAL): + + is_http_error = response.status_code >= 400 + + if _negotiate_value(response) is not None: + log.debug("handle_other(): Authenticating the server") + if not self.authenticate_server(response): + # Mutual authentication failure when mutual auth is wanted, + # raise an exception so the user doesn't use an untrusted + # response. + log.error("handle_other(): Mutual authentication failed") + raise MutualAuthenticationError("Unable to authenticate " + "{0}".format(response)) + + # Authentication successful + log.debug("handle_other(): returning {0}".format(response)) + return response + + elif is_http_error or self.mutual_authentication == OPTIONAL: + if not response.ok: + log.error("handle_other(): Mutual authentication unavailable " + "on {0} response".format(response.status_code)) + + if(self.mutual_authentication == REQUIRED and + self.sanitize_mutual_error_response): + return SanitizedResponse(response) + else: + return response + else: + # Unable to attempt mutual authentication when mutual auth is + # required, raise an exception so the user doesnt use an + # untrusted response. + log.error("handle_other(): Mutual authentication failed") + raise MutualAuthenticationError("Unable to authenticate " + "{0}".format(response)) + else: + log.debug("handle_other(): returning {0}".format(response)) + return response + + def authenticate_server(self, response): + """ + Uses GSSAPI to authenticate the server. + + Returns True on success, False on failure. + """ + + log.debug("authenticate_server(): Authenticate header: {0}".format( + _negotiate_value(response))) + + host = urlparse(response.url).hostname + + try: + result = kerberos.authGSSClientStep(self.context[host], + _negotiate_value(response)) + except kerberos.GSSError: + log.exception("authenticate_server(): authGSSClientStep() failed:") + return False + + if result < 1: + log.error("authenticate_server(): authGSSClientStep() failed: " + "{0}".format(result)) + return False + + log.debug("authenticate_server(): returning {0}".format(response)) + return True + + def handle_response(self, response, **kwargs): + """Takes the given response and tries kerberos-auth, as needed.""" + num_401s = kwargs.pop('num_401s', 0) + + if self.pos is not None: + # Rewind the file position indicator of the body to where + # it was to resend the request. + response.request.body.seek(self.pos) + + if response.status_code == 401 and num_401s < 2: + # 401 Unauthorized. Handle it, and if it still comes back as 401, + # that means authentication failed. + _r = self.handle_401(response, **kwargs) + log.debug("handle_response(): returning %s", _r) + log.debug("handle_response() has seen %d 401 responses", num_401s) + num_401s += 1 + return self.handle_response(_r, num_401s=num_401s, **kwargs) + elif response.status_code == 401 and num_401s >= 2: + # Still receiving 401 responses after attempting to handle them. + # Authentication has failed. Return the 401 response. + log.debug("handle_response(): returning 401 %s", response) + return response + else: + _r = self.handle_other(response) + log.debug("handle_response(): returning %s", _r) + return _r + + def deregister(self, response): + """Deregisters the response handler""" + response.request.deregister_hook('response', self.handle_response) + + def __call__(self, request): + if self.force_preemptive: + # add Authorization header before we receive a 401 + # by the 401 handler + host = urlparse(request.url).hostname + + auth_header = self.generate_request_header(None, host, is_preemptive=True) + + log.debug("HTTPKerberosAuth: Preemptive Authorization header: {0}".format(auth_header)) + + request.headers['Authorization'] = auth_header + + request.register_hook('response', self.handle_response) + try: + self.pos = request.body.tell() + except AttributeError: + # In the case of HTTPKerberosAuth 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. + self.pos = None + return request diff --git a/src/pip/_vendor/vendor.txt b/src/pip/_vendor/vendor.txt index dbf7d664a58..2e8e25df63b 100644 --- a/src/pip/_vendor/vendor.txt +++ b/src/pip/_vendor/vendor.txt @@ -21,3 +21,4 @@ retrying==1.3.3 setuptools==41.0.1 six==1.12.0 webencodings==0.5.1 +requests_kerberos==0.11.0