Merge pull request #164 from erickt/logout
Add Single Logout support to examples and fix some bugs
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 += '<br><a href="/logout">logout</a>'
|
||||
|
||||
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)))
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
<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
|
||||
<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
|
||||
BAYTAlNFMQ0wCwYDVQQHEwRVbWVhMRgwFgYDVQQKEw9VbWVhIFVuaXZlcnNpdHkx
|
||||
EDAOBgNVBAsTB0lUIFVuaXQxEDAOBgNVBAMTB1Rlc3QgU1AwHhcNMDkxMDI2MTMz
|
||||
MTE1WhcNMTAxMDI2MTMzMTE1WjBaMQswCQYDVQQGEwJTRTENMAsGA1UEBxMEVW1l
|
||||
@@ -31,4 +31,4 @@ AxMHVGVzdCBTUIIJAJHg2V5J31I8MAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEF
|
||||
BQADgYEAMuRwwXRnsiyWzmRikpwinnhTmbooKm5TINPE7A7gSQ710RxioQePPhZO
|
||||
zkM27NnHTrCe2rBVg0EGz7QTd1JIwLPvgoj4VTi/fSha/tXrYUaqc9AqU1kWI4WN
|
||||
+vffBGQ09mo+6CffuFTZYeOhzP/2stAPwCTU4kxEoiy0KpZMANI=
|
||||
</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>
|
||||
</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>
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1833,4 +1833,4 @@ def any_response_from_string(xmlstr):
|
||||
|
||||
if not resp:
|
||||
raise Exception("Unknown response type")
|
||||
return resp
|
||||
return resp
|
||||
|
||||
Reference in New Issue
Block a user