From f3ec1cd41d9dcf9b96500165433c9b7d91479bb4 Mon Sep 17 00:00:00 2001 From: Sigmund Augdal Date: Mon, 3 Jun 2013 16:46:24 +0200 Subject: [PATCH 1/4] Implement initiating logout from s2repoze plugin. Adds a new repoze config option path_logout to the challenge decider plugin. When a request is received on this url a global logout request is initiated. Responses to this request is received on the single_logout_service endpoint of the sp as configured in saml config of the sp. Tested with only on IdP and only using HTTP_REDIRECT bindings TODO: handle receiving logout requests on the single_logout_service endpoint --- src/s2repoze/plugins/challenge_decider.py | 31 ++++++++---- src/s2repoze/plugins/sp.py | 59 +++++++++++++++++++---- src/saml2/client.py | 5 +- 3 files changed, 73 insertions(+), 22 deletions(-) diff --git a/src/s2repoze/plugins/challenge_decider.py b/src/s2repoze/plugins/challenge_decider.py index 85966c1..03fa441 100644 --- a/src/s2repoze/plugins/challenge_decider.py +++ b/src/s2repoze/plugins/challenge_decider.py @@ -54,25 +54,31 @@ def my_request_classifier(environ): zope.interface.directlyProvides(my_request_classifier, IRequestClassifier) class MyChallengeDecider: - def __init__(self, path_login=""): + def __init__(self, path_login="", path_logout=""): self.path_login = path_login + self.path_logout = path_logout def __call__(self, environ, status, _headers): if status.startswith('401 '): return True else: - # logout : need to "forget" => require a peculiar challenge - if environ.has_key('rwpc.logout'): + if environ.has_key('samlsp.pending'): return True + uri = environ.get('REQUEST_URI', None) + if uri is None: + uri = construct_url(environ) + + # require and challenge for logout and inform the challenge plugin that it is a logout we want + for regex in self.path_logout: + if regex.match(uri) is not None: + environ['samlsp.logout'] = True + return True + # If the user is already authent, whatever happens(except logout), # don't make a challenge if environ.has_key('repoze.who.identity'): return False - uri = environ.get('REQUEST_URI', None) - if uri is None: - uri = construct_url(environ) - # require a challenge for login for regex in self.path_login: if regex.match(uri) is not None: @@ -82,7 +88,7 @@ class MyChallengeDecider: -def make_plugin(path_login = None): +def make_plugin(path_login = None, path_logout = None): if path_login is None: raise ValueError( 'must include path_login in configuration') @@ -94,7 +100,14 @@ def make_plugin(path_login = None): if carg != '': list_login.append(re.compile(carg)) - plugin = MyChallengeDecider(list_login) + list_logout = [] + if path_logout is not None: + for arg in path_logout.splitlines(): + carg = arg.lstrip() + if carg != '': + list_logout.append(re.compile(carg)) + + plugin = MyChallengeDecider(list_login, list_logout) return plugin diff --git a/src/s2repoze/plugins/sp.py b/src/s2repoze/plugins/sp.py index f8f25e4..a4ebca9 100644 --- a/src/s2repoze/plugins/sp.py +++ b/src/s2repoze/plugins/sp.py @@ -24,9 +24,9 @@ import sys import platform import shelve import traceback -from urlparse import parse_qs +from urlparse import parse_qs, urlparse -from paste.httpexceptions import HTTPSeeOther +from paste.httpexceptions import HTTPSeeOther, HTTPRedirection from paste.httpexceptions import HTTPNotImplemented from paste.httpexceptions import HTTPInternalServerError from paste.request import parse_dict_querystring @@ -133,6 +133,7 @@ class SAML2Plugin(FormPluginBase): self.cache = cache self.discosrv = discovery self.idp_query_param = idp_query_param + self.logout_endpoints = [urlparse(ep)[2] for ep in config.endpoint("single_logout_service")] try: self.metadata = self.conf.metadata @@ -282,10 +283,22 @@ class SAML2Plugin(FormPluginBase): _cli = self.saml_client - # this challenge consist in logging out - if 'rwpc.logout' in environ: - # ignore right now? - pass + + if 'REMOTE_USER' in environ: + name_id = decode(environ["REMOTE_USER"]) + + _cli = self.saml_client + path_info = environ['PATH_INFO'] + + if 'samlsp.logout' in environ: + responses = _cli.global_logout(name_id) + return self._handle_logout(responses) + + if 'samlsp.pending' in environ: + response = environ['samlsp.pending'] + if isinstance(response, HTTPRedirection): + response.headers += _forget_headers + return response #logger = environ.get('repoze.who.logger','') @@ -405,7 +418,8 @@ class SAML2Plugin(FormPluginBase): """ #logger = environ.get('repoze.who.logger', '') - if "CONTENT_LENGTH" not in environ or not environ["CONTENT_LENGTH"]: + query = parse_dict_querystring(environ) + if ("CONTENT_LENGTH" not in environ or not environ["CONTENT_LENGTH"]) and "SAMLResponse" not in query: logger.debug('[identify] get or empty post') return {} @@ -443,10 +457,28 @@ class SAML2Plugin(FormPluginBase): logger.info("[sp.identify] --- SAMLResponse ---") # check for SAML2 authN response #if self.debug: + path_info = environ['PATH_INFO'] + logout = False + if path_info in self.logout_endpoints: + logout = True try: - session_info = self._eval_authn_response( - environ, cgi_field_storage_to_dict(post), - binding=binding) + if logout: + response = self.saml_client.parse_logout_request_response(post["SAMLResponse"], binding) + if response: + action = self.saml_client.handle_logout_response(response) + request = None + if type(action) == dict: + request = self._handle_logout(action) + else: + #logout complete + request = HTTPSeeOther(headers=[('Location', "/")]) + if request: + environ['samlsp.pending'] = request + return {} + else: + session_info = self._eval_authn_response( + environ, cgi_field_storage_to_dict(post), + binding=binding) except Exception, err: environ["s2repoze.saml_error"] = err return {} @@ -532,6 +564,13 @@ class SAML2Plugin(FormPluginBase): else: return None + def _handle_logout(self, responses): + ht_args = responses[responses.keys()[0]][1] + if not ht_args["data"] and ht_args["headers"][0][0] == "Location": + logger.debug('redirect to: %s' % ht_args["headers"][0][1]) + return HTTPSeeOther(headers=ht_args["headers"]) + else: + return ht_args["data"] def make_plugin(remember_name=None, # plugin for remember cache="", # cache diff --git a/src/saml2/client.py b/src/saml2/client.py index 91f8f6c..fcca810 100644 --- a/src/saml2/client.py +++ b/src/saml2/client.py @@ -113,7 +113,6 @@ class Saml2Client(Base): # find out which IdPs/AAs I should notify entity_ids = self.users.issuers_of_info(name_id) - self.users.remove_person(name_id) return self.do_logout(name_id, entity_ids, reason, expire, sign) def do_logout(self, name_id, entity_ids, reason, expire, sign=None): @@ -232,11 +231,11 @@ class Saml2Client(Base): logger.info("issuer: %s" % issuer) del self.state[response.in_response_to] if status["entity_ids"] == [issuer]: # done - self.local_logout(status["subject_id"]) + self.local_logout(status["name_id"]) return 0, "200 Ok", [("Content-type", "text/html")], [] else: status["entity_ids"].remove(issuer) - return self.do_logout(status["subject_id"], status["entity_ids"], + return self.do_logout(status["name_id"], status["entity_ids"], status["reason"], status["not_on_or_after"], status["sign"]) From 11b777fb1d6d3513bcce14845aea3f0fe4cf7b9f Mon Sep 17 00:00:00 2001 From: Sigmund Augdal Date: Mon, 3 Jun 2013 17:27:38 +0200 Subject: [PATCH 2/4] Allow graceful handling of auth_tkt cookies outliving saml clients cache If for some reason the session cookie outlives the saml clients cache, for instance if the webservice is restarted there could be an inconsistent state where the user is authenticated but saml attributes are missing and saml logout requests will fail. By using only saml2sp as authenticator plugin and repoze.who 2.0 this little check will work around that and require a new login in this case --- src/s2repoze/plugins/sp.py | 3 +++ src/saml2/client.py | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/src/s2repoze/plugins/sp.py b/src/s2repoze/plugins/sp.py index a4ebca9..6ede258 100644 --- a/src/s2repoze/plugins/sp.py +++ b/src/s2repoze/plugins/sp.py @@ -560,6 +560,9 @@ class SAML2Plugin(FormPluginBase): #noinspection PyUnusedLocal def authenticate(self, environ, identity=None): if identity: + tktuser = identity.get('repoze.who.plugins.auth_tkt.userid', None) + if tktuser and self.saml_client.is_logged_in(decode(tktuser)): + return tktuser return identity.get('login', None) else: return None diff --git a/src/saml2/client.py b/src/saml2/client.py index fcca810..501bccd 100644 --- a/src/saml2/client.py +++ b/src/saml2/client.py @@ -216,6 +216,14 @@ class Saml2Client(Base): self.users.remove_person(name_id) return True + def is_logged_in(self, name_id): + """ Check if user is in the cache + + :param name_id: The identifier of the subject + """ + identity = self.users.get_identity(name_id)[0] + return bool(identity) + def handle_logout_response(self, response): """ handles a Logout response From 8b69c3592c02278ce081d009aecee78dc6dcae54 Mon Sep 17 00:00:00 2001 From: Sigmund Augdal Date: Tue, 11 Jun 2013 14:14:17 +0200 Subject: [PATCH 3/4] Added support for receiving SAMLRequests on the single_logout endpoint --- src/s2repoze/plugins/sp.py | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/src/s2repoze/plugins/sp.py b/src/s2repoze/plugins/sp.py index 6ede258..5f667a7 100644 --- a/src/s2repoze/plugins/sp.py +++ b/src/s2repoze/plugins/sp.py @@ -419,7 +419,7 @@ class SAML2Plugin(FormPluginBase): #logger = environ.get('repoze.who.logger', '') query = parse_dict_querystring(environ) - if ("CONTENT_LENGTH" not in environ or not environ["CONTENT_LENGTH"]) and "SAMLResponse" not in query: + if ("CONTENT_LENGTH" not in environ or not environ["CONTENT_LENGTH"]) and "SAMLResponse" not in query and "SAMLRequest" not in query: logger.debug('[identify] get or empty post') return {} @@ -434,7 +434,7 @@ class SAML2Plugin(FormPluginBase): query = parse_dict_querystring(environ) logger.debug('[sp.identify] query: %s' % (query,)) - if "SAMLResponse" in query: + if "SAMLResponse" in query or "SAMLRequest" in query: post = query binding = BINDING_HTTP_REDIRECT else: @@ -447,7 +447,21 @@ class SAML2Plugin(FormPluginBase): pass try: - if "SAMLResponse" not in post: + path_info = environ['PATH_INFO'] + logout = False + if path_info in self.logout_endpoints: + logout = True + + if logout and "SAMLRequest" in post: + print("logout request received") + try: + response = self.saml_client.handle_logout_request(post["SAMLRequest"], self.saml_client.users.subjects()[0], binding) + environ['samlsp.pending'] = self._handle_logout(response) + return {} + except: + import traceback + traceback.print_exc() + elif "SAMLResponse" not in post: logger.info("[sp.identify] --- NOT SAMLResponse ---") # Not for me, put the post back where next in line can # find it @@ -457,10 +471,6 @@ class SAML2Plugin(FormPluginBase): logger.info("[sp.identify] --- SAMLResponse ---") # check for SAML2 authN response #if self.debug: - path_info = environ['PATH_INFO'] - logout = False - if path_info in self.logout_endpoints: - logout = True try: if logout: response = self.saml_client.parse_logout_request_response(post["SAMLResponse"], binding) @@ -568,7 +578,10 @@ class SAML2Plugin(FormPluginBase): return None def _handle_logout(self, responses): - ht_args = responses[responses.keys()[0]][1] + if 'data' in responses: + ht_args = responses + else: + ht_args = responses[responses.keys()[0]][1] if not ht_args["data"] and ht_args["headers"][0][0] == "Location": logger.debug('redirect to: %s' % ht_args["headers"][0][1]) return HTTPSeeOther(headers=ht_args["headers"]) From 523dfbbaa407510a0006c23361f3d27570829969 Mon Sep 17 00:00:00 2001 From: Sigmund Augdal Date: Fri, 21 Jun 2013 17:59:37 +0200 Subject: [PATCH 4/4] Loosen explicit requirement on repoze.who 1.0.18 as 2.0 seems to work fine --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 75d53d9..af2175d 100755 --- a/setup.py +++ b/setup.py @@ -42,7 +42,7 @@ install_requires = [ 'requests >= 1.0.0', 'paste', 'zope.interface', - 'repoze.who == 1.0.18', + 'repoze.who >= 1.0.18', 'm2crypto' ]