summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorBenjamin Dauvergne <bdauvergne@entrouvert.com>2019-02-28 08:31:34 (GMT)
committerBenjamin Dauvergne <bdauvergne@entrouvert.com>2019-03-07 22:12:56 (GMT)
commitb3e1b9c5331c15955f6c24eeed8b7013fe1b357a (patch)
treebad2388d031b0de94cc082ca08846d6336944355
parentb7712516eed466c53a88b74227154bc7c6caaede (diff)
downloaddjango-mellon-b3e1b9c5331c15955f6c24eeed8b7013fe1b357a.zip
django-mellon-b3e1b9c5331c15955f6c24eeed8b7013fe1b357a.tar.gz
django-mellon-b3e1b9c5331c15955f6c24eeed8b7013fe1b357a.tar.bz2
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.
-rw-r--r--mellon/app_settings.py1
-rw-r--r--mellon/utils.py10
-rw-r--r--mellon/views.py72
-rw-r--r--tests/test_sso_slo.py28
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):
</samlp:Extensions>''' % 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'