Add GSSAPI/Kerberos authentication via Negotiate
authorGuido Günther <agx@sigxcpu.org>
Fri, 8 Apr 2016 20:57:57 +0000 (22:57 +0200)
committerGuido Günther <agx@sigxcpu.org>
Sat, 9 Apr 2016 19:29:50 +0000 (21:29 +0200)
When the service name is set via the servicename config option and
pykerberos is installed allow authentication via the negotiate header.

Since this is not using basic auth and its on top of all other
authenciation schemes its not implemented as an acl module. This will
also allow us to make the whole negotiate auth be connection based in
the future.

The current code results in the user being "user@REALM" so in case of
using "acl.personal=True" the directories need to be name like this as
well so we want to add a user to principal mapping at one point.

This has been succesfully tested with iceowl.

README
calypso/__init__.py
calypso/acl/nopwd.py
calypso/gssapi.py [new file with mode: 0644]

diff --git a/README b/README
index 11a8c23645cb98fe7ee0098a9e805eb913d1d4db..f79d693fbd2c9765ea933ca34539fbadfea2745b 100644 (file)
--- a/README
+++ b/README
@@ -60,3 +60,25 @@ Given a set of files with VCALENDAR or VCARD entries, you can import them with:
 $ calypso --import private/test <filenames...>
 
 This will update any changed entries and add any new ones.
+
+Kerberos via GSSAPI support
+---------------------------
+For Kerberos authentication generate a keytab on your KDC and put the
+exported keytab on your calypso server into */etc/krb.keytab* so it
+looks like:
+
+  # ktutil -k /etc/krb5.keytab list
+  /etc/krb5.keytab:
+
+  Vno  Type                     Principal                         Aliases
+    1  aes256-cts-hmac-sha1-96  HTTP/foo.example.com@EXAMPLE.COM
+    1  des3-cbc-sha1            HTTP/foo.example.com@EXAMPLE.COM
+    1  arcfour-hmac-md5         HTTP/foo.example.com@EXAMPLE.COM
+
+the put the service name to use into ~/.config/calypso/config:
+
+  [server]
+  servicename=HTTP@foo.example.com
+
+and install the pykerberos module. You should then be able to authenticate
+via Kerberos using GSSAPI.
index 38a08d7d3d61ae286f0d9eeb8b2f8c41c24451ec..40987dd0b135acf829bc4cacd02bd10caa7fb0a6 100644 (file)
@@ -53,13 +53,14 @@ except ImportError:
     import BaseHTTPServer as server
 # pylint: enable=F0401
 
-from . import acl, config, webdav, xmlutils, paths
+from . import acl, config, webdav, xmlutils, paths, gssapi
 
 log = logging.getLogger()
 ch = logging.StreamHandler()
 formatter = logging.Formatter("%(message)s")
 ch.setFormatter (formatter)
 log.addHandler(ch)
+negotiate = gssapi.Negotiate(log)
 
 VERSION = "1.5"
 
@@ -67,25 +68,29 @@ def _check(request, function):
     """Check if user has sufficient rights for performing ``request``."""
     # ``_check`` decorator can access ``request`` protected functions
     # pylint: disable=W0212
+    owner = user = password = None
+    negotiate_success = False
 
-    authorization = request.headers.get("Authorization", None)
-    if authorization:
-        challenge = authorization.lstrip("Basic").strip().encode("ascii")
-        plain = request._decode(base64.b64decode(challenge))
-        user, password = plain.split(":")
-    else:
-        user = password = None
-
-    owner = None
     if request._collection:
         owner = request._collection.owner
 
+    authorization = request.headers.get("Authorization", None)
+    if authorization:
+        if authorization.startswith("Basic"):
+            challenge = authorization.lstrip("Basic").strip().encode("ascii")
+            plain = request._decode(base64.b64decode(challenge))
+            user, password = plain.split(":")
+        elif negotiate.enabled():
+            user, negotiate_success = negotiate.try_aaa(authorization, request, owner)
+
     # Also send UNAUTHORIZED if there's no collection. Otherwise one
     # could probe the server for (non-)existing collections.
-    if request.server.acl.has_right(owner, user, password):
+    if request.server.acl.has_right(owner, user, password) or negotiate_success:
         function(request, context={"user": user, "user-agent": request.headers.get("User-Agent", None)})
     else:
         request.send_calypso_response(client.UNAUTHORIZED, 0)
+        if negotiate.enabled():
+            request.send_header("WWW-Authenticate", "Negotiate")
         request.send_header(
             "WWW-Authenticate",
             'Basic realm="Calypso CalDAV/CardDAV server - password required"')
@@ -138,6 +143,21 @@ class CollectionHTTPHandler(server.BaseHTTPRequestHandler):
     timeout = 90
 
     server_version = "Calypso/%s" % VERSION
+    queued_headers = {}
+
+    def queue_header(self, keyword, value):
+        self.queued_headers[keyword] = value
+
+    def end_headers(self):
+        """
+        Send out all queued headers and invoke or super classes
+        end_header.
+        """
+        if self.queued_headers:
+            for keyword, val in self.queued_headers.items():
+                self.send_header(keyword, val)
+            self.queued_headers = {}
+        return server.BaseHTTPRequestHandler.end_headers(self)
 
     def address_string(self):
         return str(self.client_address[0])
index 4ef84baf21d15483db78af2cba0517b97595ee27..9a8bbca8b1840e1d40f062630b98e8d609770fb0 100644 (file)
@@ -29,7 +29,7 @@ log = logging.getLogger()
 
 def has_right(owner, user, password):
     """Check if ``user`` is valid."""
-    log.debug("owner %s user %s", owner, user)
+    log.debug("owner '%s' user '%s'", owner, user)
     if user == owner or not PERSONAL:
         return True
     return False
diff --git a/calypso/gssapi.py b/calypso/gssapi.py
new file mode 100644 (file)
index 0000000..a1f3d84
--- /dev/null
@@ -0,0 +1,98 @@
+# -*- coding: utf-8 -*-
+#
+# This file is part of Calypso - CalDAV/CardDAV/WebDAV Server
+# Copyright © 2016 Guido Günther <agx@sigxcpu.org>
+#
+# This library is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Calypso.  If not, see <http://www.gnu.org/licenses/>.
+
+"""
+Gssapi module.
+
+This module handles kerberos authenticatien via gssapi
+"""
+
+import os
+
+from . import config
+from acl import nopwd
+
+# pylint: disable=F0401
+try:
+    import kerberos as krb
+except ImportError:
+    krb = None
+# pylint: disable=F0401
+
+
+class Negotiate(object):
+    _gssapi = False
+
+    def __init__(self, log):
+        self.log = log
+        try:
+            self.servicename = os.path.expanduser(config.get("server",
+                                                             "servicename"))
+        except:
+            self.servicename = None
+
+        if self.servicename and krb:
+            self._gssapi = True
+
+    def enabled(self):
+        return self._gssapi
+
+    def try_aaa(self, authorization, request, owner):
+        """Perform authentication and authorization"""
+        user, success = self.step(authorization, request)
+        if success:
+            return user, nopwd.has_right(owner, user, None)
+        return user, False
+
+    def step(self, authorization, request):
+        """
+        Try to authenticate the client and if succesful authenticate
+        ourself to the client.
+        """
+        user = None
+
+        if not self.enabled():
+            return (None, False)
+
+        try:
+            (neg, challenge) = authorization.split()
+            if neg.lower().strip() != 'negotiate':
+                return (None, False)
+
+            self.log.debug("Negotiate header found, trying Kerberos")
+            result, context = krb.authGSSServerInit(self.servicename)
+            result = krb.authGSSServerStep(context, challenge)
+
+            if result == -1:
+                return (None, False)
+
+            response = krb.authGSSServerResponse(context)
+            # Client authenticated successfully, so authenticate to the client:
+            request.queue_header("www-authenticate",
+                                 "negotiate " + response)
+            user = krb.authGSSServerUserName(context)
+
+            self.log.debug("Negotiate: found user %s" % user)
+            result = krb.authGSSServerClean(context)
+            if result != 1:
+                self.log.error("Failed to cleanup gss context")
+            return (user, True)
+        except krb.GSSError as err:
+            self.log.error("gssapi error: %s", err)
+
+        return None, False