From 2751451f4158417e66974d6415d2da84f612ab3c Mon Sep 17 00:00:00 2001 From: Rob Crittenden Date: Thu, 25 Jun 2015 11:00:59 -0400 Subject: [PATCH] Add support for logout over SOAP 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 Reviewed-by: Patrick Uiterwijk --- ipsilon/install/ipsilon-client-install | 1 + ipsilon/providers/saml2/auth.py | 5 +- ipsilon/providers/saml2/logout.py | 89 +++++++++++++++++++++----- ipsilon/providers/saml2/provider.py | 11 +++- ipsilon/providers/saml2/sessions.py | 82 +++++++++++++++++------- ipsilon/providers/saml2idp.py | 14 ++-- ipsilon/tools/saml2metadata.py | 2 + ipsilon/util/data.py | 8 ++- 8 files changed, 167 insertions(+), 45 deletions(-) diff --git a/ipsilon/install/ipsilon-client-install b/ipsilon/install/ipsilon-client-install index 49d9e78..d8a310c 100755 --- a/ipsilon/install/ipsilon-client-install +++ b/ipsilon/install/ipsilon-client-install @@ -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') diff --git a/ipsilon/providers/saml2/auth.py b/ipsilon/providers/saml2/auth.py index c46d604..d856220 100644 --- a/ipsilon/providers/saml2/auth.py +++ b/ipsilon/providers/saml2/auth.py @@ -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() diff --git a/ipsilon/providers/saml2/logout.py b/ipsilon/providers/saml2/logout.py index cc9b777..374e885 100644 --- a/ipsilon/providers/saml2/logout.py +++ b/ipsilon/providers/saml2/logout.py @@ -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() diff --git a/ipsilon/providers/saml2/provider.py b/ipsilon/providers/saml2/provider.py index 3dea631..b70582e 100644 --- a/ipsilon/providers/saml2/provider.py +++ b/ipsilon/providers/saml2/provider.py @@ -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( diff --git a/ipsilon/providers/saml2/sessions.py b/ipsilon/providers/saml2/sessions.py index 1000a87..d3ed7e2 100644 --- a/ipsilon/providers/saml2/sessions.py +++ b/ipsilon/providers/saml2/sessions.py @@ -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", "") - sess2 = factory.add_session('_789012', provider2, "testuser", "") + sess1 = factory.add_session('_123456', provider1, "admin", + "", '_1234', + [SAML2_METADATA_BINDING_REDIRECT]) + sess2 = factory.add_session('_789012', provider2, "testuser", + "", '_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", "") + sess3 = factory.add_session('_345678', provider2, "testuser", + "", '_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() diff --git a/ipsilon/providers/saml2idp.py b/ipsilon/providers/saml2idp.py index f771ef7..5ac83dd 100644 --- a/ipsilon/providers/saml2idp.py +++ b/ipsilon/providers/saml2idp.py @@ -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) diff --git a/ipsilon/tools/saml2metadata.py b/ipsilon/tools/saml2metadata.py index 99857bf..98e7c67 100755 --- a/ipsilon/tools/saml2metadata.py +++ b/ipsilon/tools/saml2metadata.py @@ -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) } diff --git a/ipsilon/util/data.py b/ipsilon/util/data.py index 53a1756..e0cd6e1 100644 --- a/ipsilon/util/data.py +++ b/ipsilon/util/data.py @@ -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 -- 2.20.1