diff --git a/mellon/views.py b/mellon/views.py index f4ee9f7..5b193f8 100644 --- a/mellon/views.py +++ b/mellon/views.py @@ -373,13 +373,19 @@ class LoginView(ProfileMixin, LogMixin, View): if utils.get_setting(idp, 'ADD_AUTHNREQUEST_NEXT_URL_EXTENSION'): authn_request.extensions = lasso.Samlp2Extensions() - authn_request.extensions.setOriginalXmlnode( + eo_next_url = escape(request.build_absolute_uri(next_url or '/')) + # lasso>2.5.1 introduced a better API + if hasattr(authn_request.extensions, 'any'): + authn_request.extensions.any = ( + '%s' % eo_next_url,) + else: + authn_request.extensions.setOriginalXmlnode( ''' - %s - ''' % - escape(request.build_absolute_uri(next_url or '/'))) + xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" + xmlns:eo="https://www.entrouvert.com/"> + %s + ''' % eo_next_url + ) self.set_next_url(next_url) login.buildAuthnRequestMsg() except lasso.Error as e: diff --git a/tests/test_sso_slo.py b/tests/test_sso_slo.py index f69131e..b85e7c4 100644 --- a/tests/test_sso_slo.py +++ b/tests/test_sso_slo.py @@ -1,9 +1,13 @@ +import base64 +import zlib + import lasso from pytest import fixture from django.core.urlresolvers import reverse from django.utils import six +from django.utils.six.moves.urllib import parse as urlparse from mellon.utils import create_metadata @@ -58,6 +62,18 @@ class MockIdp(object): def process_authn_request_redirect(self, url, auth_result=True, consent=True): login = lasso.Login(self.server) login.processAuthnRequestMsg(url.split('?', 1)[1]) + # See + # https://docs.python.org/2/library/zlib.html#zlib.decompress + # for the -15 magic value. + # + # * -8 to -15: Uses the absolute value of wbits as the window size + # logarithm. The input must be a raw stream with no header or trailer. + # + # it means Deflate instead of GZIP (same stream no header, no trailer) + self.request = zlib.decompress( + base64.b64decode( + urlparse.parse_qs( + urlparse.urlparse(url).query)['SAMLRequest'][0]), -15) try: login.validateRequestMsg(auth_result, consent) except lasso.LoginRequestDeniedError: @@ -76,7 +92,7 @@ class MockIdp(object): login.buildAuthnResponseMsg() else: raise NotImplementedError - return login.msgUrl, login.msgBody + return login.msgUrl, login.msgBody, login.msgRelayState def resolve_artifact(self, soap_message): login = lasso.Login(self.server) @@ -105,20 +121,23 @@ def idp(sp_settings, idp_metadata, idp_private_key, sp_metadata): def test_sso_slo(db, app, idp, caplog, sp_settings): - response = app.get(reverse('mellon_login')) - url, body = idp.process_authn_request_redirect(response['Location']) + 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}) + 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 response['Location'].endswith(sp_settings.LOGIN_REDIRECT_URL) + assert response['Location'].endswith('/whatever/') def test_sso(db, app, idp, caplog, sp_settings): response = app.get(reverse('mellon_login')) - url, body = idp.process_authn_request_redirect(response['Location']) + url, body, relay_state = idp.process_authn_request_redirect(response['Location']) + assert not relay_state assert url.endswith(reverse('mellon_login')) - response = app.post(reverse('mellon_login'), params={'SAMLResponse': body}) + 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 response['Location'].endswith(sp_settings.LOGIN_REDIRECT_URL) @@ -126,9 +145,10 @@ def test_sso(db, app, idp, caplog, sp_settings): def test_sso_request_denied(db, app, idp, caplog, sp_settings): response = app.get(reverse('mellon_login')) - url, body = idp.process_authn_request_redirect(response['Location'], auth_result=False) + url, body, relay_state = idp.process_authn_request_redirect(response['Location'], auth_result=False) + assert not relay_state assert url.endswith(reverse('mellon_login')) - response = app.post(reverse('mellon_login'), params={'SAMLResponse': body}) + response = app.post(reverse('mellon_login'), params={'SAMLResponse': body, 'RelayState': relay_state}) if six.PY3: assert "status is not success codes: ['urn:oasis:names:tc:SAML:2.0:status:Responder',\ 'urn:oasis:names:tc:SAML:2.0:status:RequestDenied']" in caplog.text @@ -142,28 +162,30 @@ def test_sso_artifact(db, app, caplog, sp_settings, idp_metadata, idp_private_ke request = rf.get('/') sp_metadata = create_metadata(request) idp = MockIdp(idp_metadata, idp_private_key, sp_metadata) - response = app.get(reverse('mellon_login')) - url, body = idp.process_authn_request_redirect(response['Location']) + response = app.get(reverse('mellon_login') + '?next=/whatever/') + url, body, relay_state = idp.process_authn_request_redirect(response['Location']) + assert relay_state assert body is None assert reverse('mellon_login') in url assert 'SAMLart' in url acs_artifact_url = url.split('testserver', 1)[1] with HTTMock(idp.mock_artifact_resolver()): - response = app.get(acs_artifact_url) + response = app.get(acs_artifact_url, params={'RelayState': relay_state}) assert 'created new user' in caplog.text assert 'logged in using SAML' in caplog.text - assert response['Location'].endswith(sp_settings.LOGIN_REDIRECT_URL) + assert response['Location'].endswith('/whatever/') # force delog - app.session.flush() + del app.session['_auth_user_id'] assert 'dead artifact' not in caplog.text with HTTMock(idp.mock_artifact_resolver()): - response = app.get(acs_artifact_url) + response = app.get(acs_artifact_url, params={'RelayState': relay_state}) # verify retry login was asked assert 'dead artifact' in caplog.text assert response.status_code == 302 assert reverse('mellon_login') in url response = response.follow() - url, body = idp.process_authn_request_redirect(response['Location']) + url, body, relay_state = idp.process_authn_request_redirect(response['Location']) + assert relay_state reset_caplog(caplog) # verify caplog has been cleaned assert 'created new user' not in caplog.text @@ -172,7 +194,19 @@ def test_sso_artifact(db, app, caplog, sp_settings, idp_metadata, idp_private_ke assert 'SAMLart' in url acs_artifact_url = url.split('testserver', 1)[1] with HTTMock(idp.mock_artifact_resolver()): - response = app.get(acs_artifact_url) + response = app.get(acs_artifact_url, params={'RelayState': relay_state}) assert 'created new user' in caplog.text assert 'logged in using SAML' in caplog.text - assert response['Location'].endswith(sp_settings.LOGIN_REDIRECT_URL) + assert response['Location'].endswith('/whatever/') + + +def test_sso_slo_pass_next_url(db, app, idp, caplog, sp_settings): + sp_settings.MELLON_ADD_AUTHNREQUEST_NEXT_URL_EXTENSION = True + response = app.get(reverse('mellon_login') + '?next=/whatever/') + url, body, relay_state = idp.process_authn_request_redirect(response['Location']) + assert 'eo:next_url' 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 response['Location'].endswith('/whatever/')