diff --git a/doc/source/examples.rst b/doc/source/examples.rst index 350afd2..9fc5f27 100644 --- a/doc/source/examples.rst +++ b/doc/source/examples.rst @@ -31,8 +31,36 @@ instead of a real password while creating a Jenkins instance. .. _Jenkins Authentication: https://wiki.jenkins-ci.org/display/JENKINS/Authenticating+scripted+clients +Example 2: Logging into Jenkins using kerberos +---------------------------------------------- -Example 2: Working with Jenkins Jobs +Kerberos support is only enabled if you have "kerberos" python package installed. +You can install the "kerberos" package from PyPI using the obvious pip command. + +.. code-block:: bash + + pip install kerberos + +.. note:: This might require python header files as well + as kerberos header files. + +If you have "kerberos" python package installed, python-jenkins tries to authenticate +using kerberos automatically when the Jenkins server replies "401 Unauthorized" +and indicates it supports kerberos. That is, kerberos authentication should +work automagically. For a quick test, just try the following. + +:: + + import jenkins + + server = jenkins.Jenkins('http://localhost:8080') + print server.jobs_count() + +.. note:: Jenkins as such does not support kerberos, it needs to be supported by + the Servlet container or a reverse proxy sitting in front of Jenkins. + + +Example 3: Working with Jenkins Jobs ------------------------------------ This is an example showing how to create, configure and delete Jenkins jobs. @@ -59,7 +87,7 @@ This is an example showing how to create, configure and delete Jenkins jobs. print build_info -Example 3: Working with Jenkins Views +Example 4: Working with Jenkins Views ------------------------------------- This is an example showing how to create, configure and delete Jenkins views. @@ -73,7 +101,7 @@ This is an example showing how to create, configure and delete Jenkins views. print views -Example 4: Working with Jenkins Plugins +Example 5: Working with Jenkins Plugins --------------------------------------- This is an example showing how to retrieve Jenkins plugins information. @@ -89,7 +117,7 @@ from the :func:`get_plugins_info` method is documented in the :doc:`api` doc. -Example 5: Working with Jenkins Nodes +Example 6: Working with Jenkins Nodes ------------------------------------- This is an example showing how to add, configure, enable and delete Jenkins nodes. @@ -120,7 +148,7 @@ This is an example showing how to add, configure, enable and delete Jenkins node launcher=jenkins.LAUNCHER_SSH, launcher_params=params) -Example 6: Working with Jenkins Build Queue +Example 7: Working with Jenkins Build Queue ------------------------------------------- This is an example showing how to retrieve information on the Jenkins queue. @@ -133,7 +161,7 @@ This is an example showing how to retrieve information on the Jenkins queue. server.cancel_queue(id) -Example 7: Working with Jenkins Cloudbees Folders +Example 8: Working with Jenkins Cloudbees Folders ------------------------------------------------- Requires the `Cloudbees Folders Plugin @@ -151,7 +179,7 @@ This is an example showing how to create, configure and delete Jenkins folders. server.delete_job('folder') -Example 8: Updating Next Build Number +Example 9: Updating Next Build Number ------------------------------------- Requires the `Next Build Number Plugin diff --git a/jenkins/__init__.py b/jenkins/__init__.py index 9f6075a..677fa5e 100644 --- a/jenkins/__init__.py +++ b/jenkins/__init__.py @@ -60,10 +60,20 @@ from six.moves.http_client import BadStatusLine from six.moves.urllib.error import HTTPError from six.moves.urllib.error import URLError from six.moves.urllib.parse import quote, urlencode, urljoin, urlparse -from six.moves.urllib.request import Request, urlopen +from six.moves.urllib.request import Request, install_opener, build_opener, urlopen from jenkins import plugins +try: + import kerberos + assert kerberos # pyflakes + import urllib_kerb + opener = build_opener() + opener.add_handler(urllib_kerb.HTTPNegotiateHandler()) + install_opener(opener) +except ImportError: + pass + warnings.simplefilter("default", DeprecationWarning) if sys.version_info < (2, 7, 0): diff --git a/jenkins/urllib_kerb.py b/jenkins/urllib_kerb.py new file mode 100644 index 0000000..a58ac35 --- /dev/null +++ b/jenkins/urllib_kerb.py @@ -0,0 +1,121 @@ +# Copyright (C) 2015 OpenStack Foundation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import logging +import re + +import kerberos +from six.moves.urllib import error, request + +logger = logging.getLogger(__name__) + + +class HTTPNegotiateHandler(request.BaseHandler): + handler_order = 490 # before Digest auth + + def __init__(self, max_tries=5): + self.krb_context = None + self.tries = 0 + self.max_tries = max_tries + self.re_extract_auth = re.compile('.*?Negotiate\s*([^,]*)', re.I) + + def http_error_401(self, req, fp, code, msg, headers): + logger.debug("INSIDE http_error_401") + try: + try: + krb_req = self._extract_krb_value(headers) + except ValueError: + # Negotiate header not found or a similar error + # we can't handle this, let the next handler have a go + return None + + if not krb_req: + # First reply from server (no neg value) + self.tries = 0 + krb_req = "" + else: + if self.tries > self.max_tries: + raise error.HTTPError( + req.get_full_url(), 401, "Negotiate auth failed", + headers, None) + + self.tries += 1 + try: + krb_resp = self._krb_response(req.get_host(), krb_req) + + req.add_unredirected_header('Authorization', + "Negotiate %s" % krb_resp) + + resp = self.parent.open(req, timeout=req.timeout) + self._authenticate_server(resp.headers) + return resp + + except kerberos.GSSError as err: + try: + msg = err.args[1][0] + except Exception: + msg = "Negotiate auth failed" + logger.debug(msg) + return None # let the next handler (if any) have a go + + finally: + if self.krb_context is not None: + kerberos.authGSSClientClean(self.krb_context) + self.krb_context = None + + def _krb_response(self, host, krb_val): + logger.debug("INSIDE _krb_response") + + _dummy, self.krb_context = kerberos.authGSSClientInit("HTTP@%s" % host) + kerberos.authGSSClientStep(self.krb_context, krb_val) + response = kerberos.authGSSClientResponse(self.krb_context) + + logger.debug("kerb auth successful") + + return response + + def _authenticate_server(self, headers): + logger.debug("INSIDE _authenticate_server") + try: + val = self._extract_krb_value(headers) + except ValueError: + logger.critical("Server authentication failed." + "Auth value couldn't be extracted from headers.") + return None + if not val: + logger.critical("Server authentication failed." + "Empty 'Negotiate' value.") + return None + + kerberos.authGSSClientStep(self.krb_context, val) + + def _extract_krb_value(self, headers): + logger.debug("INSIDE _extract_krb_value") + header = headers.get('www-authenticate', None) + + if header is None: + msg = "www-authenticate header not found" + logger.debug(msg) + raise ValueError(msg) + + if "negotiate" in header.lower(): + matches = self.re_extract_auth.search(header) + if matches: + return matches.group(1) + else: + return "" + else: + msg = "Negotiate not in www-authenticate header (%s)" % header + logger.debug(msg) + raise ValueError(msg) diff --git a/test-requirements.txt b/test-requirements.txt index 956f12a..a08b50d 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,6 +1,7 @@ coverage>=3.6 discover hacking>=0.5.6,<0.11 +kerberos>=1.2.4 mock<1.1 unittest2 python-subunit diff --git a/tests/test_kerberos.py b/tests/test_kerberos.py new file mode 100644 index 0000000..d8c4c83 --- /dev/null +++ b/tests/test_kerberos.py @@ -0,0 +1,116 @@ +import kerberos +assert kerberos # pyflakes +from mock import patch, Mock +import testtools + +from jenkins import urllib_kerb + + +class KerberosTests(testtools.TestCase): + + @patch('kerberos.authGSSClientResponse') + @patch('kerberos.authGSSClientStep') + @patch('kerberos.authGSSClientInit') + @patch('kerberos.authGSSClientClean') + def test_http_error_401_simple(self, clean_mock, init_mock, step_mock, response_mock): + headers_from_server = {'www-authenticate': 'Negotiate xxx'} + + init_mock.side_effect = lambda x: (x, "context") + response_mock.return_value = "foo" + + parent_mock = Mock() + parent_return_mock = Mock() + parent_return_mock.headers = {'www-authenticate': "Negotiate bar"} + parent_mock.open.return_value = parent_return_mock + + request_mock = Mock() + h = urllib_kerb.HTTPNegotiateHandler() + h.add_parent(parent_mock) + rv = h.http_error_401(request_mock, "", "", "", headers_from_server) + + init_mock.assert_called() + step_mock.assert_any_call("context", "xxx") + # verify authGSSClientStep was called for response as well + step_mock.assert_any_call("context", "bar") + response_mock.assert_called_with("context") + request_mock.add_unredirected_header.assert_called_with( + 'Authorization', 'Negotiate %s' % "foo") + self.assertEqual(rv, parent_return_mock) + clean_mock.assert_called_with("context") + + @patch('kerberos.authGSSClientResponse') + @patch('kerberos.authGSSClientStep') + @patch('kerberos.authGSSClientInit') + @patch('kerberos.authGSSClientClean') + def test_http_error_401_gsserror(self, clean_mock, init_mock, step_mock, response_mock): + headers_from_server = {'www-authenticate': 'Negotiate xxx'} + + init_mock.side_effect = kerberos.GSSError + + h = urllib_kerb.HTTPNegotiateHandler() + rv = h.http_error_401(Mock(), "", "", "", headers_from_server) + self.assertEqual(rv, None) + + @patch('kerberos.authGSSClientResponse') + @patch('kerberos.authGSSClientStep') + @patch('kerberos.authGSSClientInit') + @patch('kerberos.authGSSClientClean') + def test_http_error_401_empty(self, clean_mock, init_mock, step_mock, response_mock): + headers_from_server = {} + + h = urllib_kerb.HTTPNegotiateHandler() + rv = h.http_error_401(Mock(), "", "", "", headers_from_server) + self.assertEqual(rv, None) + + @patch('kerberos.authGSSClientResponse') + @patch('kerberos.authGSSClientStep') + @patch('kerberos.authGSSClientInit') + def test_krb_response_simple(self, init_mock, step_mock, response_mock): + response_mock.return_value = "foo" + init_mock.return_value = ("bar", "context") + h = urllib_kerb.HTTPNegotiateHandler() + rv = h._krb_response("host", "xxx") + self.assertEqual(rv, "foo") + + @patch('kerberos.authGSSClientResponse') + @patch('kerberos.authGSSClientStep') + @patch('kerberos.authGSSClientInit') + def test_krb_response_gsserror(self, init_mock, step_mock, response_mock): + response_mock.side_effect = kerberos.GSSError + init_mock.return_value = ("bar", "context") + h = urllib_kerb.HTTPNegotiateHandler() + with testtools.ExpectedException(kerberos.GSSError): + h._krb_response("host", "xxx") + + @patch('kerberos.authGSSClientStep') + def test_authenticate_server_simple(self, step_mock): + headers_from_server = {'www-authenticate': 'Negotiate xxx'} + h = urllib_kerb.HTTPNegotiateHandler() + h.krb_context = "foo" + h._authenticate_server(headers_from_server) + step_mock.assert_called_with("foo", "xxx") + + @patch('kerberos.authGSSClientStep') + def test_authenticate_server_empty(self, step_mock): + headers_from_server = {'www-authenticate': 'Negotiate'} + h = urllib_kerb.HTTPNegotiateHandler() + rv = h._authenticate_server(headers_from_server) + self.assertEqual(rv, None) + + def test_extract_krb_value_simple(self): + headers_from_server = {'www-authenticate': 'Negotiate xxx'} + h = urllib_kerb.HTTPNegotiateHandler() + rv = h._extract_krb_value(headers_from_server) + self.assertEqual(rv, "xxx") + + def test_extract_krb_value_empty(self): + headers_from_server = {} + h = urllib_kerb.HTTPNegotiateHandler() + with testtools.ExpectedException(ValueError): + h._extract_krb_value(headers_from_server) + + def test_extract_krb_value_invalid(self): + headers_from_server = {'www-authenticate': 'Foo-&#@^%:; bar'} + h = urllib_kerb.HTTPNegotiateHandler() + with testtools.ExpectedException(ValueError): + h._extract_krb_value(headers_from_server)