diff --git a/README.rst b/README.rst
index 1d86242..749c754 100755
--- a/README.rst
+++ b/README.rst
@@ -182,6 +182,73 @@ default ``users`` and ``admins,managers``
``[',', ';']`` ``users``, ``admins``, and ``managers``
=========================== =======================================
+
+Single Logout
+~~~~~~~~~~~~~
+
+`Single Logout`_ (SLO), if initiated by the Django webapp (called front-channel logout), is supported by pointing ``LOGOUT_REDIRECT_URL`` to the Shibboleth SP SLO endpoint (``/Shibboleth.sso/Logout``), if you are using the provided ShibbolethLogoutView for logout.
+
+If you want to support SLO initiated by another app or the IdP (back-channel logout), you need to enable it using the ``SINGLE_LOGOUT_BACKCHANNEL`` setting, but this feature requires additional dependencies. For more details, see the following sections.
+
+SLO is supported by Shibboleth IdP since 3.2.0 (with fixes in 3.2.1) and Shibboleth SP (version >=2.4 recommended).
+
+Additional Requirements
++++++++++++++++++++++++
+
+* lxml (tested with 4.1.0)
+* spyne (tested with 2.12.14)
+
+
+Configuration
++++++++++++++
+
+* Add shibboleth to installed apps.
+
+ .. code-block:: python
+
+ INSTALLED_APPS += (
+ 'shibboleth',
+ )
+
+* Run migrations.
+
+ .. code-block:: bash
+
+ django-admin migrate
+
+
+* Add back-channel SLO endpoint to ``urlpatterns``, if you don't already include ``shibboleth.urls``.
+
+ .. code-block:: python
+
+ if SINGLE_LOGOUT_BACKCHANNEL:
+ from spyne.protocol.soap import Soap11
+ from spyne.server.django import DjangoView
+ from .slo_view import LogoutNotificationService
+
+ urlpatterns += [
+ url(r'^logoutNotification/', DjangoView.as_view(
+ services=[LogoutNotificationService],
+ tns='urn:mace:shibboleth:2.0:sp:notify',
+ in_protocol=Soap11(validator='lxml'), out_protocol=Soap11())),
+ ]
+
+* Enable SLO in ``shibboleth2.xml`` of Shibboleth SP.
+
+ .. code-block:: xml
+
+ SAML2 Local
+
+* Configure SLO notification in ``shibboleth2.xml`` of Shibboleth SP.
+
+ .. code-block:: xml
+
+
+
+
.. |build-status| image:: https://travis-ci.org/Brown-University-Library/django-shibboleth-remoteuser.svg?branch=master&style=flat
:target: https://travis-ci.org/Brown-University-Library/django-shibboleth-remoteuser
:alt: Build status
+.. _`Single Logout`: https://wiki.shibboleth.net/confluence/display/SHIB2/SLOWebappAdaptation
diff --git a/quicktest.py b/quicktest.py
index e34d0a1..802c34b 100755
--- a/quicktest.py
+++ b/quicktest.py
@@ -26,6 +26,7 @@ class QuickDjangoTest(object):
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
+ 'shibboleth',
)
def __init__(self, *args, **kwargs):
diff --git a/shibboleth/app_settings.py b/shibboleth/app_settings.py
index 8809e4d..5de5049 100755
--- a/shibboleth/app_settings.py
+++ b/shibboleth/app_settings.py
@@ -30,3 +30,6 @@
#LOGOUT_REDIRECT_URL specifies a default logout page that will always be used when
#users logout from Shibboleth.
LOGOUT_REDIRECT_URL = getattr(settings, 'SHIBBOLETH_LOGOUT_REDIRECT_URL', None)
+
+# back-channel SLO
+SINGLE_LOGOUT_BACKCHANNEL = getattr(settings, 'SHIBBOLETH_SINGLE_LOGOUT_BACKCHANNEL', False)
diff --git a/shibboleth/middleware.py b/shibboleth/middleware.py
index ced4892..eafcbdc 100755
--- a/shibboleth/middleware.py
+++ b/shibboleth/middleware.py
@@ -2,9 +2,11 @@
from django.contrib.auth.models import Group
from django.contrib import auth
from django.core.exceptions import ImproperlyConfigured
+import django.utils.version
import re
-from shibboleth.app_settings import SHIB_ATTRIBUTE_MAP, GROUP_ATTRIBUTES, GROUP_DELIMITERS
+from shibboleth.app_settings import SHIB_ATTRIBUTE_MAP, GROUP_ATTRIBUTES, GROUP_DELIMITERS, SINGLE_LOGOUT_BACKCHANNEL
+from shibboleth.models import ShibSession
class ShibbolethRemoteUserMiddleware(RemoteUserMiddleware):
@@ -56,8 +58,23 @@ def process_request(self, request):
# User is valid. Set request.user and persist user in the session
# by logging the user in.
request.user = user
+
+ if SINGLE_LOGOUT_BACKCHANNEL:
+ # workaround for bug in Django < 1.10
+ # fixed in Django commit 3389c5ea2
+ if request.session.session_key is None:
+ django_version = django.utils.version.get_complete_version()
+ if django_version[0] == 1 and django_version[1] < 10:
+ request.session.create()
+
auth.login(request, user)
-
+
+ if SINGLE_LOGOUT_BACKCHANNEL:
+ # store session mapping
+ ShibSession.objects.get_or_create(
+ shib=request.META['Shib_Session_ID'],
+ session_id=request.session.session_key)
+
# Upgrade user groups if configured in the settings.py
# If activated, the user will be associated with those groups.
if GROUP_ATTRIBUTES:
diff --git a/shibboleth/models.py b/shibboleth/models.py
index e482fb3..20e8fb9 100755
--- a/shibboleth/models.py
+++ b/shibboleth/models.py
@@ -1 +1,7 @@
-#intentionally left blank
+from django.db import models
+from django.contrib.sessions.models import Session
+
+
+class ShibSession(models.Model):
+ shib = models.CharField(max_length=100, primary_key=True)
+ session = models.ForeignKey(Session, on_delete=models.CASCADE)
diff --git a/shibboleth/slo_view.py b/shibboleth/slo_view.py
new file mode 100644
index 0000000..5b7cc47
--- /dev/null
+++ b/shibboleth/slo_view.py
@@ -0,0 +1,52 @@
+from django.contrib.sessions.models import Session
+from shibboleth.models import ShibSession
+#SLO (back-channel) / spyne stuff
+from spyne.model.primitive import Unicode
+from spyne.model import XmlAttribute
+from spyne.model.enum import Enum
+try:
+ from spyne.service import Service
+except ImportError:
+ from spyne.service import ServiceBase as Service
+
+from spyne.decorator import rpc
+from spyne import ComplexModel
+from spyne.model.fault import Fault
+
+
+class OKType(ComplexModel):
+ pass
+
+
+class MandatoryUnicode(Unicode):
+ class Attributes(Unicode.Attributes):
+ nullable = False
+ min_occurs = 1
+
+
+class LogoutRequest(ComplexModel):
+ __namespace__ = 'urn:mace:shibboleth:2.0:sp:notify'
+ SessionID = MandatoryUnicode
+ type = XmlAttribute(Enum("global", "local",
+ type_name="LogoutNotificationType"))
+
+
+class LogoutResponse(ComplexModel):
+ __namespace__ = 'urn:mace:shibboleth:2.0:sp:notify'
+ OK = OKType
+
+
+class LogoutNotificationService(Service):
+ @rpc(LogoutRequest, _returns=LogoutResponse, _body_style='bare')
+ def LogoutNotification(ctx, req):
+ # delete user session based on shib session
+ try:
+ session_mapping = ShibSession.objects.get(shib=req.SessionID)
+ except:
+ # Can't delete session
+ raise Fault(faultcode='Client', faultstring='Invalid session id')
+ else:
+ # Deleting session
+ Session.objects.filter(
+ session_key=session_mapping.session_id).delete()
+ return LogoutResponse
diff --git a/shibboleth/tests/test_shib.py b/shibboleth/tests/test_shib.py
index 0cb9448..2e3af5a 100755
--- a/shibboleth/tests/test_shib.py
+++ b/shibboleth/tests/test_shib.py
@@ -7,7 +7,7 @@
from django.contrib.auth.middleware import RemoteUserMiddleware
from django.contrib.auth.models import User, Group
from django.contrib.sessions.middleware import SessionMiddleware
-from django.test import TestCase, RequestFactory
+from django.test import TestCase, RequestFactory, Client
SAMPLE_HEADERS = {
@@ -17,6 +17,7 @@
"Shib-AuthnContext-Class": "urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified",
"Shib-Identity-Provider": "https://sso.college.edu/idp/shibboleth",
"Shib-Session-ID": "1",
+ "Shib_Session_ID": "1",
"Shib-Session-Index": "12",
"Shibboleth-affiliation": "member@college.edu;staff@college.edu",
"Shibboleth-schoolBarCode": "12345678",
@@ -60,6 +61,7 @@
settings.SHIBBOLETH_LOGOUT_URL = 'https://sso.school.edu/logout?next=%s'
settings.SHIBBOLETH_LOGOUT_REDIRECT_URL = 'http://school.edu/'
+settings.SHIBBOLETH_SINGLE_LOGOUT_BACKCHANNEL = True
# MUST be imported after the settings above
from shibboleth import app_settings
@@ -277,3 +279,42 @@ def test_logout(self):
self.assertEqual(resp.status_code, 302)
# Make sure the context is empty.
self.assertEqual(resp.context, None)
+
+
+class SingleLogoutBackchannelTest(TestCase):
+# separate client for post request; check logout
+ logout_req = """
+
+
+
+ %s
+
+
+
+"""
+
+ def test_logout(self):
+ idp = Client()
+ # Login
+ login = self.client.get('/', **SAMPLE_HEADERS)
+ self.assertEqual(login.status_code, 200)
+ # back-channel logout
+ soap_resp = idp.post('/logoutNotification/', data=(self.logout_req % 1), content_type='text/xml;charset=UTF-8')
+ self.assertContains(soap_resp, "", status_code=200)
+ # Load root url to see if user is in fact logged out.
+ resp = self.client.get('/')
+ self.assertEqual(resp.status_code, 302)
+ # Make sure the context is empty.
+ self.assertEqual(resp.context, None)
+
+ def test_invalid_logout(self):
+ idp = Client()
+ # Login
+ login = self.client.get('/', **SAMPLE_HEADERS)
+ self.assertEqual(login.status_code, 200)
+ # invalid back-channel logout (raises Exception and returns Internal Server Error!)
+ soap_resp = idp.post('/logoutNotification/', data=(self.logout_req % 'NonExistentSession'), content_type='text/xml;charset=UTF-8')
+ self.assertContains(soap_resp, "Invalid session id", status_code=500)
+ # Load root url to see if user is still logged in
+ resp = self.client.get('/')
+ self.assertEqual(resp.status_code, 200)
diff --git a/shibboleth/urls.py b/shibboleth/urls.py
index b01ec96..25e10db 100755
--- a/shibboleth/urls.py
+++ b/shibboleth/urls.py
@@ -1,5 +1,6 @@
import django
from django.conf.urls import url
+from shibboleth.app_settings import SINGLE_LOGOUT_BACKCHANNEL
from .views import ShibbolethView, ShibbolethLogoutView, ShibbolethLoginView
@@ -8,3 +9,15 @@
url(r'^logout/$', ShibbolethLogoutView.as_view(), name='logout'),
url(r'^$', ShibbolethView.as_view(), name='info'),
]
+
+if SINGLE_LOGOUT_BACKCHANNEL:
+ from spyne.protocol.soap import Soap11
+ from spyne.server.django import DjangoView
+ from .slo_view import LogoutNotificationService
+
+ urlpatterns += [
+ url(r'^logoutNotification/', DjangoView.as_view(
+ services=[LogoutNotificationService],
+ tns='urn:mace:shibboleth:2.0:sp:notify',
+ in_protocol=Soap11(validator='lxml'), out_protocol=Soap11())),
+ ]