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:
parent
b7712516ee
commit
b3e1b9c533
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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()))
|
||||||
|
|
||||||
|
|
|
@ -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'
|
||||||
|
|
Loading…
Reference in New Issue