Skip to content

Commit e2a07fa

Browse files
author
Roland Hedberg
committed
Merge pull request #164 from erickt/logout
Add Single Logout support to examples and fix some bugs
2 parents 01eebe4 + e8a8183 commit e2a07fa

File tree

9 files changed

+178
-44
lines changed

9 files changed

+178
-44
lines changed

.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -177,3 +177,5 @@ example/sp-repoze/idp_test.xml
177177
example/sp-repoze/sp_conf_example.py
178178

179179
example/idp2/idp_conf_example.py
180+
181+
example/sp-wsgi/sp_conf.py

example/idp2/idp.py

+30-11
Original file line numberDiff line numberDiff line change
@@ -570,14 +570,13 @@ class SLO(Service):
570570
def do(self, request, binding, relay_state="", encrypt_cert=None):
571571
logger.info("--- Single Log Out Service ---")
572572
try:
573-
_, body = request.split("\n")
574-
logger.debug("req: '%s'" % body)
575-
req_info = IDP.parse_logout_request(body, binding)
573+
logger.debug("req: '%s'" % request)
574+
req_info = IDP.parse_logout_request(request, binding)
576575
except Exception as exc:
577576
logger.error("Bad request: %s" % exc)
578577
resp = BadRequest("%s" % exc)
579578
return resp(self.environ, self.start_response)
580-
579+
581580
msg = req_info.message
582581
if msg.name_id:
583582
lid = IDP.ident.find_local_id(msg.name_id)
@@ -591,14 +590,24 @@ def do(self, request, binding, relay_state="", encrypt_cert=None):
591590
try:
592591
IDP.session_db.remove_authn_statements(msg.name_id)
593592
except KeyError as exc:
594-
logger.error("ServiceError: %s" % exc)
595-
resp = ServiceError("%s" % exc)
593+
logger.error("Unknown session: %s" % exc)
594+
resp = ServiceError("Unknown session: %s" % exc)
596595
return resp(self.environ, self.start_response)
597-
596+
598597
resp = IDP.create_logout_response(msg, [binding])
599-
598+
599+
if binding == BINDING_SOAP:
600+
destination = ""
601+
response = False
602+
else:
603+
binding, destination = IDP.pick_binding("single_logout_service",
604+
[binding], "spsso",
605+
req_info)
606+
response = True
607+
600608
try:
601-
hinfo = IDP.apply_binding(binding, "%s" % resp, "", relay_state)
609+
hinfo = IDP.apply_binding(binding, "%s" % resp, destination, relay_state,
610+
response=response)
602611
except Exception as exc:
603612
logger.error("ServiceError: %s" % exc)
604613
resp = ServiceError("%s" % exc)
@@ -609,8 +618,18 @@ def do(self, request, binding, relay_state="", encrypt_cert=None):
609618
if delco:
610619
hinfo["headers"].append(delco)
611620
logger.info("Header: %s" % (hinfo["headers"],))
612-
resp = Response(hinfo["data"], headers=hinfo["headers"])
613-
return resp(self.environ, self.start_response)
621+
622+
if binding == BINDING_HTTP_REDIRECT:
623+
for key, value in hinfo['headers']:
624+
if key.lower() == 'location':
625+
resp = Redirect(value, headers=hinfo["headers"])
626+
return resp(self.environ, self.start_response)
627+
628+
resp = ServiceError('missing Location header')
629+
return resp(self.environ, self.start_response)
630+
else:
631+
resp = Response(hinfo["data"], headers=hinfo["headers"])
632+
return resp(self.environ, self.start_response)
614633

615634
# ----------------------------------------------------------------------------
616635
# Manage Name ID service

example/sp-wsgi/sp.py

+119-21
Original file line numberDiff line numberDiff line change
@@ -156,45 +156,47 @@ def __init__(self):
156156
self.user = {}
157157
self.result = {}
158158

159-
def kaka2user(self, kaka):
160-
logger.debug("KAKA: %s" % kaka)
161-
if kaka:
162-
cookie_obj = SimpleCookie(kaka)
159+
def get_user(self, environ):
160+
cookie = environ.get("HTTP_COOKIE", '')
161+
162+
logger.debug("Cookie: %s" % cookie)
163+
if cookie:
164+
cookie_obj = SimpleCookie(cookie)
163165
morsel = cookie_obj.get(self.cookie_name, None)
164166
if morsel:
165167
try:
166168
return self.uid2user[morsel.value]
167169
except KeyError:
168170
return None
169171
else:
170-
logger.debug("No spauthn cookie")
172+
logger.debug("No %s cookie", self.cookie_name)
173+
171174
return None
172175

173-
def delete_cookie(self, environ=None, kaka=None):
174-
if not kaka:
175-
kaka = environ.get("HTTP_COOKIE", '')
176-
logger.debug("delete KAKA: %s" % kaka)
177-
if kaka:
176+
def delete_cookie(self, environ):
177+
cookie = environ.get("HTTP_COOKIE", '')
178+
logger.debug("delete cookie: %s" % cookie)
179+
if cookie:
178180
_name = self.cookie_name
179-
cookie_obj = SimpleCookie(kaka)
181+
cookie_obj = SimpleCookie(cookie)
180182
morsel = cookie_obj.get(_name, None)
181183
cookie = SimpleCookie()
182184
cookie[_name] = ""
183185
cookie[_name]['path'] = "/"
184186
logger.debug("Expire: %s" % morsel)
185-
cookie[_name]["expires"] = _expiration("dawn")
186-
return tuple(cookie.output().split(": ", 1))
187+
cookie[_name]["expires"] = _expiration("now")
188+
return cookie.output().split(": ", 1)
187189
return None
188190

189-
def user2kaka(self, user):
191+
def set_cookie(self, user):
190192
uid = rndstr(32)
191193
self.uid2user[uid] = user
192194
cookie = SimpleCookie()
193195
cookie[self.cookie_name] = uid
194196
cookie[self.cookie_name]['path'] = "/"
195197
cookie[self.cookie_name]["expires"] = _expiration(480)
196198
logger.debug("Cookie expires: %s" % cookie[self.cookie_name]["expires"])
197-
return tuple(cookie.output().split(": ", 1))
199+
return cookie.output().split(": ", 1)
198200

199201

200202
# -----------------------------------------------------------------------------
@@ -318,6 +320,12 @@ def not_authn(self):
318320
# -----------------------------------------------------------------------------
319321

320322

323+
class User(object):
324+
def __init__(self, name_id, data):
325+
self.name_id = name_id
326+
self.data = data
327+
328+
321329
class ACS(Service):
322330
def __init__(self, sp, environ, start_response, cache=None, **kwargs):
323331
Service.__init__(self, environ, start_response)
@@ -357,7 +365,14 @@ def do(self, response, binding, relay_state="", mtype="response"):
357365
return resp(self.environ, self.start_response)
358366

359367
logger.info("AVA: %s" % self.response.ava)
360-
resp = Response(dict_to_table(self.response.ava))
368+
369+
user = User(self.response.name_id, self.response.ava)
370+
cookie = self.cache.set_cookie(user)
371+
372+
resp = Redirect("/", headers=[
373+
("Location", "/"),
374+
cookie,
375+
])
361376
return resp(self.environ, self.start_response)
362377

363378
def verify_attributes(self, ava):
@@ -543,7 +558,6 @@ def redirect_to_auth(self, _cli, entity_id, came_from):
543558
ht_args = _cli.apply_binding(_binding, "%s" % req, destination,
544559
relay_state=_rstate)
545560
_sid = req_id
546-
logger.debug("ht_args: %s" % ht_args)
547561
except Exception, exc:
548562
logger.exception(exc)
549563
resp = ServiceError(
@@ -582,6 +596,19 @@ def do(self):
582596
# ----------------------------------------------------------------------------
583597

584598

599+
class SLO(Service):
600+
def __init__(self, sp, environ, start_response, cache=None):
601+
Service.__init__(self, environ, start_response)
602+
self.sp = sp
603+
self.cache = cache
604+
605+
def do(self, response, binding, relay_state="", mtype="response"):
606+
req_info = self.sp.parse_logout_request_response(response, binding)
607+
return finish_logout(self.environ, self.start_response)
608+
609+
# ----------------------------------------------------------------------------
610+
611+
585612
#noinspection PyUnusedLocal
586613
def not_found(environ, start_response):
587614
"""Called if no URL matches."""
@@ -593,9 +620,18 @@ def not_found(environ, start_response):
593620

594621

595622
#noinspection PyUnusedLocal
596-
def main(environ, start_response, _sp):
597-
_sso = SSO(_sp, environ, start_response, cache=CACHE, **ARGS)
598-
return _sso.do()
623+
def main(environ, start_response, sp):
624+
user = CACHE.get_user(environ)
625+
626+
if user is None:
627+
sso = SSO(sp, environ, start_response, cache=CACHE, **ARGS)
628+
return sso.do()
629+
630+
body = dict_to_table(user.data)
631+
body += '<br><a href="/logout">logout</a>'
632+
633+
resp = Response(body)
634+
return resp(environ, start_response)
599635

600636

601637
def disco(environ, start_response, _sp):
@@ -613,12 +649,67 @@ def disco(environ, start_response, _sp):
613649

614650
# ----------------------------------------------------------------------------
615651

652+
653+
#noinspection PyUnusedLocal
654+
def logout(environ, start_response, sp):
655+
user = CACHE.get_user(environ)
656+
657+
if user is None:
658+
sso = SSO(sp, environ, start_response, cache=CACHE, **ARGS)
659+
return sso.do()
660+
661+
logger.info("[logout] subject_id: '%s'" % (user.name_id,))
662+
663+
# What if more than one
664+
data = sp.global_logout(user.name_id)
665+
logger.info("[logout] global_logout > %s" % data)
666+
667+
for entity_id, logout_info in data.items():
668+
if isinstance(logout_info, tuple):
669+
binding, http_info = logout_info
670+
671+
if binding == BINDING_HTTP_POST:
672+
body = ''.join(http_info['data'])
673+
resp = Response(body)
674+
return resp(environ, start_response)
675+
elif binding == BINDING_HTTP_REDIRECT:
676+
for key, value in http_info['headers']:
677+
if key.lower() == 'location':
678+
resp = Redirect(value)
679+
return resp(environ, start_response)
680+
681+
resp = ServiceError('missing Location header')
682+
return resp(environ, start_response)
683+
else:
684+
resp = ServiceError('unknown logout binding: %s', binding)
685+
return resp(environ, start_response)
686+
else: # result from logout, should be OK
687+
pass
688+
689+
return finish_logout(environ, start_response)
690+
691+
692+
def finish_logout(environ, start_response):
693+
logger.info("[logout done] environ: %s" % environ)
694+
logger.info("[logout done] remaining subjects: %s" % CACHE.uid2user.values())
695+
696+
# remove cookie and stored info
697+
cookie = CACHE.delete_cookie(environ)
698+
699+
resp = Response('You are now logged out of this service', headers=[
700+
cookie,
701+
])
702+
return resp(environ, start_response)
703+
704+
# ----------------------------------------------------------------------------
705+
616706
# map urls to functions
617707
urls = [
618708
# Hmm, place holder, NOT used
619709
('place', ("holder", None)),
620710
(r'^$', main),
621-
(r'^disco', disco)
711+
(r'^disco', disco),
712+
(r'^logout$', logout),
622713
]
623714

624715

@@ -630,6 +721,13 @@ def add_urls():
630721
urls.append(("%s/redirect$" % base, (ACS, "redirect", SP)))
631722
urls.append(("%s/redirect/(.*)$" % base, (ACS, "redirect", SP)))
632723

724+
base = "slo"
725+
726+
urls.append(("%s/post$" % base, (SLO, "post", SP)))
727+
urls.append(("%s/post/(.*)$" % base, (SLO, "post", SP)))
728+
urls.append(("%s/redirect$" % base, (SLO, "redirect", SP)))
729+
urls.append(("%s/redirect/(.*)$" % base, (SLO, "redirect", SP)))
730+
633731
# ----------------------------------------------------------------------------
634732

635733

example/sp-wsgi/sp.xml

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<?xml version='1.0' encoding='UTF-8'?>
2-
<ns0:EntityDescriptor xmlns:ns0="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:ns1="urn:oasis:names:tc:SAML:metadata:attribute" xmlns:ns2="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:ns4="http://www.w3.org/2000/09/xmldsig#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" entityID="http://localhost:8087/sp.xml"><ns0:Extensions><ns1:EntityAttributes><ns2:Attribute Name="http://macedir.org/entity-category"><ns2:AttributeValue xsi:type="xs:string">http://www.geant.net/uri/dataprotection-code-of-conduct/v1</ns2:AttributeValue></ns2:Attribute></ns1:EntityAttributes></ns0:Extensions><ns0:SPSSODescriptor AuthnRequestsSigned="false" WantAssertionsSigned="true" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol"><ns0:KeyDescriptor use="encryption"><ns4:KeyInfo><ns4:X509Data><ns4:X509Certificate>MIIC8jCCAlugAwIBAgIJAJHg2V5J31I8MA0GCSqGSIb3DQEBBQUAMFoxCzAJBgNV
2+
<ns0:EntityDescriptor xmlns:ns0="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:ns1="urn:oasis:names:tc:SAML:metadata:attribute" xmlns:ns2="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:ns4="http://www.w3.org/2000/09/xmldsig#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" entityID="http://localhost:8087/sp.xml"><ns0:Extensions><ns1:EntityAttributes><ns2:Attribute Name="http://macedir.org/entity-category" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri"><ns2:AttributeValue xsi:type="xs:string">http://www.geant.net/uri/dataprotection-code-of-conduct/v1</ns2:AttributeValue></ns2:Attribute></ns1:EntityAttributes></ns0:Extensions><ns0:SPSSODescriptor AuthnRequestsSigned="false" WantAssertionsSigned="true" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol"><ns0:KeyDescriptor use="encryption"><ns4:KeyInfo><ns4:X509Data><ns4:X509Certificate>MIIC8jCCAlugAwIBAgIJAJHg2V5J31I8MA0GCSqGSIb3DQEBBQUAMFoxCzAJBgNV
33
BAYTAlNFMQ0wCwYDVQQHEwRVbWVhMRgwFgYDVQQKEw9VbWVhIFVuaXZlcnNpdHkx
44
EDAOBgNVBAsTB0lUIFVuaXQxEDAOBgNVBAMTB1Rlc3QgU1AwHhcNMDkxMDI2MTMz
55
MTE1WhcNMTAxMDI2MTMzMTE1WjBaMQswCQYDVQQGEwJTRTENMAsGA1UEBxMEVW1l
@@ -31,4 +31,4 @@ AxMHVGVzdCBTUIIJAJHg2V5J31I8MAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEF
3131
BQADgYEAMuRwwXRnsiyWzmRikpwinnhTmbooKm5TINPE7A7gSQ710RxioQePPhZO
3232
zkM27NnHTrCe2rBVg0EGz7QTd1JIwLPvgoj4VTi/fSha/tXrYUaqc9AqU1kWI4WN
3333
+vffBGQ09mo+6CffuFTZYeOhzP/2stAPwCTU4kxEoiy0KpZMANI=
34-
</ns4:X509Certificate></ns4:X509Data></ns4:KeyInfo></ns0:KeyDescriptor><ns0:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="http://localhost:8087/acs/redirect" index="1" /><ns0:AttributeConsumingService index="1"><ns0:ServiceName xml:lang="en">My SP service</ns0:ServiceName><ns0:ServiceDescription xml:lang="en">Example SP</ns0:ServiceDescription><ns0:RequestedAttribute FriendlyName="sn" Name="urn:oid:2.5.4.4" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri" isRequired="true" /><ns0:RequestedAttribute FriendlyName="givenname" Name="urn:oid:2.5.4.42" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri" isRequired="true" /><ns0:RequestedAttribute FriendlyName="edupersonaffiliation" Name="urn:oid:1.3.6.1.4.1.5923.1.1.1.1" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri" isRequired="true" /><ns0:RequestedAttribute FriendlyName="title" Name="urn:oid:2.5.4.12" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri" isRequired="false" /></ns0:AttributeConsumingService></ns0:SPSSODescriptor></ns0:EntityDescriptor>
34+
</ns4:X509Certificate></ns4:X509Data></ns4:KeyInfo></ns0:KeyDescriptor><ns0:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="http://localhost:8087/slo/redirect" /><ns0:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="http://localhost:8087/slo/post" /><ns0:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="http://localhost:8087/acs/redirect" index="1" /><ns0:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="http://localhost:8087/acs/post" index="2" /></ns0:SPSSODescriptor></ns0:EntityDescriptor>

example/sp-wsgi/sp_conf.py.example

+6
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,16 @@ CONFIG = {
2323
"description": "Example SP",
2424
"service": {
2525
"sp": {
26+
"authn_requests_signed": True,
27+
"logout_requests_signed": True,
2628
"endpoints": {
2729
"assertion_consumer_service": [
2830
("%s/acs/post" % BASE, BINDING_HTTP_POST)
2931
],
32+
"single_logout_service": [
33+
("%s/slo/redirect" % BASE, BINDING_HTTP_REDIRECT),
34+
("%s/slo/post" % BASE, BINDING_HTTP_POST),
35+
],
3036
}
3137
},
3238
},

src/saml2/client.py

+2-3
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
from saml2.samlp import STATUS_UNKNOWN_PRINCIPAL
2323
from saml2.time_util import not_on_or_after
2424
from saml2.saml import AssertionIDRef
25-
from saml2.saml import NAMEID_FORMAT_PERSISTENT
2625
from saml2.client_base import Base
2726
from saml2.client_base import LogoutError
2827
from saml2.client_base import NoServiceDefined
@@ -44,7 +43,7 @@ class Saml2Client(Base):
4443

4544
def prepare_for_authenticate(self, entityid=None, relay_state="",
4645
binding=saml2.BINDING_HTTP_REDIRECT, vorg="",
47-
nameid_format=NAMEID_FORMAT_PERSISTENT,
46+
nameid_format=None,
4847
scoping=None, consent=None, extensions=None,
4948
sign=None,
5049
response_binding=saml2.BINDING_HTTP_POST,
@@ -178,7 +177,7 @@ def do_logout(self, name_id, entity_ids, reason, expire, sign=None,
178177
not_done.remove(entity_id)
179178
response = response.text
180179
logger.info("Response: %s" % response)
181-
res = self.parse_logout_request_response(response)
180+
res = self.parse_logout_request_response(response, binding)
182181
responses[entity_id] = res
183182
else:
184183
logger.info("NOT OK response from %s" % destination)

src/saml2/client_base.py

+12-6
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ def service_urls(self, binding=BINDING_HTTP_POST):
193193

194194
def create_authn_request(self, destination, vorg="", scoping=None,
195195
binding=saml2.BINDING_HTTP_POST,
196-
nameid_format=NAMEID_FORMAT_TRANSIENT,
196+
nameid_format=None,
197197
service_url_binding=None, message_id=0,
198198
consent=None, extensions=None, sign=None,
199199
allow_create=False, sign_prepare=False, **kwargs):
@@ -261,13 +261,19 @@ def create_authn_request(self, destination, vorg="", scoping=None,
261261
else:
262262
allow_create = "false"
263263

264-
# Profile stuff, should be configurable
265-
if nameid_format is None:
266-
name_id_policy = samlp.NameIDPolicy(
267-
allow_create=allow_create, format=NAMEID_FORMAT_TRANSIENT)
268-
elif nameid_format == "":
264+
if nameid_format == "":
269265
name_id_policy = None
270266
else:
267+
if nameid_format is None:
268+
nameid_format = self.config.getattr("name_id_format", "sp")
269+
270+
if nameid_format is None:
271+
nameid_format = NAMEID_FORMAT_TRANSIENT
272+
elif isinstance(nameid_format, list):
273+
# NameIDPolicy can only have one format specified
274+
nameid_format = nameid_format[0]
275+
276+
271277
name_id_policy = samlp.NameIDPolicy(allow_create=allow_create,
272278
format=nameid_format)
273279

0 commit comments

Comments
 (0)