diff --git a/.gitignore b/.gitignore index 54ce3bb..a8ddb96 100644 --- a/.gitignore +++ b/.gitignore @@ -177,3 +177,5 @@ example/sp-repoze/idp_test.xml example/sp-repoze/sp_conf_example.py example/idp2/idp_conf_example.py + +example/sp-wsgi/sp_conf.py diff --git a/example/idp2/idp.py b/example/idp2/idp.py index 8e6af4f..efa8e92 100755 --- a/example/idp2/idp.py +++ b/example/idp2/idp.py @@ -570,14 +570,13 @@ class SLO(Service): def do(self, request, binding, relay_state="", encrypt_cert=None): logger.info("--- Single Log Out Service ---") try: - _, body = request.split("\n") - logger.debug("req: '%s'" % body) - req_info = IDP.parse_logout_request(body, binding) + logger.debug("req: '%s'" % request) + req_info = IDP.parse_logout_request(request, binding) except Exception as exc: logger.error("Bad request: %s" % exc) resp = BadRequest("%s" % exc) return resp(self.environ, self.start_response) - + msg = req_info.message if msg.name_id: lid = IDP.ident.find_local_id(msg.name_id) @@ -591,14 +590,24 @@ class SLO(Service): try: IDP.session_db.remove_authn_statements(msg.name_id) except KeyError as exc: - logger.error("ServiceError: %s" % exc) - resp = ServiceError("%s" % exc) + logger.error("Unknown session: %s" % exc) + resp = ServiceError("Unknown session: %s" % exc) return resp(self.environ, self.start_response) - + resp = IDP.create_logout_response(msg, [binding]) - + + if binding == BINDING_SOAP: + destination = "" + response = False + else: + binding, destination = IDP.pick_binding("single_logout_service", + [binding], "spsso", + req_info) + response = True + try: - hinfo = IDP.apply_binding(binding, "%s" % resp, "", relay_state) + hinfo = IDP.apply_binding(binding, "%s" % resp, destination, relay_state, + response=response) except Exception as exc: logger.error("ServiceError: %s" % exc) resp = ServiceError("%s" % exc) @@ -609,8 +618,18 @@ class SLO(Service): if delco: hinfo["headers"].append(delco) logger.info("Header: %s" % (hinfo["headers"],)) - resp = Response(hinfo["data"], headers=hinfo["headers"]) - return resp(self.environ, self.start_response) + + if binding == BINDING_HTTP_REDIRECT: + for key, value in hinfo['headers']: + if key.lower() == 'location': + resp = Redirect(value, headers=hinfo["headers"]) + return resp(self.environ, self.start_response) + + resp = ServiceError('missing Location header') + return resp(self.environ, self.start_response) + else: + resp = Response(hinfo["data"], headers=hinfo["headers"]) + return resp(self.environ, self.start_response) # ---------------------------------------------------------------------------- # Manage Name ID service diff --git a/example/sp-wsgi/sp.py b/example/sp-wsgi/sp.py index 5fa31da..d9f59f5 100755 --- a/example/sp-wsgi/sp.py +++ b/example/sp-wsgi/sp.py @@ -156,10 +156,12 @@ class Cache(object): self.user = {} self.result = {} - def kaka2user(self, kaka): - logger.debug("KAKA: %s" % kaka) - if kaka: - cookie_obj = SimpleCookie(kaka) + def get_user(self, environ): + cookie = environ.get("HTTP_COOKIE", '') + + logger.debug("Cookie: %s" % cookie) + if cookie: + cookie_obj = SimpleCookie(cookie) morsel = cookie_obj.get(self.cookie_name, None) if morsel: try: @@ -167,26 +169,26 @@ class Cache(object): except KeyError: return None else: - logger.debug("No spauthn cookie") + logger.debug("No %s cookie", self.cookie_name) + return None - def delete_cookie(self, environ=None, kaka=None): - if not kaka: - kaka = environ.get("HTTP_COOKIE", '') - logger.debug("delete KAKA: %s" % kaka) - if kaka: + def delete_cookie(self, environ): + cookie = environ.get("HTTP_COOKIE", '') + logger.debug("delete cookie: %s" % cookie) + if cookie: _name = self.cookie_name - cookie_obj = SimpleCookie(kaka) + cookie_obj = SimpleCookie(cookie) morsel = cookie_obj.get(_name, None) cookie = SimpleCookie() cookie[_name] = "" cookie[_name]['path'] = "/" logger.debug("Expire: %s" % morsel) - cookie[_name]["expires"] = _expiration("dawn") - return tuple(cookie.output().split(": ", 1)) + cookie[_name]["expires"] = _expiration("now") + return cookie.output().split(": ", 1) return None - def user2kaka(self, user): + def set_cookie(self, user): uid = rndstr(32) self.uid2user[uid] = user cookie = SimpleCookie() @@ -194,7 +196,7 @@ class Cache(object): cookie[self.cookie_name]['path'] = "/" cookie[self.cookie_name]["expires"] = _expiration(480) logger.debug("Cookie expires: %s" % cookie[self.cookie_name]["expires"]) - return tuple(cookie.output().split(": ", 1)) + return cookie.output().split(": ", 1) # ----------------------------------------------------------------------------- @@ -318,6 +320,12 @@ class Service(object): # ----------------------------------------------------------------------------- +class User(object): + def __init__(self, name_id, data): + self.name_id = name_id + self.data = data + + class ACS(Service): def __init__(self, sp, environ, start_response, cache=None, **kwargs): Service.__init__(self, environ, start_response) @@ -357,7 +365,14 @@ class ACS(Service): return resp(self.environ, self.start_response) logger.info("AVA: %s" % self.response.ava) - resp = Response(dict_to_table(self.response.ava)) + + user = User(self.response.name_id, self.response.ava) + cookie = self.cache.set_cookie(user) + + resp = Redirect("/", headers=[ + ("Location", "/"), + cookie, + ]) return resp(self.environ, self.start_response) def verify_attributes(self, ava): @@ -543,7 +558,6 @@ class SSO(object): ht_args = _cli.apply_binding(_binding, "%s" % req, destination, relay_state=_rstate) _sid = req_id - logger.debug("ht_args: %s" % ht_args) except Exception, exc: logger.exception(exc) resp = ServiceError( @@ -582,6 +596,19 @@ class SSO(object): # ---------------------------------------------------------------------------- +class SLO(Service): + def __init__(self, sp, environ, start_response, cache=None): + Service.__init__(self, environ, start_response) + self.sp = sp + self.cache = cache + + def do(self, response, binding, relay_state="", mtype="response"): + req_info = self.sp.parse_logout_request_response(response, binding) + return finish_logout(self.environ, self.start_response) + +# ---------------------------------------------------------------------------- + + #noinspection PyUnusedLocal def not_found(environ, start_response): """Called if no URL matches.""" @@ -593,9 +620,18 @@ def not_found(environ, start_response): #noinspection PyUnusedLocal -def main(environ, start_response, _sp): - _sso = SSO(_sp, environ, start_response, cache=CACHE, **ARGS) - return _sso.do() +def main(environ, start_response, sp): + user = CACHE.get_user(environ) + + if user is None: + sso = SSO(sp, environ, start_response, cache=CACHE, **ARGS) + return sso.do() + + body = dict_to_table(user.data) + body += '
logout' + + resp = Response(body) + return resp(environ, start_response) def disco(environ, start_response, _sp): @@ -613,12 +649,67 @@ def disco(environ, start_response, _sp): # ---------------------------------------------------------------------------- + +#noinspection PyUnusedLocal +def logout(environ, start_response, sp): + user = CACHE.get_user(environ) + + if user is None: + sso = SSO(sp, environ, start_response, cache=CACHE, **ARGS) + return sso.do() + + logger.info("[logout] subject_id: '%s'" % (user.name_id,)) + + # What if more than one + data = sp.global_logout(user.name_id) + logger.info("[logout] global_logout > %s" % data) + + for entity_id, logout_info in data.items(): + if isinstance(logout_info, tuple): + binding, http_info = logout_info + + if binding == BINDING_HTTP_POST: + body = ''.join(http_info['data']) + resp = Response(body) + return resp(environ, start_response) + elif binding == BINDING_HTTP_REDIRECT: + for key, value in http_info['headers']: + if key.lower() == 'location': + resp = Redirect(value) + return resp(environ, start_response) + + resp = ServiceError('missing Location header') + return resp(environ, start_response) + else: + resp = ServiceError('unknown logout binding: %s', binding) + return resp(environ, start_response) + else: # result from logout, should be OK + pass + + return finish_logout(environ, start_response) + + +def finish_logout(environ, start_response): + logger.info("[logout done] environ: %s" % environ) + logger.info("[logout done] remaining subjects: %s" % CACHE.uid2user.values()) + + # remove cookie and stored info + cookie = CACHE.delete_cookie(environ) + + resp = Response('You are now logged out of this service', headers=[ + cookie, + ]) + return resp(environ, start_response) + +# ---------------------------------------------------------------------------- + # map urls to functions urls = [ # Hmm, place holder, NOT used ('place', ("holder", None)), (r'^$', main), - (r'^disco', disco) + (r'^disco', disco), + (r'^logout$', logout), ] @@ -630,6 +721,13 @@ def add_urls(): urls.append(("%s/redirect$" % base, (ACS, "redirect", SP))) urls.append(("%s/redirect/(.*)$" % base, (ACS, "redirect", SP))) + base = "slo" + + urls.append(("%s/post$" % base, (SLO, "post", SP))) + urls.append(("%s/post/(.*)$" % base, (SLO, "post", SP))) + urls.append(("%s/redirect$" % base, (SLO, "redirect", SP))) + urls.append(("%s/redirect/(.*)$" % base, (SLO, "redirect", SP))) + # ---------------------------------------------------------------------------- diff --git a/example/sp-wsgi/sp.xml b/example/sp-wsgi/sp.xml index 6c28d9c..93df2be 100644 --- a/example/sp-wsgi/sp.xml +++ b/example/sp-wsgi/sp.xml @@ -1,5 +1,5 @@ -http://www.geant.net/uri/dataprotection-code-of-conduct/v1MIIC8jCCAlugAwIBAgIJAJHg2V5J31I8MA0GCSqGSIb3DQEBBQUAMFoxCzAJBgNV +http://www.geant.net/uri/dataprotection-code-of-conduct/v1MIIC8jCCAlugAwIBAgIJAJHg2V5J31I8MA0GCSqGSIb3DQEBBQUAMFoxCzAJBgNV BAYTAlNFMQ0wCwYDVQQHEwRVbWVhMRgwFgYDVQQKEw9VbWVhIFVuaXZlcnNpdHkx EDAOBgNVBAsTB0lUIFVuaXQxEDAOBgNVBAMTB1Rlc3QgU1AwHhcNMDkxMDI2MTMz MTE1WhcNMTAxMDI2MTMzMTE1WjBaMQswCQYDVQQGEwJTRTENMAsGA1UEBxMEVW1l @@ -31,4 +31,4 @@ AxMHVGVzdCBTUIIJAJHg2V5J31I8MAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEF BQADgYEAMuRwwXRnsiyWzmRikpwinnhTmbooKm5TINPE7A7gSQ710RxioQePPhZO zkM27NnHTrCe2rBVg0EGz7QTd1JIwLPvgoj4VTi/fSha/tXrYUaqc9AqU1kWI4WN +vffBGQ09mo+6CffuFTZYeOhzP/2stAPwCTU4kxEoiy0KpZMANI= -My SP serviceExample SP + diff --git a/example/sp-wsgi/sp_conf.py.example b/example/sp-wsgi/sp_conf.py.example index 96d1585..46adaa9 100644 --- a/example/sp-wsgi/sp_conf.py.example +++ b/example/sp-wsgi/sp_conf.py.example @@ -23,10 +23,16 @@ CONFIG = { "description": "Example SP", "service": { "sp": { + "authn_requests_signed": True, + "logout_requests_signed": True, "endpoints": { "assertion_consumer_service": [ ("%s/acs/post" % BASE, BINDING_HTTP_POST) ], + "single_logout_service": [ + ("%s/slo/redirect" % BASE, BINDING_HTTP_REDIRECT), + ("%s/slo/post" % BASE, BINDING_HTTP_POST), + ], } }, }, diff --git a/src/saml2/client.py b/src/saml2/client.py index b498d44..ca83bf9 100644 --- a/src/saml2/client.py +++ b/src/saml2/client.py @@ -22,7 +22,6 @@ from saml2.samlp import STATUS_REQUEST_DENIED from saml2.samlp import STATUS_UNKNOWN_PRINCIPAL from saml2.time_util import not_on_or_after from saml2.saml import AssertionIDRef -from saml2.saml import NAMEID_FORMAT_PERSISTENT from saml2.client_base import Base from saml2.client_base import LogoutError from saml2.client_base import NoServiceDefined @@ -44,7 +43,7 @@ class Saml2Client(Base): def prepare_for_authenticate(self, entityid=None, relay_state="", binding=saml2.BINDING_HTTP_REDIRECT, vorg="", - nameid_format=NAMEID_FORMAT_PERSISTENT, + nameid_format=None, scoping=None, consent=None, extensions=None, sign=None, response_binding=saml2.BINDING_HTTP_POST, @@ -178,7 +177,7 @@ class Saml2Client(Base): not_done.remove(entity_id) response = response.text logger.info("Response: %s" % response) - res = self.parse_logout_request_response(response) + res = self.parse_logout_request_response(response, binding) responses[entity_id] = res else: logger.info("NOT OK response from %s" % destination) diff --git a/src/saml2/client_base.py b/src/saml2/client_base.py index 793e3f7..5e026d0 100644 --- a/src/saml2/client_base.py +++ b/src/saml2/client_base.py @@ -193,7 +193,7 @@ class Base(Entity): def create_authn_request(self, destination, vorg="", scoping=None, binding=saml2.BINDING_HTTP_POST, - nameid_format=NAMEID_FORMAT_TRANSIENT, + nameid_format=None, service_url_binding=None, message_id=0, consent=None, extensions=None, sign=None, allow_create=False, sign_prepare=False, **kwargs): @@ -261,13 +261,19 @@ class Base(Entity): else: allow_create = "false" - # Profile stuff, should be configurable - if nameid_format is None: - name_id_policy = samlp.NameIDPolicy( - allow_create=allow_create, format=NAMEID_FORMAT_TRANSIENT) - elif nameid_format == "": + if nameid_format == "": name_id_policy = None else: + if nameid_format is None: + nameid_format = self.config.getattr("name_id_format", "sp") + + if nameid_format is None: + nameid_format = NAMEID_FORMAT_TRANSIENT + elif isinstance(nameid_format, list): + # NameIDPolicy can only have one format specified + nameid_format = nameid_format[0] + + name_id_policy = samlp.NameIDPolicy(allow_create=allow_create, format=nameid_format) diff --git a/src/saml2/request.py b/src/saml2/request.py index 3497db1..17f3edf 100644 --- a/src/saml2/request.py +++ b/src/saml2/request.py @@ -127,6 +127,10 @@ class LogoutRequest(Request): attribute_converters, timeslack) self.signature_check = self.sec.correctly_signed_logout_request + @property + def issuer(self): + return self.message.issuer + class AttributeQuery(Request): msgtype = "attribute_query" diff --git a/src/saml2/samlp.py b/src/saml2/samlp.py index 8951d34..06b4e4c 100644 --- a/src/saml2/samlp.py +++ b/src/saml2/samlp.py @@ -1833,4 +1833,4 @@ def any_response_from_string(xmlstr): if not resp: raise Exception("Unknown response type") - return resp \ No newline at end of file + return resp