Add support for logout over SOAP
authorRob Crittenden <rcritten@redhat.com>
Thu, 25 Jun 2015 15:00:59 +0000 (11:00 -0400)
committerPatrick Uiterwijk <puiterwijk@redhat.com>
Thu, 16 Jul 2015 13:04:36 +0000 (15:04 +0200)
As each login session comes in, store the supported logout
mechanisms in the SP metadata.

Upon a logout request, loop through all of those SP's that
support SOAP and log those out first, then log out any
remaining sessions using HTTP Redirect.

https://fedorahosted.org/ipsilon/ticket/59

Signed-off-by: Rob Crittenden <rcritten@redhat.com>
Reviewed-by: Patrick Uiterwijk <puiterwijk@redhat.com>
ipsilon/install/ipsilon-client-install
ipsilon/providers/saml2/auth.py
ipsilon/providers/saml2/logout.py
ipsilon/providers/saml2/provider.py
ipsilon/providers/saml2/sessions.py
ipsilon/providers/saml2idp.py
ipsilon/tools/saml2metadata.py
ipsilon/util/data.py

index 49d9e78..d8a310c 100755 (executable)
@@ -97,6 +97,7 @@ def saml2():
     m.set_entity_id(url_sp)
     m.add_certs(c)
     m.add_service(SAML2_SERVICE_MAP['logout-redirect'], url_logout)
+    m.add_service(SAML2_SERVICE_MAP['slo-soap'], url_logout)
     m.add_service(SAML2_SERVICE_MAP['response-post'], url_post, index="0")
     m.add_allowed_name_format(SAML2_NAMEID_MAP[args['saml_nameid']])
     sp_metafile = os.path.join(path, 'metadata.xml')
index c46d604..d856220 100644 (file)
@@ -278,10 +278,13 @@ class AuthenticateRequest(ProviderPageBase):
 
         lasso_session = lasso.Session()
         lasso_session.addAssertion(login.remoteProviderId, login.assertion)
+        provider = ServiceProvider(self.cfg, login.remoteProviderId)
         saml_sessions.add_session(login.assertion.id,
                                   login.remoteProviderId,
                                   user.name,
-                                  lasso_session.dump())
+                                  lasso_session.dump(),
+                                  None,
+                                  provider.logout_mechs)
 
     def saml2error(self, login, code, message):
         status = lasso.Samlp2Status()
index cc9b777..374e885 100644 (file)
@@ -4,8 +4,10 @@ from ipsilon.providers.common import ProviderPageBase
 from ipsilon.providers.common import InvalidRequest
 from ipsilon.providers.saml2.auth import UnknownProvider
 from ipsilon.util.user import UserSession
+from ipsilon.util.constants import SOAP_MEDIA_TYPE
 import cherrypy
 import lasso
+import requests
 
 
 class LogoutRequest(ProviderPageBase):
@@ -58,7 +60,7 @@ class LogoutRequest(ProviderPageBase):
         # all the session indexes and mark them as logging out but only one
         # is needed to handle the request.
         if len(session_indexes) < 1:
-            self.error('SLO empty session Indexes: %s')
+            self.error('SLO empty session Indexes')
             raise cherrypy.HTTPError(400, 'Invalid logout request')
         session = saml_sessions.get_session_by_id(session_indexes[0])
         if session:
@@ -181,11 +183,36 @@ class LogoutRequest(ProviderPageBase):
         else:
             raise cherrypy.HTTPError(400, 'Not logged in')
 
+    def _soap_logout(self, logout):
+        """
+        Send a SOAP logout request over HTTP and return the result.
+        """
+        headers = {'Content-Type': SOAP_MEDIA_TYPE}
+        try:
+            response = requests.post(logout.msgUrl, data=logout.msgBody,
+                                     headers=headers)
+        except Exception as e:  # pylint: disable=broad-except
+            self.error('SOAP HTTP request failed: (%s) (on %s)' %
+                       (e, logout.msgUrl))
+            raise
+
+        if response.status_code != 200:
+            self.error('SOAP error (%s) (on %s)' %
+                       (response.status, logout.msgUrl))
+            raise InvalidRequest('SOAP HTTP error code', response.status_code)
+
+        if not response.text:
+            self.error('Empty SOAP response')
+            raise InvalidRequest('No content in SOAP response')
+
+        return response.text
+
     def logout(self, message, relaystate=None, samlresponse=None):
         """
-        Handle HTTP Redirect logout. This is an asynchronous logout
-        request process that relies on the HTTP agent to forward
-        logout requests to any other SP's that are also logged in.
+        Handle HTTP logout. The supported logout methods are stored
+        in each session. First all the SOAP sessions are logged out
+        then the HTTP Redirect method is used for any remaining
+        sessions.
 
         The basic process is this:
          1. A logout request is received. It is processed and the response
@@ -198,6 +225,8 @@ class LogoutRequest(ProviderPageBase):
          Repeat steps 2-3 until only the initial logout request is
          left unhandled, at which time the pre-generated response is sent
          back to the SP that originated the logout request.
+
+        The final logout response is always a redirect.
         """
         logout = self.cfg.idp.get_logout_handler()
 
@@ -217,8 +246,13 @@ class LogoutRequest(ProviderPageBase):
         # Fall through to handle any remaining sessions.
 
         # Find the next SP to logout and send a LogoutRequest
-        session = saml_sessions.get_next_logout()
-        if session:
+        logout_order = [
+            lasso.SAML2_METADATA_BINDING_SOAP,
+            lasso.SAML2_METADATA_BINDING_REDIRECT,
+        ]
+        (logout_mech, session) = saml_sessions.get_next_logout(
+            logout_mechs=logout_order)
+        while session:
             self.debug('Going to log out %s' % session.provider_id)
 
             try:
@@ -227,8 +261,12 @@ class LogoutRequest(ProviderPageBase):
                 self.error('Failed to load session: %s' % e)
                 raise cherrypy.HTTPRedirect(400, 'Failed to log out user: %s '
                                             % e)
-
-            logout.initRequest(session.provider_id, lasso.HTTP_METHOD_REDIRECT)
+            if logout_mech == lasso.SAML2_METADATA_BINDING_REDIRECT:
+                logout.initRequest(session.provider_id,
+                                   lasso.HTTP_METHOD_REDIRECT)
+            else:
+                logout.initRequest(session.provider_id,
+                                   lasso.HTTP_METHOD_SOAP)
 
             try:
                 logout.buildRequestMsg()
@@ -243,7 +281,7 @@ class LogoutRequest(ProviderPageBase):
             indexes = saml_sessions.get_session_id_by_provider_id(
                 session.provider_id
             )
-            self.debug('Requesting logout for sessions %s' % indexes)
+            self.debug('Requesting logout for sessions %s' % (indexes,))
             req = logout.get_request()
             req.setSessionIndexes(indexes)
 
@@ -253,13 +291,34 @@ class LogoutRequest(ProviderPageBase):
 
             self.debug('Request logout ID %s for session ID %s' %
                        (logout.request.id, session.session_id))
-            self.debug('Redirecting to another SP to logout on %s at %s' %
-                       (logout.remoteProviderId, logout.msgUrl))
-
-            raise cherrypy.HTTPRedirect(logout.msgUrl)
 
-        # Otherwise we're done, respond to the original request using the
-        # response we cached earlier.
+            if logout_mech == lasso.SAML2_METADATA_BINDING_REDIRECT:
+                self.debug('Redirecting to another SP to logout on %s at %s' %
+                           (logout.remoteProviderId, logout.msgUrl))
+                raise cherrypy.HTTPRedirect(logout.msgUrl)
+            else:
+                self.debug('SOAP request to another SP to logout on %s at %s' %
+                           (logout.remoteProviderId, logout.msgUrl))
+                if logout.msgBody:
+                    message = self._soap_logout(logout)
+                    try:
+                        self._handle_logout_response(us,
+                                                     logout,
+                                                     saml_sessions,
+                                                     message,
+                                                     samlresponse)
+                    except Exception as e:  # pylint: disable=broad-except
+                        self.error('SOAP SLO failed %s' % e)
+                else:
+                    self.error('Provider does not support SOAP')
+
+            (logout_mech, session) = saml_sessions.get_next_logout(
+                logout_mechs=logout_order)
+
+        # done while
+
+        # All sessions should be logged out now. Respond to the
+        # original request using the response we cached earlier.
 
         try:
             session = saml_sessions.get_initial_logout()
index 3dea631..b70582e 100644 (file)
@@ -3,8 +3,9 @@
 from ipsilon.providers.common import ProviderException
 from ipsilon.util import config as pconfig
 from ipsilon.util.config import ConfigHelper
-from ipsilon.tools.saml2metadata import SAML2_NAMEID_MAP
+from ipsilon.tools.saml2metadata import SAML2_NAMEID_MAP, NSMAP
 from ipsilon.util.log import Log
+from lxml import etree
 import lasso
 import re
 
@@ -49,6 +50,14 @@ class ServiceProvider(ServiceProviderConfig):
         self._properties = data[idval]
         self._staging = dict()
         self.load_config()
+        self.logout_mechs = []
+        xmldoc = etree.XML(str(data[idval]['metadata']))
+        logout = xmldoc.xpath('//md:EntityDescriptor'
+                              '/md:SPSSODescriptor'
+                              '/md:SingleLogoutService',
+                              namespaces=NSMAP)
+        for service in logout:
+            self.logout_mechs.append(service.values()[0])
 
     def load_config(self):
         self.new_config(
index 1000a87..d3ed7e2 100644 (file)
@@ -4,6 +4,10 @@ from cherrypy import config as cherrypy_config
 from ipsilon.util.log import Log
 from ipsilon.util.data import SAML2SessionStore
 import datetime
+from lasso import (
+    SAML2_METADATA_BINDING_SOAP,
+    SAML2_METADATA_BINDING_REDIRECT,
+)
 
 LOGGED_IN = 1
 INIT_LOGOUT = 2
@@ -29,11 +33,13 @@ class SAMLSession(Log):
                     which matches this.
        logout_request - the Logout request object
        expiration_time - the time the login session expires
+       supported_logout_mechs - logout mechanisms supported by this session
     """
     def __init__(self, uuidval, session_id, provider_id, user,
                  login_session, logoutstate=None, relaystate=None,
                  logout_request=None, request_id=None,
-                 expiration_time=None):
+                 expiration_time=None,
+                 supported_logout_mechs=None):
 
         self.uuidval = uuidval
         self.session_id = session_id
@@ -45,6 +51,9 @@ class SAMLSession(Log):
         self.request_id = request_id
         self.logout_request = logout_request
         self.expiration_time = expiration_time
+        if supported_logout_mechs is None:
+            supported_logout_mechs = []
+        self.supported_logout_mechs = supported_logout_mechs
 
     def set_logoutstate(self, relaystate=None, request=None, request_id=None):
         """
@@ -66,6 +75,7 @@ class SAMLSession(Log):
         self.debug('provider_id %s' % self.provider_id)
         self.debug('login session %s' % self.login_session)
         self.debug('logoutstate %s' % self.logoutstate)
+        self.debug('logout mech %s' % self.supported_logout_mechs)
 
     def convert(self):
         """
@@ -118,12 +128,20 @@ class SAMLSessionFactory(Log):
                            data.get('relaystate'),
                            data.get('logout_request'),
                            data.get('request_id'),
-                           data.get('expiration_time'))
+                           data.get('expiration_time'),
+                           data.get('supported_logout_mechs'))
 
     def add_session(self, session_id, provider_id, user, login_session,
-                    request_id=None):
+                    request_id, supported_logout_mechs):
         """
         Add a new login session to the table.
+
+        :param session_id: The login session ID
+        :param provider_id: The URL of the SP
+        :param user: The NameID username
+        :param login_session: The lasso Login session
+        :param request_id: The request ID of the Logout
+        :param supported_logout_mechs: A list of logout protocols supported
         """
         self.user = user
 
@@ -136,9 +154,9 @@ class SAMLSessionFactory(Log):
                 'user': user,
                 'login_session': login_session,
                 'logoutstate': LOGGED_IN,
-                'expiration_time': expiration_time}
-        if request_id:
-            data['request_id'] = request_id
+                'expiration_time': expiration_time,
+                'request_id': request_id,
+                'supported_logout_mechs': supported_logout_mechs}
 
         uuidval = self._ss.new_session(data)
 
@@ -209,7 +227,8 @@ class SAMLSessionFactory(Log):
         datum = samlsession.convert()
         self._ss.update_session(datum)
 
-    def get_next_logout(self, peek=False):
+    def get_next_logout(self, peek=False,
+                        logout_mechs=None):
         """
         Get the next session in the logged-in state and move
         it to the logging_out state.  Return the session that is
@@ -218,24 +237,34 @@ class SAMLSessionFactory(Log):
         :param peek: for IdP-initiated logout we can't remove the
                      session otherwise when the request comes back
                      in the user won't be seen as being logged-on.
-
-        Return None if no more sessions in LOGGED_IN state.
+        :param logout_mechs: An ordered list of logout mechanisms
+                     you're looking for. For each mechanism in order
+                     loop through all sessions. If If no sessions of
+                     this method are available then try the next mechanism
+                     until exhausted. In that case None is returned.
+
+        Returns a tuple of (mechanism, session) or
+        (None, None) if no more sessions in LOGGED_IN state.
         """
         candidates = self._ss.get_user_sessions(self.user)
-
-        for c in candidates:
-            key = c.keys()[0]
-            if int(c[key].get('logoutstate', 0)) == LOGGED_IN:
-                samlsession = self._data_to_samlsession(key, c[key])
-                self.start_logout(samlsession, initial=False)
-                return samlsession
-        return None
+        if logout_mechs is None:
+            logout_mechs = [SAML2_METADATA_BINDING_REDIRECT, ]
+
+        for mech in logout_mechs:
+            for c in candidates:
+                key = c.keys()[0]
+                if ((int(c[key].get('logoutstate', 0)) == LOGGED_IN) and
+                        (mech in c[key].get('supported_logout_mechs'))):
+                    samlsession = self._data_to_samlsession(key, c[key])
+                    self.start_logout(samlsession, initial=False)
+                    return (mech, samlsession)
+        return (None, None)
 
     def get_initial_logout(self):
         """
         Get the initial logout request.
 
-        Return None if no sessions in INIT_LOGOUT state.
+        Raises ValueError if no sessions in INIT_LOGOUT state.
         """
         candidates = self._ss.get_user_sessions(self.user)
 
@@ -248,7 +277,7 @@ class SAMLSessionFactory(Log):
             if int(c[key].get('logoutstate', 0)) == INIT_LOGOUT:
                 samlsession = self._data_to_samlsession(key, c[key])
                 return samlsession
-        return None
+        raise ValueError()
 
     def wipe_data(self):
         self._ss.wipe_data()
@@ -276,14 +305,21 @@ if __name__ == '__main__':
     factory = SAMLSessionFactory('/tmp/saml2sessions.sqlite')
     factory.wipe_data()
 
-    sess1 = factory.add_session('_123456', provider1, "admin", "<Login/>")
-    sess2 = factory.add_session('_789012', provider2, "testuser", "<Login/>")
+    sess1 = factory.add_session('_123456', provider1, "admin",
+                                "<Login/>", '_1234',
+                                [SAML2_METADATA_BINDING_REDIRECT])
+    sess2 = factory.add_session('_789012', provider2, "testuser",
+                                "<Login/>", '_7890',
+                                [SAML2_METADATA_BINDING_SOAP,
+                                 SAML2_METADATA_BINDING_REDIRECT])
 
     # Test finding sessions by provider
     ids = factory.get_session_id_by_provider_id(provider2)
     assert(len(ids) == 1)
 
-    sess3 = factory.add_session('_345678', provider2, "testuser", "<Login/>")
+    sess3 = factory.add_session('_345678', provider2, "testuser",
+                                "<Login/>", '_3456',
+                                [SAML2_METADATA_BINDING_REDIRECT])
     ids = factory.get_session_id_by_provider_id(provider2)
     assert(len(ids) == 2)
 
@@ -307,7 +343,7 @@ if __name__ == '__main__':
     test2 = factory.get_session_by_id('_789012')
     factory.start_logout(test2, initial=True)
 
-    test3 = factory.get_next_logout()
+    (lmech, test3) = factory.get_next_logout()
     assert(test3.session_id == '_345678')
 
     test4 = factory.get_initial_logout()
index f771ef7..5ac83dd 100644 (file)
@@ -131,7 +131,7 @@ class Continue(AuthenticateRequest):
         return self.auth(login)
 
 
-class RedirectLogout(LogoutRequest):
+class Logout(LogoutRequest):
 
     def GET(self, *args, **kwargs):
         query = cherrypy.request.query_string
@@ -159,7 +159,7 @@ class SLO(ProviderPageBase):
     def __init__(self, *args, **kwargs):
         super(SLO, self).__init__(*args, **kwargs)
         self.debug('SLO init')
-        self.Redirect = RedirectLogout(*args, **kwargs)
+        self.Redirect = Logout(*args, **kwargs)
 
 
 # one week
@@ -394,13 +394,18 @@ Provides SAML 2.0 authentication infrastructure. """
         Logout all SP sessions when the logout comes from the IdP.
 
         For the current user only.
+
+        Only use HTTP-Redirect to start the logout. This is guaranteed
+        to be supported in SAML 2.
         """
         self.debug("IdP-initiated SAML2 logout")
         us = UserSession()
         user = us.get_user()
 
         saml_sessions = self.sessionfactory
-        session = saml_sessions.get_next_logout()
+        # pylint: disable=unused-variable
+        (mech, session) = saml_sessions.get_next_logout(
+            logout_mechs=[lasso.SAML2_METADATA_BINDING_REDIRECT])
         if session is None:
             return
 
@@ -418,7 +423,8 @@ Provides SAML 2.0 authentication infrastructure. """
         # be redirected to when all SP's are logged out.
         idpurl = self._root.instance_base_url()
         session_id = "_" + uuid.uuid4().hex.upper()
-        saml_sessions.add_session(session_id, idpurl, user.name, "")
+        saml_sessions.add_session(session_id, idpurl, user.name, "", "",
+                                  [lasso.SAML2_METADATA_BINDING_REDIRECT])
         init_session = saml_sessions.get_session_by_id(session_id)
         saml_sessions.start_logout(init_session, relaystate=idpurl)
 
index 99857bf..98e7c67 100755 (executable)
@@ -29,6 +29,8 @@ SAML2_SERVICE_MAP = {
                  lasso.SAML2_METADATA_BINDING_SOAP),
     'logout-redirect': ('SingleLogoutService',
                         lasso.SAML2_METADATA_BINDING_REDIRECT),
+    'slo-soap': ('SingleLogoutService',
+                 lasso.SAML2_METADATA_BINDING_SOAP),
     'response-post': ('AssertionConsumerService',
                       lasso.SAML2_METADATA_BINDING_POST)
 }
index 53a1756..e0cd6e1 100644 (file)
@@ -551,6 +551,10 @@ class SAML2SessionStore(Store):
         return self.get_unique_data(self.table, idval, name, value)
 
     def new_session(self, datum):
+        if 'supported_logout_mechs' in datum:
+            datum['supported_logout_mechs'] = ','.join(
+                datum['supported_logout_mechs']
+            )
         return self.new_unique_data(self.table, datum)
 
     def get_session(self, session_id=None, request_id=None):
@@ -567,7 +571,7 @@ class SAML2SessionStore(Store):
 
     def get_user_sessions(self, user):
         """
-        Retrun a list of all sessions for a given user.
+        Return a list of all sessions for a given user.
         """
         rows = self.get_unique_data(self.table, name='user', value=user)
 
@@ -575,6 +579,8 @@ class SAML2SessionStore(Store):
         logged_in = []
         for r in rows:
             data = self.get_unique_data(self.table, uuidval=r)
+            data[r]['supported_logout_mechs'] = data[r].get(
+                'supported_logout_mechs', '').split(',')
             logged_in.append(data)
 
         return logged_in