prevent redirection loop on artifact resolution errors (fixes #14810)

Signature of method sso_failure() is changed to match the name name of
the context variable in template mellon/authentication_failed.html
(idp_message => reason).
This commit is contained in:
Benjamin Dauvergne 2018-03-07 17:19:51 +01:00
parent 91f726ed4f
commit f2e05b84ae
4 changed files with 88 additions and 27 deletions

View File

@ -86,10 +86,5 @@ msgstr "Aucun utilisateur trouvé pour l'identifiant SAML %(name_id)s"
msgid "IdP is temporarily down, please try again later." msgid "IdP is temporarily down, please try again later."
msgstr "Le fournisseur d'identités est temporairement inaccessible, veuillez réessayer plus tard." msgstr "Le fournisseur d'identités est temporairement inaccessible, veuillez réessayer plus tard."
#~ msgid "" msgid "There were too many redirections with the identity provider."
#~ "The authentication has failed, you can return to\n" msgstr "Il y a eu trop de redirections avec le fournisseur d'identité."
#~ " the <a href=\"%(next_url)s\">last page</a> you where.\n"
#~ " "
#~ msgstr ""
#~ "L'authentification a échoué, vous pouvez retourner à <a href="
#~ "\"%(next_url)s\">la dernière page</a> atteinte."

View File

@ -12,7 +12,7 @@
<h2 class="mellon-message-header">{% trans "Authentication failed" %}</h2> <h2 class="mellon-message-header">{% trans "Authentication failed" %}</h2>
<p class="mellon-message-body"> <p class="mellon-message-body">
{% blocktrans %}The authentication has failed.{% endblocktrans %} {% blocktrans %}The authentication has failed.{% endblocktrans %}
{% if idp_message %}<p class="mellon-idp-message">{% trans "Reason" %}&nbsp;: {{ idp_message }}</p>{% endif %} {% if reason %}<p class="mellon-reason">{% trans "Reason" %}&nbsp;: {{ reason }}</p>{% endif %}
</p> </p>
<p class="mellon-message-continue"> <p class="mellon-message-continue">
<a class="mellon-link" href="{{ next_url }}">{% trans "Continue" %}</a> <a class="mellon-link" href="{{ next_url }}">{% trans "Continue" %}</a>

View File

@ -21,6 +21,7 @@ from django.utils.translation import ugettext as _
from . import app_settings, utils from . import app_settings, utils
RETRY_LOGIN_COOKIE = 'MELLON_RETRY_LOGIN'
lasso.setFlag('thin-sessions') lasso.setFlag('thin-sessions')
@ -133,24 +134,24 @@ class LoginView(ProfileMixin, LogMixin, View):
if 'RelayState' in request.POST and utils.is_nonnull(request.POST['RelayState']): if 'RelayState' in request.POST and utils.is_nonnull(request.POST['RelayState']):
login.msgRelayState = request.POST['RelayState'] login.msgRelayState = request.POST['RelayState']
return self.sso_success(request, login) return self.sso_success(request, login)
return self.sso_failure(request, login, idp_message, status_codes) return self.sso_failure(request, reason=idp_message, status_codes=status_codes)
def sso_failure(self, request, login, idp_message, status_codes): def sso_failure(self, request, reason='', status_codes=()):
'''show error message to user after a login failure''' '''show error message to user after a login failure'''
login = self.profile
idp = utils.get_idp(login.remoteProviderId) idp = utils.get_idp(login.remoteProviderId)
error_url = utils.get_setting(idp, 'ERROR_URL') error_url = utils.get_setting(idp, 'ERROR_URL')
error_redirect_after_timeout = utils.get_setting(idp, 'ERROR_REDIRECT_AFTER_TIMEOUT') error_redirect_after_timeout = utils.get_setting(idp, 'ERROR_REDIRECT_AFTER_TIMEOUT')
if error_url: if error_url:
error_url = resolve_url(error_url) error_url = resolve_url(error_url)
next_url = error_url or resolve_url(settings.LOGIN_REDIRECT_URL) next_url = error_url or self.get_next_url(default=resolve_url(settings.LOGIN_REDIRECT_URL))
return render(request, 'mellon/authentication_failed.html', return render(request, 'mellon/authentication_failed.html',
{ {
'debug': settings.DEBUG, 'debug': settings.DEBUG,
'idp_message': idp_message, 'reason': reason,
'status_codes': status_codes, 'status_codes': status_codes,
'issuer': login.remoteProviderId, 'issuer': login.remoteProviderId,
'next_url': next_url, 'next_url': next_url,
'error_url': error_url,
'relaystate': login.msgRelayState, 'relaystate': login.msgRelayState,
'error_redirect_after_timeout': error_redirect_after_timeout, 'error_redirect_after_timeout': error_redirect_after_timeout,
}) })
@ -193,7 +194,9 @@ class LoginView(ProfileMixin, LogMixin, View):
attributes['authn_context_class_ref'] = \ attributes['authn_context_class_ref'] = \
authn_context.authnContextClassRef authn_context.authnContextClassRef
self.log.debug('trying to authenticate with attributes %r', attributes) self.log.debug('trying to authenticate with attributes %r', attributes)
return self.authenticate(request, login, attributes) response = self.authenticate(request, login, attributes)
response.delete_cookie(RETRY_LOGIN_COOKIE)
return response
def authenticate(self, request, login, attributes): def authenticate(self, request, login, attributes):
user = auth.authenticate(saml_attributes=attributes) user = auth.authenticate(saml_attributes=attributes)
@ -224,12 +227,23 @@ class LoginView(ProfileMixin, LogMixin, View):
return HttpResponseRedirect(next_url) return HttpResponseRedirect(next_url)
def retry_login(self): def retry_login(self):
'''Retry login if it failed for a temporary error''' '''Retry login if it failed for a temporary error.
Use a cookie to prevent looping forever.
'''
if RETRY_LOGIN_COOKIE in self.request.COOKIES:
response = self.sso_failure(
self.request,
reason=_('There were too many redirections with the identity provider.'))
response.delete_cookie(RETRY_LOGIN_COOKIE)
return response
url = reverse('mellon_login') url = reverse('mellon_login')
next_url = self.get_next_url() next_url = self.get_next_url()
if next_url: if next_url:
url = '%s?%s' % (url, urlencode({REDIRECT_FIELD_NAME: next_url})) url = '%s?%s' % (url, urlencode({REDIRECT_FIELD_NAME: next_url}))
return HttpResponseRedirect(url) response = HttpResponseRedirect(url)
response.set_cookie(RETRY_LOGIN_COOKIE, value='1', max_age=None)
return response
def continue_sso_artifact(self, request, method): def continue_sso_artifact(self, request, method):
idp_message = None idp_message = None
@ -271,12 +285,13 @@ class LoginView(ProfileMixin, LogMixin, View):
verify=verify_ssl_certificate) verify=verify_ssl_certificate)
except RequestException as e: except RequestException as e:
self.log.warning('unable to reach %r: %s', login.msgUrl, e) self.log.warning('unable to reach %r: %s', login.msgUrl, e)
return self.sso_failure(request, login, _('IdP is temporarily down, please try again ' return self.sso_failure(request,
'later.'), status_codes) reason=_('IdP is temporarily down, please try again ' 'later.'),
status_codes=status_codes)
if result.status_code != 200: if result.status_code != 200:
self.log.warning('SAML authentication failed: IdP returned %s when given artifact: %r', self.log.warning('SAML authentication failed: IdP returned %s when given artifact: %r',
result.status_code, result.content) result.status_code, result.content)
return self.sso_failure(request, login, idp_message, status_codes) return self.sso_failure(request, reason=idp_message, status_codes=status_codes)
self.log.info('Got SAML Artifact Response', extra={'saml_response': result.content}) self.log.info('Got SAML Artifact Response', extra={'saml_response': result.content})
result.encoding = utils.get_xml_encoding(result.content) result.encoding = utils.get_xml_encoding(result.content)
@ -318,7 +333,7 @@ class LoginView(ProfileMixin, LogMixin, View):
return HttpResponseBadRequest('error processing the authentication response: %r' % e) return HttpResponseBadRequest('error processing the authentication response: %r' % e)
else: else:
return self.sso_success(request, login) return self.sso_success(request, login)
return self.sso_failure(request, login, idp_message, status_codes) return self.sso_failure(request, login, reason=idp_message, status_codes=status_codes)
def request_discovery_service(self, request, is_passive=False): def request_discovery_service(self, request, is_passive=False):
self_url = request.build_absolute_uri(request.path) self_url = request.build_absolute_uri(request.path)

View File

@ -1,3 +1,4 @@
import re
import base64 import base64
import zlib import zlib
@ -129,7 +130,7 @@ def test_sso_slo(db, app, idp, caplog, sp_settings):
response = app.post(reverse('mellon_login'), params={'SAMLResponse': body, 'RelayState': relay_state}) response = app.post(reverse('mellon_login'), params={'SAMLResponse': body, 'RelayState': relay_state})
assert 'created new user' in caplog.text assert 'created new user' in caplog.text
assert 'logged in using SAML' in caplog.text assert 'logged in using SAML' in caplog.text
assert response['Location'].endswith('/whatever/') assert urlparse.urlparse(response['Location']).path == '/whatever/'
def test_sso(db, app, idp, caplog, sp_settings): def test_sso(db, app, idp, caplog, sp_settings):
@ -140,7 +141,7 @@ def test_sso(db, app, idp, caplog, sp_settings):
response = app.post(reverse('mellon_login'), params={'SAMLResponse': body, 'RelayState': relay_state}) response = app.post(reverse('mellon_login'), params={'SAMLResponse': body, 'RelayState': relay_state})
assert 'created new user' in caplog.text assert 'created new user' in caplog.text
assert 'logged in using SAML' in caplog.text assert 'logged in using SAML' in caplog.text
assert response['Location'].endswith(sp_settings.LOGIN_REDIRECT_URL) assert urlparse.urlparse(response['Location']).path == sp_settings.LOGIN_REDIRECT_URL
def test_sso_request_denied(db, app, idp, caplog, sp_settings): def test_sso_request_denied(db, app, idp, caplog, sp_settings):
@ -173,16 +174,16 @@ def test_sso_artifact(db, app, caplog, sp_settings, idp_metadata, idp_private_ke
response = app.get(acs_artifact_url, params={'RelayState': relay_state}) response = app.get(acs_artifact_url, params={'RelayState': relay_state})
assert 'created new user' in caplog.text assert 'created new user' in caplog.text
assert 'logged in using SAML' in caplog.text assert 'logged in using SAML' in caplog.text
assert response['Location'].endswith('/whatever/') assert urlparse.urlparse(response['Location']).path == '/whatever/'
# force delog # force delog, but keep session information for relaystate handling
assert app.session
del app.session['_auth_user_id'] del app.session['_auth_user_id']
assert 'dead artifact' not in caplog.text assert 'dead artifact' not in caplog.text
with HTTMock(idp.mock_artifact_resolver()): with HTTMock(idp.mock_artifact_resolver()):
response = app.get(acs_artifact_url, params={'RelayState': relay_state}) response = app.get(acs_artifact_url, params={'RelayState': relay_state})
# verify retry login was asked # verify retry login was asked
assert 'dead artifact' in caplog.text assert 'dead artifact' in caplog.text
assert response.status_code == 302 assert urlparse.urlparse(response['Location']).path == reverse('mellon_login')
assert reverse('mellon_login') in url
response = response.follow() response = response.follow()
url, body, relay_state = idp.process_authn_request_redirect(response['Location']) url, body, relay_state = idp.process_authn_request_redirect(response['Location'])
assert relay_state assert relay_state
@ -197,7 +198,7 @@ def test_sso_artifact(db, app, caplog, sp_settings, idp_metadata, idp_private_ke
response = app.get(acs_artifact_url, params={'RelayState': relay_state}) response = app.get(acs_artifact_url, params={'RelayState': relay_state})
assert 'created new user' in caplog.text assert 'created new user' in caplog.text
assert 'logged in using SAML' in caplog.text assert 'logged in using SAML' in caplog.text
assert response['Location'].endswith('/whatever/') assert urlparse.urlparse(response['Location']).path == '/whatever/'
def test_sso_slo_pass_next_url(db, app, idp, caplog, sp_settings): def test_sso_slo_pass_next_url(db, app, idp, caplog, sp_settings):
@ -210,3 +211,53 @@ def test_sso_slo_pass_next_url(db, app, idp, caplog, sp_settings):
assert 'created new user' in caplog.text assert 'created new user' in caplog.text
assert 'logged in using SAML' in caplog.text assert 'logged in using SAML' in caplog.text
assert response['Location'].endswith('/whatever/') assert response['Location'].endswith('/whatever/')
def test_sso_artifact_no_loop(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, relay_state = 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]
# forget the artifact
idp.artifact = ''
with HTTMock(idp.mock_artifact_resolver()):
response = app.get(acs_artifact_url)
assert 'MELLON_RETRY_LOGIN=1;' in response['Set-Cookie']
# first error, we retry
assert urlparse.urlparse(response['Location']).path == reverse('mellon_login')
# check we are not logged
assert not app.session
# redo
response = app.get(reverse('mellon_login'))
url, body, relay_state = 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]
# forget the artifact
idp.artifact = ''
with HTTMock(idp.mock_artifact_resolver()):
response = app.get(acs_artifact_url)
# check cookie is deleted after failed retry
# Py3-Dj111 variation
assert re.match(r'.*MELLON_RETRY_LOGIN=("")?;', response['Set-Cookie'])
assert 'Location' not in response
# check we are still not logged
assert not app.session
# check return url is in page
assert '"%s"' % sp_settings.LOGIN_REDIRECT_URL in response.text