From b3e1b9c5331c15955f6c24eeed8b7013fe1b357a Mon Sep 17 00:00:00 2001 From: Benjamin Dauvergne Date: Thu, 28 Feb 2019 09:31:34 +0100 Subject: [PATCH] views: add new setting LOGIN_HINTS (fixes #30966) You can set MELLON_LOGIN_HINTS = ['backoffice'] to get a node eo:login-hint set to "backoffice" in AuthnRequest when next_url for the login view is among /manage/, /admin/ or /manager/. Another value is 'always_backoffice' which always set the 'backoffice' login_hint. --- mellon/app_settings.py | 1 + mellon/utils.py | 10 ++++++ mellon/views.py | 72 ++++++++++++++++++++++++++++++++++-------- tests/test_sso_slo.py | 28 ++++++++++++++++ 4 files changed, 98 insertions(+), 13 deletions(-) diff --git a/mellon/app_settings.py b/mellon/app_settings.py index 2355e8a..fe1a566 100644 --- a/mellon/app_settings.py +++ b/mellon/app_settings.py @@ -38,6 +38,7 @@ class AppSettings(object): 'LOGIN_URL': 'mellon_login', 'LOGOUT_URL': 'mellon_logout', 'ARTIFACT_RESOLVE_TIMEOUT': 10.0, + 'LOGIN_HINTS': [], } @property diff --git a/mellon/utils.py b/mellon/utils.py index 2372f96..73ee34f 100644 --- a/mellon/utils.py +++ b/mellon/utils.py @@ -253,3 +253,13 @@ def get_xml_encoding(content): parser.XmlDeclHandler = xmlDeclHandler parser.Parse(content, True) return xml_encoding + + +def get_local_path(request, url): + if not url: + return + parsed = urlparse(url) + path = parsed.path + if request.META.get('SCRIPT_NAME'): + path = path[len(request.META['SCRIPT_NAME']):] + return path diff --git a/mellon/views.py b/mellon/views.py index a360977..54040ef 100644 --- a/mellon/views.py +++ b/mellon/views.py @@ -4,6 +4,8 @@ import lasso import uuid from requests.exceptions import RequestException from xml.sax.saxutils import escape +import xml.etree.ElementTree as ET + from django.core.urlresolvers import reverse from django.views.generic import View @@ -32,6 +34,9 @@ else: def lasso_decode(x): return x.decode('utf-8') +EO_NS = 'https://www.entrouvert.com/' +LOGIN_HINT = '{%s}login-hint' % EO_NS + class LogMixin(object): """Initialize a module logger in new objects""" @@ -40,22 +45,29 @@ class LogMixin(object): super(LogMixin, self).__init__(*args, **kwargs) +def check_next_url(request, next_url): + log = logging.getLogger(__name__) + if not next_url: + return + if not utils.is_nonnull(next_url): + log.warning('next parameter ignored, as it contains null characters') + return + try: + next_url.encode('ascii') + except UnicodeDecodeError: + log.warning('next parameter ignored, as is\'s not an ASCII string') + return + if not utils.same_origin(next_url, request.build_absolute_uri()): + log.warning('next parameter ignored as it is not of the same origin') + return + return next_url + + class ProfileMixin(object): profile = None def set_next_url(self, next_url): - if not next_url: - return - if not utils.is_nonnull(next_url): - self.log.warning('next parameter ignored, as it contains null characters') - return - try: - next_url.encode('ascii') - except UnicodeDecodeError: - self.log.warning('next parameter ignored, as is\'s not an ASCII string') - return - if not utils.same_origin(next_url, self.request.build_absolute_uri()): - self.log.warning('next parameter ignored as it is not of the same origin') + if not check_next_url(self.request, next_url): return self.set_state('next_url', next_url) @@ -360,7 +372,7 @@ class LoginView(ProfileMixin, LogMixin, View): return self.request_discovery_service( request, is_passive=request.GET.get('passive') == '1') - next_url = request.GET.get(REDIRECT_FIELD_NAME) + next_url = check_next_url(self.request, request.GET.get(REDIRECT_FIELD_NAME)) idp = self.get_idp(request) if idp is None: return HttpResponseBadRequest('no idp found') @@ -402,6 +414,7 @@ class LoginView(ProfileMixin, LogMixin, View): ''' % eo_next_url ) self.set_next_url(next_url) + self.add_login_hints(idp, authn_request, request=request, next_url=next_url) login.buildAuthnRequestMsg() except lasso.Error as e: return HttpResponseBadRequest('error initializing the authentication request: %r' % e) @@ -409,6 +422,39 @@ class LoginView(ProfileMixin, LogMixin, View): self.log.debug('to url %r', login.msgUrl) return HttpResponseRedirect(login.msgUrl) + def add_extension_node(self, authn_request, node): + '''Factorize adding an XML node to the samlp:Extensions node''' + if not authn_request.extensions: + authn_request.extensions = lasso.Samlp2Extensions() + assert hasattr(authn_request.extensions, 'any'), 'extension nodes need lasso > 2.5.1' + serialized = ET.tostring(node, 'utf-8') + # tostring return bytes in PY3, but lasso needs str + if six.PY3: + serialized = serialized.decode('utf-8') + extension_content = authn_request.extensions.any or () + extension_content += (serialized,) + authn_request.extensions.any = extension_content + + def is_in_backoffice(self, request, next_url): + path = utils.get_local_path(request, next_url) + return path.startswith(('/admin/', '/manage/', '/manager/')) + + def add_login_hints(self, idp, authn_request, request, next_url=None): + login_hints = utils.get_setting(idp, 'LOGIN_HINTS', []) + hints = [] + for login_hint in login_hints: + if login_hint == 'backoffice': + if self.is_in_backoffice(request, next_url): + hints.append('backoffice') + if login_hint == 'always_backoffice': + hints.append('backoffice') + + for hint in hints: + node = ET.Element(LOGIN_HINT) + node.text = hint + self.add_extension_node(authn_request, node) + + # we need fine control of transactions to prevent double user creations login = transaction.non_atomic_requests(csrf_exempt(LoginView.as_view())) diff --git a/tests/test_sso_slo.py b/tests/test_sso_slo.py index 8a4e95c..cd5a795 100644 --- a/tests/test_sso_slo.py +++ b/tests/test_sso_slo.py @@ -1,6 +1,7 @@ import re import base64 import zlib +import xml.etree.ElementTree as ET import lasso @@ -261,3 +262,30 @@ def test_sso_artifact_no_loop(db, app, caplog, sp_settings, idp_metadata, idp_pr # check return url is in page assert '"%s"' % sp_settings.LOGIN_REDIRECT_URL in response.text + + +def test_sso_slo_pass_login_hints_always_backoffice(db, app, idp, caplog, sp_settings): + sp_settings.MELLON_LOGIN_HINTS = ['always_backoffice'] + response = app.get(reverse('mellon_login') + '?next=/whatever/') + url, body, relay_state = idp.process_authn_request_redirect(response['Location']) + root = ET.fromstring(idp.request) + login_hints = root.findall('.//{https://www.entrouvert.com/}login-hint') + assert len(login_hints) == 1, 'missing login hint' + assert login_hints[0].text == 'backoffice', 'login hint is not backoffice' + + +def test_sso_slo_pass_login_hints_backoffice(db, app, idp, caplog, sp_settings): + sp_settings.MELLON_LOGIN_HINTS = ['backoffice'] + response = app.get(reverse('mellon_login') + '?next=/whatever/') + url, body, relay_state = idp.process_authn_request_redirect(response['Location']) + root = ET.fromstring(idp.request) + login_hints = root.findall('.//{https://www.entrouvert.com/}login-hint') + assert len(login_hints) == 0 + + for next_url in ['/manage/', '/admin/', '/manager/']: + response = app.get(reverse('mellon_login') + '?next=%s' % next_url) + url, body, relay_state = idp.process_authn_request_redirect(response['Location']) + root = ET.fromstring(idp.request) + login_hints = root.findall('.//{https://www.entrouvert.com/}login-hint') + assert len(login_hints) == 1, 'missing login hint' + assert login_hints[0].text == 'backoffice', 'login hint is not backoffice'