Projet

Général

Profil

0002-views-add-new-setting-LOGIN_HINTS-fixes-30966.patch

Benjamin Dauvergne, 28 février 2019 12:06

Télécharger (8,52 ko)

Voir les différences:

Subject: [PATCH 2/2] 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  | 29 +++++++++++++++++
 4 files changed, 99 insertions(+), 13 deletions(-)
mellon/app_settings.py
38 38
        'LOGIN_URL': 'mellon_login',
39 39
        'LOGOUT_URL': 'mellon_logout',
40 40
        'ARTIFACT_RESOLVE_TIMEOUT': 10.0,
41
        'LOGIN_HINTS': [],
41 42
    }
42 43

  
43 44
    @property
mellon/utils.py
253 253
    parser.XmlDeclHandler = xmlDeclHandler
254 254
    parser.Parse(content, True)
255 255
    return xml_encoding
256

  
257

  
258
def get_local_path(request, url):
259
    if not url:
260
        return
261
    parsed = urlparse(url)
262
    path = parsed.path
263
    if request.META.get('SCRIPT_NAME'):
264
        path = path[len(request.META['SCRIPT_NAME']):]
265
    return path
mellon/views.py
4 4
import uuid
5 5
from requests.exceptions import RequestException
6 6
from xml.sax.saxutils import escape
7
import xml.etree.ElementTree as ET
8

  
7 9

  
8 10
from django.core.urlresolvers import reverse
9 11
from django.views.generic import View
......
31 33
    def lasso_decode(x):
32 34
        return x.decode('utf-8')
33 35

  
36
EO_NS = 'https://www.entrouvert.com/'
37
LOGIN_HINT = '{%s}login-hint' % EO_NS
38

  
34 39

  
35 40
class LogMixin(object):
36 41
    """Initialize a module logger in new objects"""
......
39 44
        super(LogMixin, self).__init__(*args, **kwargs)
40 45

  
41 46

  
47
def check_next_url(request, next_url):
48
    log = logging.getLogger(__name__)
49
    if not next_url:
50
        return
51
    if not utils.is_nonnull(next_url):
52
        log.warning('next parameter ignored, as it contains null characters')
53
        return
54
    try:
55
        next_url.encode('ascii')
56
    except UnicodeDecodeError:
57
        log.warning('next parameter ignored, as is\'s not an ASCII string')
58
        return
59
    if not utils.same_origin(next_url, request.build_absolute_uri()):
60
        log.warning('next parameter ignored as it is not of the same origin')
61
        return
62
    return next_url
63

  
64

  
42 65
class ProfileMixin(object):
43 66
    profile = None
44 67

  
45 68
    def set_next_url(self, next_url):
46
        if not next_url:
47
            return
48
        if not utils.is_nonnull(next_url):
49
            self.log.warning('next parameter ignored, as it contains null characters')
50
            return
51
        try:
52
            next_url.encode('ascii')
53
        except UnicodeDecodeError:
54
            self.log.warning('next parameter ignored, as is\'s not an ASCII string')
55
            return
56
        if not utils.same_origin(next_url, self.request.build_absolute_uri()):
57
            self.log.warning('next parameter ignored as it is not of the same origin')
69
        if not check_next_url(self.request, next_url):
58 70
            return
59 71
        self.set_state('next_url', next_url)
60 72

  
......
345 357
            return self.request_discovery_service(
346 358
                request, is_passive=request.GET.get('passive') == '1')
347 359

  
348
        next_url = request.GET.get(REDIRECT_FIELD_NAME)
360
        next_url = check_next_url(self.request, request.GET.get(REDIRECT_FIELD_NAME))
349 361
        idp = self.get_idp(request)
350 362
        if idp is None:
351 363
            return HttpResponseBadRequest('no idp found')
......
387 399
                            </samlp:Extensions>''' % eo_next_url
388 400
                    )
389 401
            self.set_next_url(next_url)
402
            self.add_login_hints(idp, authn_request, request=request, next_url=next_url)
390 403
            login.buildAuthnRequestMsg()
391 404
        except lasso.Error as e:
392 405
            return HttpResponseBadRequest('error initializing the authentication request: %r' % e)
......
394 407
        self.log.debug('to url %r', login.msgUrl)
395 408
        return HttpResponseRedirect(login.msgUrl)
396 409

  
410
    def add_extension_node(self, authn_request, node):
411
        '''Factorize adding an XML node to the samlp:Extensions node'''
412
        if not authn_request.extensions:
413
            authn_request.extensions = lasso.Samlp2Extensions()
414
        assert hasattr(authn_request.extensions, 'any'), 'extension nodes need lasso > 2.5.1'
415
        serialized = ET.tostring(node, 'utf-8')
416
        # tostring return bytes in PY3, but lasso needs str
417
        if six.PY3:
418
            serialized = serialized.decode('utf-8')
419
        extension_content = authn_request.extensions.any or ()
420
        extension_content += (serialized,)
421
        authn_request.extensions.any = extension_content
422

  
423
    def is_in_backoffice(self, request, next_url):
424
        path = utils.get_local_path(request, next_url)
425
        return path.startswith(('/admin/', '/manage/', '/manager/'))
426

  
427
    def add_login_hints(self, idp, authn_request, request, next_url=None):
428
        login_hints = utils.get_setting(idp, 'LOGIN_HINTS', [])
429
        hints = []
430
        for login_hint in login_hints:
431
            if login_hint == 'backoffice':
432
                if self.is_in_backoffice(request, next_url):
433
                    hints.append('backoffice')
434
            if login_hint == 'always_backoffice':
435
                hints.append('backoffice')
436

  
437
        for hint in hints:
438
            node = ET.Element(LOGIN_HINT)
439
            node.text = hint
440
            self.add_extension_node(authn_request, node)
441

  
442

  
397 443
# we need fine control of transactions to prevent double user creations
398 444
login = transaction.non_atomic_requests(csrf_exempt(LoginView.as_view()))
399 445

  
tests/test_sso_slo.py
1 1
import base64
2 2
import zlib
3
import xml.etree.ElementTree as ET
3 4

  
4 5
import lasso
5 6

  
......
10 11
from django.utils.six.moves.urllib import parse as urlparse
11 12

  
12 13
from mellon.utils import create_metadata
14
from mellon.views import LOGIN_HINT
13 15

  
14 16
from httmock import all_requests, HTTMock, response as mock_response
15 17

  
......
210 212
    assert 'created new user' in caplog.text
211 213
    assert 'logged in using SAML' in caplog.text
212 214
    assert response['Location'].endswith('/whatever/')
215

  
216

  
217
def test_sso_slo_pass_login_hints_always_backoffice(db, app, idp, caplog, sp_settings):
218
    sp_settings.MELLON_LOGIN_HINTS = ['always_backoffice']
219
    response = app.get(reverse('mellon_login') + '?next=/whatever/')
220
    url, body, relay_state = idp.process_authn_request_redirect(response['Location'])
221
    root = ET.fromstring(idp.request)
222
    login_hints = root.findall('.//{https://www.entrouvert.com/}login-hint')
223
    assert len(login_hints) == 1, 'missing login hint'
224
    assert login_hints[0].text == 'backoffice', 'login hint is not backoffice'
225

  
226

  
227
def test_sso_slo_pass_login_hints_backoffice(db, app, idp, caplog, sp_settings):
228
    sp_settings.MELLON_LOGIN_HINTS = ['backoffice']
229
    response = app.get(reverse('mellon_login') + '?next=/whatever/')
230
    url, body, relay_state = idp.process_authn_request_redirect(response['Location'])
231
    root = ET.fromstring(idp.request)
232
    login_hints = root.findall('.//{https://www.entrouvert.com/}login-hint')
233
    assert len(login_hints) == 0
234

  
235
    for next_url in ['/manage/', '/admin/', '/manager/']:
236
        response = app.get(reverse('mellon_login') + '?next=%s' % next_url)
237
        url, body, relay_state = idp.process_authn_request_redirect(response['Location'])
238
        root = ET.fromstring(idp.request)
239
        login_hints = root.findall('.//{https://www.entrouvert.com/}login-hint')
240
        assert len(login_hints) == 1, 'missing login hint'
241
        assert login_hints[0].text == 'backoffice', 'login hint is not backoffice'
213
-