From 826d2648d20519ca6aefdd90385563b0614b645b Mon Sep 17 00:00:00 2001 From: Benjamin Dauvergne Date: Fri, 24 Apr 2020 13:01:17 +0200 Subject: [PATCH 5/7] misc: add support for SOAP SLO (#41949) --- mellon/templates/mellon/metadata.xml | 3 ++ mellon/views.py | 17 +++--- tests/test_sso_slo.py | 80 ++++++++++++++++++++++++++++ tests/test_utils.py | 8 +-- tests/test_views.py | 6 +-- 5 files changed, 100 insertions(+), 14 deletions(-) diff --git mellon/templates/mellon/metadata.xml mellon/templates/mellon/metadata.xml index c67283e..333f98e 100644 --- mellon/templates/mellon/metadata.xml +++ mellon/templates/mellon/metadata.xml @@ -26,6 +26,9 @@ + {% for name_id_format in name_id_formats %} {{ name_id_format }} {% endfor %} diff --git mellon/views.py mellon/views.py index 5b50bd8..179b720 100644 --- mellon/views.py +++ mellon/views.py @@ -35,7 +35,7 @@ from django.shortcuts import render, resolve_url from django.urls import reverse from django.utils.http import urlencode from django.utils import six -from django.utils.encoding import force_text +from django.utils.encoding import force_text, force_str from django.contrib.auth import REDIRECT_FIELD_NAME from django.db import transaction from django.utils.translation import ugettext as _ @@ -515,20 +515,24 @@ login = transaction.non_atomic_requests(csrf_exempt(LoginView.as_view())) class LogoutView(ProfileMixin, LogMixin, View): def get(self, request, *args, **kwargs): if 'SAMLRequest' in request.GET: - return self.idp_logout(request, request.META['QUERY_STRING']) + return self.idp_logout(request, request.META['QUERY_STRING'], 'redirect') elif 'SAMLResponse' in request.GET: return self.sp_logout_response(request) else: return self.sp_logout_request(request) - def logout(self, request, issuer, saml_user, session_indexes, indexes): + def post(self, request, *args, **kwargs): + return self.idp_logout(request, force_str(request.body), 'soap') + + def logout(self, request, issuer, saml_user, session_indexes, indexes, mode): session_keys = set(indexes.values_list('session_key', flat=True)) indexes.delete() synchronous_logout = request.user == saml_user asynchronous_logout = ( + mode == 'soap' # the current session is not the only killed - len(session_keys) != 1 + or len(session_keys) != 1 or ( # there is not current session not request.user.is_authenticated() @@ -559,7 +563,7 @@ class LogoutView(ProfileMixin, LogMixin, View): auth.logout(request) self.log.info('synchronous logout of %s', user) - def idp_logout(self, request, msg): + def idp_logout(self, request, msg, mode): '''Handle logout request emitted by the IdP''' self.profile = logout = utils.create_logout(request) try: @@ -602,7 +606,8 @@ class LogoutView(ProfileMixin, LogMixin, View): issuer=issuer, saml_user=name_id_user, session_indexes=session_indexes, - indexes=indexes) + indexes=indexes, + mode=mode) try: logout.buildResponseMsg() diff --git tests/test_sso_slo.py tests/test_sso_slo.py index 7049b3a..f49ba2d 100644 --- tests/test_sso_slo.py +++ tests/test_sso_slo.py @@ -277,6 +277,48 @@ def test_sso_idp_slo(db, app, idp, caplog, sp_settings): idp.check_slo_return(response.location) +def test_sso_idp_slo_soap(db, app, idp, caplog, sp_settings): + assert Session.objects.count() == 0 + assert User.objects.count() == 0 + + # first session + response = app.get(reverse('mellon_login') + '?next=/whatever/') + url, body, relay_state = idp.process_authn_request_redirect(response['Location']) + assert relay_state + assert 'eo:next_url' not in str(idp.request) + assert url.endswith(reverse('mellon_login')) + response = app.post(reverse('mellon_login'), params={'SAMLResponse': body, 'RelayState': relay_state}) + assert 'created new user' in caplog.text + assert 'logged in using SAML' in caplog.text + assert urlparse.urlparse(response['Location']).path == '/whatever/' + + # start a new Lasso session + idp.reset_session_dump() + + # second session + app.cookiejar.clear() + response = app.get(reverse('mellon_login') + '?next=/whatever/') + url, body, relay_state = idp.process_authn_request_redirect(response['Location']) + assert relay_state + assert 'eo:next_url' not in str(idp.request) + assert url.endswith(reverse('mellon_login')) + response = app.post(reverse('mellon_login'), params={'SAMLResponse': body, 'RelayState': relay_state}) + assert 'created new user' in caplog.text + assert 'logged in using SAML' in caplog.text + assert urlparse.urlparse(response['Location']).path == '/whatever/' + + assert Session.objects.count() == 2 + assert User.objects.count() == 1 + + # idp logout + app.cookiejar.clear() + + url, body, relay_state = idp.init_slo(method=lasso.HTTP_METHOD_SOAP) + response = app.post(url, params=body, headers={'Content-Type': force_str('text/xml')}) + assert Session.objects.count() == 1 + idp.check_slo_return(body=response.content) + + def test_sso_idp_slo_full(db, app, idp, caplog, sp_settings): assert Session.objects.count() == 0 assert User.objects.count() == 0 @@ -315,6 +357,44 @@ def test_sso_idp_slo_full(db, app, idp, caplog, sp_settings): idp.check_slo_return(url=response.location) +def test_sso_idp_slo_full_soap(db, app, idp, caplog, sp_settings): + assert Session.objects.count() == 0 + assert User.objects.count() == 0 + + # first session + response = app.get(reverse('mellon_login') + '?next=/whatever/') + url, body, relay_state = idp.process_authn_request_redirect(response['Location']) + assert relay_state + assert 'eo:next_url' not in str(idp.request) + assert url.endswith(reverse('mellon_login')) + response = app.post(reverse('mellon_login'), params={'SAMLResponse': body, 'RelayState': relay_state}) + assert 'created new user' in caplog.text + assert 'logged in using SAML' in caplog.text + assert urlparse.urlparse(response['Location']).path == '/whatever/' + + # second session + app.cookiejar.clear() + response = app.get(reverse('mellon_login') + '?next=/whatever/') + url, body, relay_state = idp.process_authn_request_redirect(response['Location']) + assert relay_state + assert 'eo:next_url' not in str(idp.request) + assert url.endswith(reverse('mellon_login')) + response = app.post(reverse('mellon_login'), params={'SAMLResponse': body, 'RelayState': relay_state}) + assert 'created new user' in caplog.text + assert 'logged in using SAML' in caplog.text + assert urlparse.urlparse(response['Location']).path == '/whatever/' + + assert Session.objects.count() == 2 + assert User.objects.count() == 1 + + # idp logout + app.cookiejar.clear() + url, body, relay_state = idp.init_slo(method=lasso.HTTP_METHOD_SOAP, full=True) + response = app.post(url, params=body, headers={'Content-Type': force_str('text/xml')}) + assert Session.objects.count() == 0 + idp.check_slo_return(body=response.content) + + def test_sso(db, app, idp, caplog, sp_settings): response = app.get(reverse('mellon_login')) url, body, relay_state = idp.process_authn_request_redirect(response['Location']) diff --git tests/test_utils.py tests/test_utils.py index 832e7ae..db47fd7 100644 --- tests/test_utils.py +++ tests/test_utils.py @@ -42,9 +42,9 @@ def test_create_metadata(rf, private_settings, caplog): ('/sm:EntityDescriptor[@entityID="http://testserver/metadata/"]', 1, ('/*', 1), ('/sm:SPSSODescriptor', 1, - ('/*', 6), + ('/*', 7), ('/sm:NameIDFormat', 1), - ('/sm:SingleLogoutService', 1), + ('/sm:SingleLogoutService', 2), ('/sm:AssertionConsumerService[@isDefault=\'true\'][@Binding=\'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact\']', 1), ('/sm:AssertionConsumerService[@isDefault=\'true\'][@Binding=\'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST\']', 0), @@ -64,11 +64,11 @@ def test_create_metadata(rf, private_settings, caplog): ('/sm:EntityDescriptor[@entityID="http://testserver/metadata/"]', 1, ('/*', 1), ('/sm:SPSSODescriptor', 1, - ('/*', 7), + ('/*', 8), ('/sm:Extensions', 1, ('/idpdisc:DiscoveryResponse', 1)), ('/sm:NameIDFormat', 1), - ('/sm:SingleLogoutService', 1), + ('/sm:SingleLogoutService', 2), ('/sm:AssertionConsumerService[@isDefault=\'true\'][@Binding=\'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact\']', 1), ('/sm:AssertionConsumerService[@isDefault=\'true\'][@Binding=\'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST\']', 0), diff --git tests/test_views.py tests/test_views.py index 7964bd9..79a27c8 100644 --- tests/test_views.py +++ tests/test_views.py @@ -20,11 +20,9 @@ import mock import lasso from django.utils.six.moves.urllib.parse import parse_qs, urlparse import base64 -import random import hashlib from httmock import HTTMock -import django from django.urls import reverse from django.utils.encoding import force_text from django.utils.http import urlencode @@ -109,9 +107,9 @@ def test_metadata(private_settings, client): ('/sm:EntityDescriptor[@entityID="http://testserver/metadata/"]', 1, ('/*', 4), ('/sm:SPSSODescriptor', 1, - ('/*', 6), + ('/*', 7), ('/sm:NameIDFormat', 1), - ('/sm:SingleLogoutService', 1), + ('/sm:SingleLogoutService', 2), ('/sm:AssertionConsumerService', None, ('[@isDefault="true"]', None, ('[@Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact"]', 1), -- 2.26.0