From ac1bae1e0f2a4720db15852798346cb46f204dae Mon Sep 17 00:00:00 2001 From: Rob Crittenden Date: Fri, 30 Jan 2015 15:07:12 -0500 Subject: [PATCH] Implement Single Logout Service for SP-initiated logout https://fedorahosted.org/ipsilon/ticket/24 Signed-off-by: Rob Crittenden Reviewed-by: Simo Sorce --- ipsilon/providers/saml2/auth.py | 19 ++ ipsilon/providers/saml2/logout.py | 265 ++++++++++++++++++++++++++++ ipsilon/providers/saml2/provider.py | 6 + ipsilon/providers/saml2idp.py | 23 +++ 4 files changed, 313 insertions(+) create mode 100644 ipsilon/providers/saml2/logout.py diff --git a/ipsilon/providers/saml2/auth.py b/ipsilon/providers/saml2/auth.py index 46ad7eb..44ed834 100644 --- a/ipsilon/providers/saml2/auth.py +++ b/ipsilon/providers/saml2/auth.py @@ -20,6 +20,7 @@ from ipsilon.providers.common import AuthenticationError, InvalidRequest from ipsilon.providers.saml2.provider import ServiceProvider from ipsilon.providers.saml2.provider import InvalidProviderId from ipsilon.providers.saml2.provider import NameIdNotAllowed +from ipsilon.providers.saml2.sessions import SAMLSessionsContainer from ipsilon.util.user import UserSession from ipsilon.util.trans import Transaction import cherrypy @@ -239,6 +240,24 @@ class AuthenticateRequest(ProviderPageBase): self.debug('Assertion: %s' % login.assertion.dump()) + saml_sessions = us.get_provider_data('saml2') + if saml_sessions is None: + saml_sessions = SAMLSessionsContainer() + + session = saml_sessions.find_session_by_provider( + login.remoteProviderId) + if session: + # TODO: something... + self.debug('Login session for this user already exists!?') + session.dump() + + lasso_session = lasso.Session() + lasso_session.addAssertion(login.remoteProviderId, login.assertion) + saml_sessions.add_session(login.assertion.id, + login.remoteProviderId, + lasso_session) + us.save_provider_data('saml2', saml_sessions) + def saml2error(self, login, code, message): status = lasso.Samlp2Status() status.statusCode = lasso.Samlp2StatusCode() diff --git a/ipsilon/providers/saml2/logout.py b/ipsilon/providers/saml2/logout.py new file mode 100644 index 0000000..46aea6e --- /dev/null +++ b/ipsilon/providers/saml2/logout.py @@ -0,0 +1,265 @@ +# Copyright (C) 2015 Rob Crittenden +# +# see file 'COPYING' for use and warranty information +# +# This program 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 program 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 this program. If not, see . + +from ipsilon.providers.common import ProviderPageBase +from ipsilon.providers.common import InvalidRequest +from ipsilon.providers.saml2.sessions import SAMLSessionsContainer +from ipsilon.providers.saml2.auth import UnknownProvider +from ipsilon.util.user import UserSession +import cherrypy +import lasso + + +class LogoutRequest(ProviderPageBase): + """ + SP-initiated logout. + + The sequence is: + - On each logout a new session is created to represent that + provider + - Initial logout request is verified and stored in the login + session + - If there are other sessions then one is chosen that is not + the current provider and a logoutRequest is sent + - When a logoutResponse is received the session is removed + - When all other sessions but the initial one have been + logged out then it a final logoutResponse is sent and the + session removed. At this point the cherrypy session is + deleted. + """ + + def __init__(self, *args, **kwargs): + super(LogoutRequest, self).__init__(*args, **kwargs) + + def _handle_logout_request(self, us, logout, saml_sessions, message): + self.debug('Logout request') + + try: + logout.processRequestMsg(message) + except (lasso.ServerProviderNotFoundError, + lasso.ProfileUnknownProviderError) as e: + msg = 'Invalid SP [%s] (%r [%r])' % (logout.remoteProviderId, + e, message) + self.error(msg) + raise UnknownProvider(msg) + except (lasso.ProfileInvalidProtocolprofileError, + lasso.DsError), e: + msg = 'Invalid SAML Request: %r (%r [%r])' % (logout.request, + e, message) + self.error(msg) + raise InvalidRequest(msg) + except lasso.Error, e: + self.error('SLO unknown error: %s' % message) + raise cherrypy.HTTPError(400, 'Invalid logout request') + + # TODO: verify that the session index is in the request + session_indexes = logout.request.sessionIndexes + self.debug('SLO from %s with %s sessions' % + (logout.remoteProviderId, session_indexes)) + + session = saml_sessions.find_session_by_provider( + logout.remoteProviderId) + if session: + try: + logout.setSessionFromDump(session.session.dump()) + except lasso.ProfileBadSessionDumpError as e: + self.error('loading session failed: %s' % e) + raise cherrypy.HTTPError(400, 'Invalid logout session') + else: + self.error('Logout attempt without being loggged in.') + raise cherrypy.HTTPError(400, 'Not logged in') + + try: + logout.validateRequest() + except lasso.ProfileSessionNotFoundError, e: + self.error('Logout failed. No sessions for %s' % + logout.remoteProviderId) + raise cherrypy.HTTPError(400, 'Not logged in') + except lasso.LogoutUnsupportedProfileError: + self.error('Logout failed. Unsupported profile %s' % + logout.remoteProviderId) + raise cherrypy.HTTPError(400, 'Profile does not support logout') + except lasso.Error, e: + self.error('SLO validation failed: %s' % e) + raise cherrypy.HTTPError(400, 'Failed to validate logout request') + + try: + logout.buildResponseMsg() + except lasso.ProfileUnsupportedProfileError: + self.error('Unsupported profile for %s' % logout.remoteProviderId) + raise cherrypy.HTTPError(400, 'Profile does not support logout') + except lasso.Error, e: + self.error('SLO failed to build logout response: %s' % e) + + session.set_logoutstate(logout.msgUrl, logout.request.id, + message) + saml_sessions.start_logout(session) + + us.save_provider_data('saml2', saml_sessions) + + return + + def _handle_logout_response(self, us, logout, saml_sessions, message, + samlresponse): + + self.debug('Logout response') + + try: + logout.processResponseMsg(message) + except getattr(lasso, 'ProfileRequestDeniedError', + lasso.LogoutRequestDeniedError): + self.error('Logout request denied by %s' % + logout.remoteProviderId) + # Fall through to next provider + except (lasso.ProfileInvalidMsgError, + lasso.LogoutPartialLogoutError) as e: + self.error('Logout request from %s failed: %s' % + (logout.remoteProviderId, e)) + else: + self.debug('Processing SLO Response from %s' % + logout.remoteProviderId) + + self.debug('SLO response to request id %s' % + logout.response.inResponseTo) + + saml_sessions = us.get_provider_data('saml2') + if saml_sessions is None: + # TODO: return logged out instead + saml_sessions = SAMLSessionsContainer() + + # TODO: need to log out each SessionIndex? + session = saml_sessions.find_session_by_provider( + logout.remoteProviderId) + + if session is not None: + self.debug('Logout response session logout id is: %s' % + session.session_id) + saml_sessions.remove_session_by_provider( + logout.remoteProviderId) + us.save_provider_data('saml2', saml_sessions) + user = us.get_user() + self._audit('Logged out user: %s [%s] from %s' % + (user.name, user.fullname, + logout.remoteProviderId)) + else: + self.error('Logout attempt without being loggged in.') + raise cherrypy.HTTPError(400, 'Not logged in') + + return + + 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. + + The basic process is this: + 1. A logout request is received. It is processed and the response + cached. + 2. If any other SP's have also logged in as this user then the + first such session is popped off and a logout request is + generated and forwarded to the SP. + 3. If a logout response is received then the user is marked + as logged out from that SP. + 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. + """ + logout = self.cfg.idp.get_logout_handler() + + us = UserSession() + + saml_sessions = us.get_provider_data('saml2') + if saml_sessions is None: + # No sessions means nothing to log out + self.error('Logout attempt without being loggged in.') + raise cherrypy.HTTPError(400, 'Not logged in') + + self.debug('%d sessions loaded' % saml_sessions.count()) + saml_sessions.dump() + + if lasso.SAML2_FIELD_REQUEST in message: + self._handle_logout_request(us, logout, saml_sessions, message) + elif samlresponse: + self._handle_logout_response(us, logout, saml_sessions, message, + samlresponse) + else: + raise cherrypy.HTTPRedirect(400, 'Bad Request. Not a logout ' + + 'request or response.') + + # Fall through to handle any remaining sessions. + + # Find the next SP to logout and send a LogoutRequest + saml_sessions = us.get_provider_data('saml2') + session = saml_sessions.get_next_logout() + if session: + self.debug('Going to log out %s' % session.provider_id) + + try: + logout.setSessionFromDump(session.session.dump()) + except lasso.ProfileBadSessionDumpError as e: + 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) + + try: + logout.buildRequestMsg() + except lasso.Error, e: + self.error('failure to build logout request msg: %s' % e) + raise cherrypy.HTTPRedirect(400, 'Failed to log out user: %s ' + % e) + + session.set_logoutstate(logout.msgUrl, logout.request.id, None) + us.save_provider_data('saml2', saml_sessions) + + 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. + + saml_sessions = us.get_provider_data('saml2') + if saml_sessions is None or saml_sessions.count() == 0: + self.error('Logout attempt without being loggged in.') + raise cherrypy.HTTPError(400, 'Not logged in') + + try: + session = saml_sessions.get_last_session() + except ValueError: + self.debug('SLO get_last_session() unable to find last session') + raise cherrypy.HTTPError(400, 'Unable to determine logout state') + + redirect = session.logoutstate.get('relaystate') + if not redirect: + redirect = self.basepath + + # Log out of cherrypy session + user = us.get_user() + self._audit('Logged out user: %s [%s] from %s' % + (user.name, user.fullname, + session.provider_id)) + us.logout(user) + + self.debug('SLO redirect to %s' % redirect) + + raise cherrypy.HTTPRedirect(redirect) diff --git a/ipsilon/providers/saml2/provider.py b/ipsilon/providers/saml2/provider.py index 337a31d..c02d6fb 100644 --- a/ipsilon/providers/saml2/provider.py +++ b/ipsilon/providers/saml2/provider.py @@ -200,3 +200,9 @@ class IdentityProvider(Log): def get_providers(self): return self.server.get_providers() + + def get_logout_handler(self, dump=None): + if dump: + return lasso.Logout.newFromDump(self.server, dump) + else: + return lasso.Logout(self.server) diff --git a/ipsilon/providers/saml2idp.py b/ipsilon/providers/saml2idp.py index c8f5dab..256fcf9 100644 --- a/ipsilon/providers/saml2idp.py +++ b/ipsilon/providers/saml2idp.py @@ -17,6 +17,7 @@ from ipsilon.providers.common import ProviderBase, ProviderPageBase from ipsilon.providers.saml2.auth import AuthenticateRequest +from ipsilon.providers.saml2.logout import LogoutRequest from ipsilon.providers.saml2.admin import Saml2AdminPage from ipsilon.providers.saml2.provider import IdentityProvider from ipsilon.tools.certs import Certificate @@ -89,6 +90,19 @@ class Continue(AuthenticateRequest): return self.auth(login) +class RedirectLogout(LogoutRequest): + + def GET(self, *args, **kwargs): + query = cherrypy.request.query_string + + relaystate = kwargs.get(lasso.SAML2_FIELD_RELAYSTATE) + response = kwargs.get(lasso.SAML2_FIELD_RESPONSE) + + return self.logout(query, + relaystate=relaystate, + samlresponse=response) + + class SSO(ProviderPageBase): def __init__(self, *args, **kwargs): @@ -98,6 +112,14 @@ class SSO(ProviderPageBase): self.Continue = Continue(*args, **kwargs) +class SLO(ProviderPageBase): + + def __init__(self, *args, **kwargs): + super(SLO, self).__init__(*args, **kwargs) + self._debug('SLO init') + self.Redirect = RedirectLogout(*args, **kwargs) + + # one week METADATA_RENEW_INTERVAL = 60 * 60 * 24 * 7 # 30 days @@ -138,6 +160,7 @@ class SAML2(ProviderPageBase): super(SAML2, self).__init__(*args, **kwargs) self.metadata = Metadata(*args, **kwargs) self.SSO = SSO(*args, **kwargs) + self.SLO = SLO(*args, **kwargs) class IdpProvider(ProviderBase): -- 2.20.1