diff --git a/mellon/views.py b/mellon/views.py index 22dd16c..bac222c 100644 --- a/mellon/views.py +++ b/mellon/views.py @@ -4,6 +4,7 @@ import lasso import uuid from requests.exceptions import RequestException +from django.core.urlresolvers import reverse from django.views.generic import View from django.http import HttpResponseBadRequest, HttpResponseRedirect, HttpResponse from django.contrib import auth @@ -13,6 +14,7 @@ from django.shortcuts import render, resolve_url from django.utils.http import urlencode from django.contrib.auth import REDIRECT_FIELD_NAME from django.db import transaction +from django.utils.translation import ugettext as _ from . import app_settings, utils @@ -212,6 +214,14 @@ class LoginView(ProfileMixin, LogMixin, View): return HttpResponseRedirect(next_url) + def retry_login(self): + '''Retry login if it failed for a temporary error''' + url = reverse('mellon_login') + next_url = self.get_next_url() + if next_url: + url = '%s?%s' % (url, urlencode({REDIRECT_FIELD_NAME: next_url})) + return HttpResponseRedirect(url) + def continue_sso_artifact(self, request, method): idp_message = None status_codes = [] @@ -220,12 +230,14 @@ class LoginView(ProfileMixin, LogMixin, View): message = request.META['QUERY_STRING'] artifact = request.GET['SAMLart'] relay_state = request.GET.get('RelayState') - else: # method == lasso.HTTP_METHOD_ARTIFACT_POST: + else: # method == lasso.HTTP_METHOD_ARTIFACT_POST: message = request.POST['SAMLart'] artifact = request.POST['SAMLart'] relay_state = request.POST.get('RelayState') self.profile = login = utils.create_login(request) + if relay_state and utils.is_nonnull(relay_state): + login.msgRelayState = relay_state try: login.initRequest(message, method) except lasso.ProfileInvalidArtifactError: @@ -249,7 +261,8 @@ class LoginView(ProfileMixin, LogMixin, View): verify=verify_ssl_certificate) except RequestException, e: self.log.warning('unable to reach %r: %s', login.msgUrl, e) - return HttpResponseBadRequest('unable to reach %r: %s' % (login.msgUrl, e)) + return self.sso_failure(request, login, _('IdP is temporarily down, please try again ' + 'later.'), status_codes) if result.status_code != 200: self.log.warning('SAML authentication failed: IdP returned %s when given artifact: %r', result.status_code, result.content) @@ -258,6 +271,10 @@ class LoginView(ProfileMixin, LogMixin, View): try: login.processResponseMsg(result.content) login.acceptSso() + except lasso.ProfileMissingResponseError: + # artifact is invalid, idp returned no response + self.log.warning('ArtifactResolveResponse is empty: dead artifact %r', artifact) + return self.retry_login() except lasso.ProfileInvalidMsgError: self.log.warning('ArtifactResolveResponse is malformed %r', result.content[:200]) if settings.DEBUG: @@ -288,8 +305,6 @@ class LoginView(ProfileMixin, LogMixin, View): self.log.exception('unexpected lasso error') return HttpResponseBadRequest('error processing the authentication response: %r' % e) else: - if relay_state and utils.is_nonnull(relay_state): - login.msgRelayState = relay_state return self.sso_success(request, login) return self.sso_failure(request, login, idp_message, status_codes) diff --git a/tests/test_sso_slo.py b/tests/test_sso_slo.py index 0054947..f0db493 100644 --- a/tests/test_sso_slo.py +++ b/tests/test_sso_slo.py @@ -1,4 +1,3 @@ -import os import lasso from pytest import fixture @@ -7,6 +6,10 @@ from django.core.urlresolvers import reverse from mellon.utils import create_metadata +from httmock import all_requests, HTTMock, response as mock_response + +from utils import reset_caplog + @fixture def idp_metadata(): @@ -57,16 +60,43 @@ class MockIdp(object): try: login.validateRequestMsg(auth_result, consent) except lasso.LoginRequestDeniedError: - login.buildAuthnResponseMsg() + pass else: login.buildAssertion(lasso.SAML_AUTHENTICATION_METHOD_PASSWORD, "FIXME", "FIXME", "FIXME", "FIXME") + if login.protocolProfile == lasso.LOGIN_PROTOCOL_PROFILE_BRWS_ART: + login.buildArtifactMsg(lasso.HTTP_METHOD_ARTIFACT_GET) + self.artifact = login.artifact + self.artifact_message = login.artifactMessage + elif login.protocolProfile == lasso.LOGIN_PROTOCOL_PROFILE_BRWS_POST: login.buildAuthnResponseMsg() + else: + raise NotImplementedError return login.msgUrl, login.msgBody + def resolve_artifact(self, soap_message): + login = lasso.Login(self.server) + login.processRequestMsg(soap_message) + if hasattr(self, 'artifact') and self.artifact == login.artifact: + # artifact is known, go on ! + login.artifactMessage = self.artifact_message + # forget the artifact + del self.artifact + del self.artifact_message + login.buildResponseMsg() + return login.msgBody + + def mock_artifact_resolver(self): + @all_requests + def f(url, request): + content = self.resolve_artifact(request.body) + return mock_response(200, content=content, + headers={'Content-Type': 'application/soap+xml'}) + return f + @fixture def idp(sp_settings, idp_metadata, idp_private_key, sp_metadata): @@ -100,3 +130,44 @@ def test_sso_request_denied(db, app, idp, caplog, sp_settings): response = app.post(reverse('mellon_login'), {'SAMLResponse': body}) assert "status is not success codes: [u'urn:oasis:names:tc:SAML:2.0:status:Responder',\ u'urn:oasis:names:tc:SAML:2.0:status:RequestDenied']" in caplog.text() + + +def test_sso_artifact(db, app, caplog, sp_settings, idp_metadata, idp_private_key, rf): + sp_settings.MELLON_DEFAULT_ASSERTION_CONSUMER_BINDING = 'artifact' + 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']) + 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) + assert 'created new user' in caplog.text() + assert 'logged in using SAML' in caplog.text() + assert response['Location'].endswith(sp_settings.LOGIN_REDIRECT_URL) + # force delog + app.session.flush() + assert 'dead artifact' not in caplog.text() + with HTTMock(idp.mock_artifact_resolver()): + response = app.get(acs_artifact_url) + # 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']) + reset_caplog(caplog) + # verify caplog has been cleaned + assert 'created new user' not in caplog.text() + 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) + assert 'created new user' in caplog.text() + assert 'logged in using SAML' in caplog.text() + assert response['Location'].endswith(sp_settings.LOGIN_REDIRECT_URL) diff --git a/tests/utils.py b/tests/utils.py index 1eace51..366fe7b 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -14,3 +14,8 @@ def html_response(url, request): @all_requests def metadata_response(url, request): return response(200, content=file('tests/metadata.xml').read()) + + +def reset_caplog(cap): + cap.handler.stream.truncate(0) + cap.handler.records = []