Add optional kerberos support

Check if "kerberos" package can be imported at runtime
(in __init__.py).  If not, everything works as before.
If "kerberos" package can be imported register a new
urllib handler (defined in urllib_kerb.py) which adds
support for kerberos auth.

This urllib handler (urllib_kerb.py) is only triggered
on 401 (Unauthorized) response, and we try to auth
with kerberos. If unsuccessful, next 401 handler (if any)
is triggered.

Change-Id: I1a47e455aa14535a124df950994718a11d7e4f57
This commit is contained in:
Lukas Vacek 2015-10-22 16:28:13 +02:00
parent 3b2dc286c9
commit 4152a0138b
5 changed files with 284 additions and 8 deletions

View File

@ -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.
@ -105,7 +133,7 @@ This is an example showing how to add, configure, enable and delete Jenkins node
server.enable_node('slave1')
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.
@ -118,7 +146,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
@ -136,7 +164,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

View File

@ -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):

121
jenkins/urllib_kerb.py Normal file
View File

@ -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)

View File

@ -1,6 +1,7 @@
coverage>=3.6
discover
hacking>=0.5.6,<0.11
kerberos>=1.2.4
mock<1.1
unittest2
python-subunit

116
tests/test_kerberos.py Normal file
View File

@ -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)