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())), + ]