URL: https://github.com/freeipa/freeipa/pull/649
Author: simo5
 Title: #649: Session cookie storage and handling fixes
Action: synchronized

To pull the PR as Git branch:
git remote add ghfreeipa https://github.com/freeipa/freeipa
git fetch ghfreeipa pull/649/head:pr649
git checkout pr649
From 9fd0b4ce68daac2edbc38ccc743d4b7c1fafdf9d Mon Sep 17 00:00:00 2001
From: Simo Sorce <s...@redhat.com>
Date: Wed, 22 Mar 2017 18:25:38 -0400
Subject: [PATCH 1/4] Avoid growing FILE ccaches unnecessarily

Related https://pagure.io/freeipa/issue/6775

Signed-off-by: Simo Sorce <s...@redhat.com>
---
 ipapython/session_storage.py | 6 ++++++
 1 file changed, 6 insertions(+)

diff --git a/ipapython/session_storage.py b/ipapython/session_storage.py
index bcf0947..f208827 100644
--- a/ipapython/session_storage.py
+++ b/ipapython/session_storage.py
@@ -111,6 +111,12 @@ def store_data(princ_name, key, value):
     if not isinstance(value, bytes):
         value = value.encode('utf-8')
 
+    # FILE ccaches grow every time an entry is stored, so we need
+    # to avoid storing the same entry multiple times.
+    oldvalue = get_data(princ_name, key)
+    if oldvalue == value:
+        return
+
     context = krb5_context()
     principal = krb5_principal()
     ccache = krb5_ccache()

From 7653192d67de8d6b19259ece49f6c1d31f788665 Mon Sep 17 00:00:00 2001
From: Simo Sorce <s...@redhat.com>
Date: Wed, 22 Mar 2017 18:38:22 -0400
Subject: [PATCH 2/4] Handle failed authentication via cookie

If cookie authentication fails and we get back a 401 see if we
tried a SPNEGO auth by checking if we had a GSSAPI context. If not
it means our session cookie was invalid or expired or some other
error happened on the server that requires us to try a full SPNEGO
handshake, so go ahead and try it.

Fixes https://pagure.io/freeipa/issue/6775

Signed-off-by: Simo Sorce <s...@redhat.com>
---
 ipalib/rpc.py | 52 ++++++++++++++++++++++++++++++++--------------------
 1 file changed, 32 insertions(+), 20 deletions(-)

diff --git a/ipalib/rpc.py b/ipalib/rpc.py
index 303b22a..f597ce0 100644
--- a/ipalib/rpc.py
+++ b/ipalib/rpc.py
@@ -586,22 +586,33 @@ def _handle_exception(self, e, service=None):
         else:
             raise errors.KerberosError(message=unicode(e))
 
-    def get_host_info(self, host):
+    def _get_host(self):
+        return self._connection[0]
+
+    def _remove_extra_header(self, name):
+        for (h, v) in self._extra_headers:
+            if h == name:
+                self._extra_headers.remove((h, v))
+                break
+
+    def get_auth_info(self, use_cookie=True):
         """
         Two things can happen here. If we have a session we will add
         a cookie for that. If not we will set an Authorization header.
         """
-        (host, extra_headers, x509) = SSLTransport.get_host_info(self, host)
-
-        if not isinstance(extra_headers, list):
-            extra_headers = []
+        if not isinstance(self._extra_headers, list):
+            self._extra_headers = []
 
-        session_cookie = getattr(context, 'session_cookie', None)
-        if session_cookie:
-            extra_headers.append(('Cookie', session_cookie))
-            return (host, extra_headers, x509)
+        # Remove any existing Cookie first
+        self._remove_extra_header('Cookie')
+        if use_cookie:
+            session_cookie = getattr(context, 'session_cookie', None)
+            if session_cookie:
+                self._extra_headers.append(('Cookie', session_cookie))
+                return
 
         # Set the remote host principal
+        host = self._get_host()
         service = self.service + "@" + host.split(':')[0]
 
         try:
@@ -616,18 +627,14 @@ def get_host_info(self, host):
         except gssapi.exceptions.GSSError as e:
             self._handle_exception(e, service=service)
 
-        self._set_auth_header(extra_headers, response)
-
-        return (host, extra_headers, x509)
+        self._set_auth_header(response)
 
-    def _set_auth_header(self, extra_headers, token):
-        for (h, v) in extra_headers:
-            if h == 'Authorization':
-                extra_headers.remove((h, v))
-                break
+    def _set_auth_header(self, token):
+        # Remove any existing authorization header first
+        self._remove_extra_header('Authorization')
 
         if token:
-            extra_headers.append(
+            self._extra_headers.append(
                 ('Authorization', 'negotiate %s' % base64.b64encode(token).decode('ascii'))
             )
 
@@ -651,18 +658,23 @@ def _auth_complete(self, response):
             if self._sec_context.complete:
                 self._sec_context = None
                 return True
-            self._set_auth_header(self._extra_headers, token)
+            self._set_auth_header(token)
+            return False
+        elif response.status == 401:
+            self.get_auth_info(use_cookie=False)
             return False
         return True
 
     def single_request(self, host, handler, request_body, verbose=0):
         # Based on Python 2.7's xmllib.Transport.single_request
         try:
-            h = SSLTransport.make_connection(self, host)
+            h = self.make_connection(host)
 
             if verbose:
                 h.set_debuglevel(1)
 
+            self.get_auth_info()
+
             while True:
                 if six.PY2:
                     # pylint: disable=no-value-for-parameter

From 3e5b74d38a4a42586898d3f711b430f48dc755b8 Mon Sep 17 00:00:00 2001
From: Simo Sorce <s...@redhat.com>
Date: Thu, 23 Mar 2017 13:02:00 -0400
Subject: [PATCH 3/4] Work around issues fetching session data

Unfortunately the MIT krb5 library has a severe limitation with FILE
ccaches when retrieving config data. It will always only search until
the first entry is found and return that one.

For FILE caches MIT krb5 does not support removing old entries when a
new one is stored, and storage happens only in append mode, so the end
result is that even if an update is stored it is never returned with the
standard krb5_cc_get_config() call.

To work around this issue we simply implement what krb5_cc_get_config()
does under the hood with the difference that we do not stop at the first
match but keep going until all ccache entries have been checked.

Related https://pagure.io/freeipa/issue/6775

Signed-off-by: Simo Sorce <s...@redhat.com>
---
 ipapython/session_storage.py | 177 ++++++++++++++++++++++++++++++++++++++-----
 1 file changed, 156 insertions(+), 21 deletions(-)

diff --git a/ipapython/session_storage.py b/ipapython/session_storage.py
index f208827..f7201f3 100644
--- a/ipapython/session_storage.py
+++ b/ipapython/session_storage.py
@@ -38,6 +38,43 @@ class krb5_principal_data(ctypes.Structure):  # noqa
     _fields_ = []
 
 
+class _krb5_keyblock(ctypes.Structure):  # noqa
+    """krb5/krb5.h struct _krb5_keyblock"""
+    _fields_ = [
+        ("magic", ctypes.c_int32),
+        ("enctype", ctypes.c_int32),
+        ("length", ctypes.c_uint),
+        ("contents", ctypes.c_void_p)
+    ]
+
+
+class _krb5_ticket_times(ctypes.Structure):  # noqa
+    """krb5/krb5.h struct _krb5_ticket_times"""
+    _fields_ = [
+        ("authtime", ctypes.c_int32),
+        ("starttime", ctypes.c_int32),
+        ("endtime", ctypes.c_int32),
+        ("renew_till", ctypes.c_int32),
+    ]
+
+
+class _krb5_creds(ctypes.Structure):  # noqa
+    """krb5/krb5.h struct _krb5_creds"""
+    _fields_ = [
+        ("magic", ctypes.c_int32),
+        ("client", ctypes.POINTER(krb5_principal_data)),
+        ("server", ctypes.POINTER(krb5_principal_data)),
+        ("keyblock", _krb5_keyblock),
+        ("times", _krb5_ticket_times),
+        ("is_skey", ctypes.c_uint),
+        ("ticket_flags", ctypes.c_int32),
+        ("addresses", ctypes.c_void_p),
+        ("ticket", _krb5_data),
+        ("second_ticket", _krb5_data),
+        ("authdata", ctypes.c_void_p)
+    ]
+
+
 class KRB5Error(Exception):
     pass
 
@@ -52,6 +89,7 @@ def krb5_errcheck(result, func, arguments):
 krb5_context = ctypes.POINTER(_krb5_context)
 krb5_ccache = ctypes.POINTER(_krb5_ccache)
 krb5_data_p = ctypes.POINTER(_krb5_data)
+krb5_creds = _krb5_creds
 krb5_error = ctypes.c_int32
 
 krb5_init_context = LIBKRB5.krb5_init_context
@@ -61,15 +99,15 @@ def krb5_errcheck(result, func, arguments):
 
 krb5_free_context = LIBKRB5.krb5_free_context
 krb5_free_context.argtypes = (krb5_context, )
-krb5_free_context.retval = None
+krb5_free_context.restype = None
 
 krb5_free_principal = LIBKRB5.krb5_free_principal
 krb5_free_principal.argtypes = (krb5_context, krb5_principal)
-krb5_free_principal.retval = None
+krb5_free_principal.restype = None
 
 krb5_free_data_contents = LIBKRB5.krb5_free_data_contents
 krb5_free_data_contents.argtypes = (krb5_context, krb5_data_p)
-krb5_free_data_contents.retval = None
+krb5_free_data_contents.restype = None
 
 krb5_cc_default = LIBKRB5.krb5_cc_default
 krb5_cc_default.argtypes = (krb5_context, ctypes.POINTER(krb5_ccache), )
@@ -78,26 +116,76 @@ def krb5_errcheck(result, func, arguments):
 
 krb5_cc_close = LIBKRB5.krb5_cc_close
 krb5_cc_close.argtypes = (krb5_context, krb5_ccache, )
-krb5_cc_close.retval = krb5_error
+krb5_cc_close.restype = krb5_error
 krb5_cc_close.errcheck = krb5_errcheck
 
 krb5_parse_name = LIBKRB5.krb5_parse_name
 krb5_parse_name.argtypes = (krb5_context, ctypes.c_char_p,
                             ctypes.POINTER(krb5_principal), )
-krb5_parse_name.retval = krb5_error
+krb5_parse_name.restype = krb5_error
 krb5_parse_name.errcheck = krb5_errcheck
 
 krb5_cc_set_config = LIBKRB5.krb5_cc_set_config
 krb5_cc_set_config.argtypes = (krb5_context, krb5_ccache, krb5_principal,
                                ctypes.c_char_p, krb5_data_p, )
-krb5_cc_set_config.retval = krb5_error
+krb5_cc_set_config.restype = krb5_error
 krb5_cc_set_config.errcheck = krb5_errcheck
 
-krb5_cc_get_config = LIBKRB5.krb5_cc_get_config
-krb5_cc_get_config.argtypes = (krb5_context, krb5_ccache, krb5_principal,
-                               ctypes.c_char_p, krb5_data_p, )
-krb5_cc_get_config.retval = krb5_error
-krb5_cc_get_config.errcheck = krb5_errcheck
+krb5_cc_get_principal = LIBKRB5.krb5_cc_get_principal
+krb5_cc_get_principal.argtypes = (krb5_context, krb5_ccache,
+                                  ctypes.POINTER(krb5_principal), )
+krb5_cc_get_principal.restype = krb5_error
+krb5_cc_get_principal.errcheck = krb5_errcheck
+
+krb5_build_principal = LIBKRB5.krb5_build_principal
+krb5_build_principal.argtypes = (krb5_context, ctypes.POINTER(krb5_principal),
+                                 ctypes.c_uint, ctypes.c_char_p,
+                                 ctypes.c_char_p, ctypes.c_char_p,
+                                 ctypes.c_char_p, ctypes.c_char_p, )
+krb5_build_principal.restype = krb5_error
+krb5_build_principal.errcheck = krb5_errcheck
+
+krb5_cc_start_seq_get = LIBKRB5.krb5_cc_start_seq_get
+krb5_cc_start_seq_get.argtypes = (krb5_context, krb5_ccache,
+                                  ctypes.POINTER(ctypes.c_void_p), )
+krb5_cc_start_seq_get.restype = krb5_error
+krb5_cc_start_seq_get.errcheck = krb5_errcheck
+
+krb5_cc_next_cred = LIBKRB5.krb5_cc_next_cred
+krb5_cc_next_cred.argtypes = (krb5_context, krb5_ccache,
+                              ctypes.POINTER(ctypes.c_void_p),
+                              ctypes.POINTER(krb5_creds), )
+krb5_cc_next_cred.restype = krb5_error
+krb5_cc_next_cred.errcheck = krb5_errcheck
+
+krb5_cc_end_seq_get = LIBKRB5.krb5_cc_end_seq_get
+krb5_cc_end_seq_get.argtypes = (krb5_context, krb5_ccache,
+                                ctypes.POINTER(ctypes.c_void_p), )
+krb5_cc_end_seq_get.restype = krb5_error
+krb5_cc_end_seq_get.errcheck = krb5_errcheck
+
+krb5_free_cred_contents = LIBKRB5.krb5_free_cred_contents
+krb5_free_cred_contents.argtypes = (krb5_context, ctypes.POINTER(krb5_creds))
+krb5_free_cred_contents.restype = krb5_error
+krb5_free_cred_contents.errcheck = krb5_errcheck
+
+krb5_principal_compare = LIBKRB5.krb5_principal_compare
+krb5_principal_compare.argtypes = (krb5_context, krb5_principal,
+                                   krb5_principal, )
+krb5_principal_compare.restype = ctypes.c_uint
+
+krb5_unparse_name = LIBKRB5.krb5_unparse_name
+krb5_unparse_name.argtypes = (krb5_context, krb5_principal,
+                              ctypes.POINTER(ctypes.c_char_p), )
+krb5_unparse_name.restype = krb5_error
+krb5_unparse_name.errcheck = krb5_errcheck
+
+krb5_free_unparsed_name = LIBKRB5.krb5_free_unparsed_name
+krb5_free_unparsed_name.argtypes = (krb5_context, ctypes.c_char_p, )
+krb5_free_unparsed_name.restype = None
+
+CONF_REALM = "X-CACHECONF:"
+CONF_NAME = "krb5_ccache_conf_data"
 
 
 def store_data(princ_name, key, value):
@@ -156,29 +244,76 @@ def get_data(princ_name, key):
 
     context = krb5_context()
     principal = krb5_principal()
+    srv_princ = krb5_principal()
     ccache = krb5_ccache()
-    data = _krb5_data()
+    pname_princ = krb5_principal()
+    pname = ctypes.c_char_p(0)
 
     try:
         krb5_init_context(ctypes.byref(context))
 
-        krb5_parse_name(context, ctypes.c_char_p(princ_name),
-                        ctypes.byref(principal))
-
         krb5_cc_default(context, ctypes.byref(ccache))
+        krb5_cc_get_principal(context, ccache, ctypes.byref(principal))
 
-        krb5_cc_get_config(context, ccache, principal, key,
-                           ctypes.byref(data))
-
-        return data.data.decode('utf-8')
+        # We need to parse and then unparse the name in case the pric_name
+        # passed in comes w/o a realm attached
+        krb5_parse_name(context, ctypes.c_char_p(princ_name),
+                        ctypes.byref(pname_princ))
+        krb5_unparse_name(context, pname_princ, ctypes.byref(pname))
+
+        krb5_build_principal(context, ctypes.byref(srv_princ),
+                             len(CONF_REALM), ctypes.c_char_p(CONF_REALM),
+                             ctypes.c_char_p(CONF_NAME), ctypes.c_char_p(key),
+                             pname, ctypes.c_char_p(None))
+
+        # Unfortunately we can't just use krb5_cc_get_config()
+        # because if bugs in some ccache handling code in krb5
+        # libraries that would always return the first entry
+        # stored and not the last one, which is the one we want.
+        cursor = ctypes.c_void_p(0)
+        creds = krb5_creds()
+        got_creds = False
+        krb5_cc_start_seq_get(context, ccache, ctypes.byref(cursor))
+        try:
+            while True:
+                checkcreds = krb5_creds()
+                krb5_cc_next_cred(context, ccache, ctypes.byref(cursor),
+                                  ctypes.byref(checkcreds))
+                if (krb5_principal_compare(context, principal,
+                                          checkcreds.client) == 1 and
+                    krb5_principal_compare(context, srv_princ,
+                                           checkcreds.server) == 1):
+                    if got_creds:
+                        krb5_free_cred_contents(context, ctypes.byref(creds))
+                    creds = checkcreds
+                    got_creds = True
+                    # We do not stop here, as we want the LAST entry
+                    # in the ccache for those ccaches that cannot delete
+                    # but only always append, like FILE
+                else:
+                    krb5_free_cred_contents(context,
+                                            ctypes.byref(checkcreds))
+        except KRB5Error:
+            pass
+        finally:
+            krb5_cc_end_seq_get(context, ccache, ctypes.byref(cursor))
+
+        if got_creds:
+            data = creds.ticket.data.decode('utf-8')
+            krb5_free_cred_contents(context, ctypes.byref(creds))
+            return data
 
     finally:
         if principal:
             krb5_free_principal(context, principal)
+        if srv_princ:
+            krb5_free_principal(context, srv_princ)
+        if pname_princ:
+            krb5_free_principal(context, pname_princ)
+        if pname:
+            krb5_free_unparsed_name(context, pname)
         if ccache:
             krb5_cc_close(context, ccache)
-        if data:
-            krb5_free_data_contents(context, data)
         if context:
             krb5_free_context(context)
 

From db0f583af141b9d4e6521582b16f6b9f3e9fc33a Mon Sep 17 00:00:00 2001
From: Simo Sorce <s...@redhat.com>
Date: Thu, 23 Mar 2017 17:49:27 -0400
Subject: [PATCH 4/4] Prevent churn on ccaches

We slice down the received cookie so that just the content that matter
is preserved. Thi is ok because servers can't trust anything else anyway
and will accept a cookie with the ancillary data missing.

By removing variable parts like the expiry component added by
mod_session or the Expiration or Max-Age metadata we keep only the part
of the cookie that changes only when a new session is generated.

This way when storing the cookie we actually add a new entry in the
ccache only when the session actually changes, and this prevents churn
on FILE based ccaches.

Related https://pagure.io/freeipa/issue/6775

Signed-off-by: Simo Sorce <s...@redhat.com>
---
 ipalib/rpc.py | 18 +++++++++++++++++-
 1 file changed, 17 insertions(+), 1 deletion(-)

diff --git a/ipalib/rpc.py b/ipalib/rpc.py
index f597ce0..4b364a8 100644
--- a/ipalib/rpc.py
+++ b/ipalib/rpc.py
@@ -737,6 +737,22 @@ def __send_request(self, connection, host, handler, request_body, debug):
             self.send_content(connection, request_body)
             return connection
 
+    def _slice_session_cookie(self, session_cookie):
+        # Keep only the cookie value and strip away all other info.
+        # This is to reduce the churn on FILE ccaches which grow every time we
+        # set new data. The expiration time for the cookie is set in the
+        # encrypted data anyway and will be enforced by the server
+        cookie_data = str(session_cookie).split(';')[0]
+        # We also remove the "expiry" part from the data which is not required
+        cookie_parts = cookie_data.split('&')
+        expiry = None
+        for part in cookie_parts:
+            if part.startswith("expiry="):
+                expiry = part
+        if expiry:
+            cookie_parts.remove(expiry)
+        return '&'.join(cookie_parts)
+
     def store_session_cookie(self, cookie_header):
         '''
         Given the contents of a Set-Cookie header scan the header and
@@ -787,7 +803,7 @@ def store_session_cookie(self, cookie_header):
         if session_cookie is None:
             return
 
-        cookie_string = str(session_cookie)
+        cookie_string = self._slice_session_cookie(session_cookie)
         root_logger.debug("storing cookie '%s' for principal %s", cookie_string, principal)
         try:
             update_persistent_client_session_data(principal, cookie_string)
-- 
Manage your subscription for the Freeipa-devel mailing list:
https://www.redhat.com/mailman/listinfo/freeipa-devel
Contribute to FreeIPA: http://www.freeipa.org/page/Contribute/Code

Reply via email to