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.
This commit is contained in:
Benjamin Dauvergne 2019-02-28 09:31:34 +01:00
parent b7712516ee
commit b3e1b9c533
4 changed files with 98 additions and 13 deletions

View File

@ -38,6 +38,7 @@ class AppSettings(object):
'LOGIN_URL': 'mellon_login',
'LOGOUT_URL': 'mellon_logout',
'ARTIFACT_RESOLVE_TIMEOUT': 10.0,
'LOGIN_HINTS': [],
}
@property

View File

@ -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

View File

@ -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()))

View File

@ -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'