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', 'LOGIN_URL': 'mellon_login',
'LOGOUT_URL': 'mellon_logout', 'LOGOUT_URL': 'mellon_logout',
'ARTIFACT_RESOLVE_TIMEOUT': 10.0, 'ARTIFACT_RESOLVE_TIMEOUT': 10.0,
'LOGIN_HINTS': [],
} }
@property @property

View File

@ -253,3 +253,13 @@ def get_xml_encoding(content):
parser.XmlDeclHandler = xmlDeclHandler parser.XmlDeclHandler = xmlDeclHandler
parser.Parse(content, True) parser.Parse(content, True)
return xml_encoding 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 import uuid
from requests.exceptions import RequestException from requests.exceptions import RequestException
from xml.sax.saxutils import escape from xml.sax.saxutils import escape
import xml.etree.ElementTree as ET
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.views.generic import View from django.views.generic import View
@ -32,6 +34,9 @@ else:
def lasso_decode(x): def lasso_decode(x):
return x.decode('utf-8') return x.decode('utf-8')
EO_NS = 'https://www.entrouvert.com/'
LOGIN_HINT = '{%s}login-hint' % EO_NS
class LogMixin(object): class LogMixin(object):
"""Initialize a module logger in new objects""" """Initialize a module logger in new objects"""
@ -40,22 +45,29 @@ class LogMixin(object):
super(LogMixin, self).__init__(*args, **kwargs) 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): class ProfileMixin(object):
profile = None profile = None
def set_next_url(self, next_url): def set_next_url(self, next_url):
if not next_url: if not check_next_url(self.request, 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')
return return
self.set_state('next_url', next_url) self.set_state('next_url', next_url)
@ -360,7 +372,7 @@ class LoginView(ProfileMixin, LogMixin, View):
return self.request_discovery_service( return self.request_discovery_service(
request, is_passive=request.GET.get('passive') == '1') 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) idp = self.get_idp(request)
if idp is None: if idp is None:
return HttpResponseBadRequest('no idp found') return HttpResponseBadRequest('no idp found')
@ -402,6 +414,7 @@ class LoginView(ProfileMixin, LogMixin, View):
</samlp:Extensions>''' % eo_next_url </samlp:Extensions>''' % eo_next_url
) )
self.set_next_url(next_url) self.set_next_url(next_url)
self.add_login_hints(idp, authn_request, request=request, next_url=next_url)
login.buildAuthnRequestMsg() login.buildAuthnRequestMsg()
except lasso.Error as e: except lasso.Error as e:
return HttpResponseBadRequest('error initializing the authentication request: %r' % 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) self.log.debug('to url %r', login.msgUrl)
return HttpResponseRedirect(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 # we need fine control of transactions to prevent double user creations
login = transaction.non_atomic_requests(csrf_exempt(LoginView.as_view())) login = transaction.non_atomic_requests(csrf_exempt(LoginView.as_view()))

View File

@ -1,6 +1,7 @@
import re import re
import base64 import base64
import zlib import zlib
import xml.etree.ElementTree as ET
import lasso 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 # check return url is in page
assert '"%s"' % sp_settings.LOGIN_REDIRECT_URL in response.text 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'