Project

General

Profile

0004-misc-support-asynchronous-logout-41949.patch

Benjamin Dauvergne, 24 Apr 2020 01:22 PM

Download (19.7 KB)

View differences:

Subject: [PATCH 4/7] misc: support asynchronous logout (#41949)

It means that will lookup for other Django sessions linked to the
received logout request; logout request can specify session indexes or
ask for logout of all sessions of the user targeted by the NameID.
 mellon/templates/mellon/session_dump.xml |  14 +--
 mellon/utils.py                          |  42 ++++----
 mellon/views.py                          | 109 +++++++++++++++++---
 tests/test_sso_slo.py                    | 122 ++++++++++++++++++++++-
 4 files changed, 246 insertions(+), 41 deletions(-)
mellon/templates/mellon/session_dump.xml
1 1
<ns0:Session xmlns:ns0="http://www.entrouvert.org/namespaces/lasso/0.0" xmlns:ns1="urn:oasis:names:tc:SAML:2.0:assertion" Version="2">
2
    {% for session_info in session_infos %}
2 3
    <ns0:NidAndSessionIndex AssertionID="" 
3
                            ProviderID="{{ entity_id }}"
4
                            SessionIndex="{{ session_index }}">
5
            <ns1:NameID Format="{{ name_id_format }}"
6
                    {% if name_id_name_qualifier %}NameQualifier="{{ name_id_name_qualifier }}"{% endif %}
7
                    {% if name_id_sp_name_qualifier %}SPNameQualifier="{{ name_id_sp_name_qualifier }}"{% endif %}
8
                    >{{ name_id_content }}</ns1:NameID>
4
                            ProviderID="{{ session_info.entity_id }}"
5
                            SessionIndex="{{ session_info.session_index }}">
6
            <ns1:NameID Format="{{ session_info.name_id_format }}"
7
                    {% if session_info.name_id_name_qualifier %}NameQualifier="{{ session_info.name_id_name_qualifier }}"{% endif %}
8
                    {% if session_info.name_id_sp_name_qualifier %}SPNameQualifier="{{ session_info.name_id_sp_name_qualifier }}"{% endif %}
9
                    >{{ session_info.name_id_content }}</ns1:NameID>
9 10
    </ns0:NidAndSessionIndex>
11
    {% endfor %}
10 12
</ns0:Session>
mellon/utils.py
22 22
import isodate
23 23
from xml.parsers import expat
24 24

  
25
import django
26 25
from django.contrib import auth
27 26
from django.template.loader import render_to_string
28 27
from django.urls import reverse
28
from django.utils.encoding import force_text
29 29
from django.utils.timezone import make_aware, now, make_naive, is_aware, get_default_timezone
30 30
from django.conf import settings
31 31
from django.utils.six.moves.urllib.parse import urlparse
......
33 33

  
34 34
from . import app_settings
35 35

  
36
logger = logging.getLogger(__name__)
36 37

  
37 38
def create_metadata(request):
38 39
    entity_id = reverse('mellon_metadata')
......
64 65

  
65 66

  
66 67
def create_server(request):
67
    logger = logging.getLogger(__name__)
68 68
    root = request.build_absolute_uri('/')
69 69
    cache = getattr(settings, '_MELLON_SERVER_CACHE', {})
70 70
    if root not in cache:
......
203 203
    return idp.get(name) or getattr(app_settings, name, default)
204 204

  
205 205

  
206
def make_session_dump(lasso_name_id, indexes):
207
    session_infos = []
208
    name_id = force_text(lasso_name_id.content)
209
    name_id_format = force_text(lasso_name_id.format)
210
    name_qualifier = lasso_name_id.nameQualifier and force_text(lasso_name_id.nameQualifier)
211
    sp_name_qualifier = lasso_name_id.spNameQualifier and force_text(lasso_name_id.spNameQualifier)
212
    for index in indexes:
213
        issuer = index.saml_identifier.issuer
214
        session_infos.append({
215
            'entity_id': issuer,
216
            'session_index': index.session_index,
217
            'name_id_content': name_id,
218
            'name_id_format': name_id_format,
219
            'name_id_name_qualifier': name_qualifier,
220
            'name_id_sp_name_qualifier': sp_name_qualifier,
221
        })
222
    session_dump = render_to_string('mellon/session_dump.xml', {'session_infos': session_infos})
223
    return session_dump
224

  
225

  
206 226
def create_logout(request):
207
    logger = logging.getLogger(__name__)
208 227
    server = create_server(request)
209
    mellon_session = request.session.get('mellon_session', {})
210
    entity_id = mellon_session.get('issuer')
211
    session_index = mellon_session.get('session_index')
212
    name_id_format = mellon_session.get('name_id_format')
213
    name_id_content = mellon_session.get('name_id_content')
214
    name_id_name_qualifier = mellon_session.get('name_id_name_qualifier')
215
    name_id_sp_name_qualifier = mellon_session.get('name_id_sp_name_qualifier')
216
    session_dump = render_to_string('mellon/session_dump.xml', {
217
        'entity_id': entity_id,
218
        'session_index': session_index,
219
        'name_id_format': name_id_format,
220
        'name_id_content': name_id_content,
221
        'name_id_name_qualifier': name_id_name_qualifier,
222
        'name_id_sp_name_qualifier': name_id_sp_name_qualifier,
223
    })
224
    logger.debug('session_dump %s', session_dump)
225 228
    logout = lasso.Logout(server)
226 229
    if not app_settings.PRIVATE_KEY and not app_settings.PRIVATE_KEYS:
227 230
        logout.setSignatureHint(lasso.PROFILE_SIGNATURE_HINT_FORBID)
228
    logout.setSessionFromDump(session_dump)
229 231
    return logout
230 232

  
231 233

  
mellon/views.py
15 15

  
16 16
from __future__ import unicode_literals
17 17

  
18
from importlib import import_module
18 19
import logging
19 20
import requests
20 21
import lasso
......
27 28
from django.views.generic import View
28 29
from django.http import HttpResponseRedirect, HttpResponse
29 30
from django.contrib import auth
31
from django.contrib.auth import get_user_model
30 32
from django.conf import settings
31 33
from django.views.decorators.csrf import csrf_exempt
32 34
from django.shortcuts import render, resolve_url
......
38 40
from django.db import transaction
39 41
from django.utils.translation import ugettext as _
40 42

  
41
from . import app_settings, utils
43
from . import app_settings, utils, models
42 44

  
43 45

  
44 46
RETRY_LOGIN_COOKIE = 'MELLON_RETRY_LOGIN'
......
55 57
EO_NS = 'https://www.entrouvert.com/'
56 58
LOGIN_HINT = '{%s}login-hint' % EO_NS
57 59

  
60
User = get_user_model()
61

  
58 62

  
59 63
class HttpResponseBadRequest(django.http.HttpResponseBadRequest):
60 64
    def __init__(self, *args, **kwargs):
......
256 260
        if user is not None:
257 261
            if user.is_active:
258 262
                utils.login(request, user)
263
                session_index = attributes['session_index']
264
                if session_index:
265
                    models.SessionIndex.objects.get_or_create(
266
                        saml_identifier=user.saml_identifier,
267
                        session_key=request.session.session_key,
268
                        session_index=session_index)
259 269
                self.log.info('user %s (NameID is %r) logged in using SAML', user,
260 270
                              attributes['name_id_content'])
261 271
                request.session['mellon_session'] = utils.flatten_datetime(attributes)
......
505 515
class LogoutView(ProfileMixin, LogMixin, View):
506 516
    def get(self, request, *args, **kwargs):
507 517
        if 'SAMLRequest' in request.GET:
508
            return self.idp_logout(request)
518
            return self.idp_logout(request, request.META['QUERY_STRING'])
509 519
        elif 'SAMLResponse' in request.GET:
510 520
            return self.sp_logout_response(request)
511 521
        else:
512 522
            return self.sp_logout_request(request)
513 523

  
514
    def idp_logout(self, request):
524
    def logout(self, request, issuer, saml_user, session_indexes, indexes):
525
        session_keys = set(indexes.values_list('session_key', flat=True))
526
        indexes.delete()
527

  
528
        synchronous_logout = request.user == saml_user
529
        asynchronous_logout = (
530
            # the current session is not the only killed
531
            len(session_keys) != 1
532
            or (
533
                # there is not current session
534
                not request.user.is_authenticated()
535
                # or the current session is not part of the list
536
                or request.session.session_key not in session_keys))
537

  
538
        if asynchronous_logout:
539
            current_session_key = request.session.session_key if request.user.is_authenticated() else None
540

  
541
            session_engine = import_module(settings.SESSION_ENGINE)
542
            store = session_engine.SessionStore()
543

  
544
            count = 0
545
            for session_key in session_keys:
546
                if session_key != current_session_key:
547
                    try:
548
                        store.delete(session_key)
549
                        count += 1
550
                    except Exception:
551
                        self.log.warning('could not delete session_key %s', session_key, exc_info=True)
552
            if not session_indexes:
553
                self.log.info('asynchronous logout of all sessions of user %s', saml_user)
554
            elif count:
555
                self.log.info('asynchronous logout of %d sessions of user %s', len(session_keys), saml_user)
556

  
557
        if synchronous_logout:
558
            user = request.user
559
            auth.logout(request)
560
            self.log.info('synchronous logout of %s', user)
561

  
562
    def idp_logout(self, request, msg):
515 563
        '''Handle logout request emitted by the IdP'''
516 564
        self.profile = logout = utils.create_logout(request)
517 565
        try:
518
            logout.processRequestMsg(request.META['QUERY_STRING'])
566
            logout.processRequestMsg(msg)
519 567
        except lasso.Error as e:
520 568
            return HttpResponseBadRequest('error processing logout request: %r' % e)
521
        try:
522
            logout.validateRequest()
523
        except lasso.Error as e:
524
            self.log.warning('error validating logout request: %r' % e)
525
        issuer = request.session.get('mellon_session', {}).get('issuer')
526
        if issuer == logout.remoteProviderId:
527
            self.log.info('user logged out by IdP SLO request')
528
            auth.logout(request)
569

  
570
        issuer = force_text(logout.remoteProviderId)
571
        session_indexes = set(force_text(sessionIndex) for sessionIndex in logout.request.sessionIndexes)
572

  
573
        saml_identifier = models.UserSAMLIdentifier.objects.filter(
574
            name_id=force_text(logout.nameIdentifier.content),
575
            issuer=issuer).select_related('user').first()
576

  
577
        if saml_identifier:
578
            name_id_user = saml_identifier.user
579
            indexes = models.SessionIndex.objects.select_related(
580
                'saml_identifier').filter(
581
                    saml_identifier=saml_identifier)
582
            if session_indexes:
583
                indexes = indexes.filter(session_index__in=session_indexes)
584

  
585
            # lasso has too much state :/
586
            logout.setSessionFromDump(
587
                utils.make_session_dump(
588
                    logout.nameIdentifier,
589
                    indexes))
590

  
591
            try:
592
                logout.validateRequest()
593
            except lasso.Error as e:
594
                self.log.warning('error validating logout request: %r' % e)
595
            else:
596
                if session_indexes:
597
                    self.log.info('logout requested for sessionIndexes %s', session_indexes)
598
                else:
599
                    self.log.info('full logout requested, no sessionIndexes')
600
                self.logout(
601
                    request,
602
                    issuer=issuer,
603
                    saml_user=name_id_user,
604
                    session_indexes=session_indexes,
605
                    indexes=indexes)
606

  
529 607
        try:
530 608
            logout.buildResponseMsg()
531 609
        except lasso.Error as e:
532 610
            return HttpResponseBadRequest('error processing logout request: %r' % e)
533
        return HttpResponseRedirect(logout.msgUrl)
611
        if logout.msgBody:
612
            return HttpResponse(force_text(logout.msgBody), content_type='text/xml')
613
        else:
614
            return HttpResponseRedirect(logout.msgUrl)
534 615

  
535 616
    def sp_logout_request(self, request):
536 617
        '''Launch a logout request to the identity provider'''
......
586 667
        return HttpResponseRedirect(next_url)
587 668

  
588 669

  
589
logout = LogoutView.as_view()
670
logout = csrf_exempt(LogoutView.as_view())
590 671

  
591 672

  
592 673
def metadata(request, **kwargs):
tests/test_sso_slo.py
26 26
import pytest
27 27
from pytest import fixture
28 28

  
29
from django.contrib.sessions.models import Session
30
from django.contrib.auth.models import User
29 31
from django.urls import reverse
30 32
from django.utils import six
31 33
from django.utils.six.moves.urllib import parse as urlparse
......
78 80

  
79 81

  
80 82
class MockIdp(object):
83
    session_dump = None
84
    identity_dump = None
85

  
81 86
    def __init__(self, idp_metadata, private_key, sp_metadata):
82 87
        self.server = server = lasso.Server.newFromBuffers(idp_metadata, private_key)
83 88
        self.server.signatureMethod = lasso.SIGNATURE_METHOD_RSA_SHA256
84 89
        server.addProviderFromBuffer(lasso.PROVIDER_ROLE_SP, sp_metadata)
85 90

  
91
    def reset_session_dump(self):
92
        self.session_dump = None
93

  
86 94
    def process_authn_request_redirect(self, url, auth_result=True, consent=True, msg=None):
87 95
        login = lasso.Login(self.server)
96
        if self.identity_dump:
97
            login.setIdentityFromDump(self.identity_dump)
98
        if self.session_dump:
99
            login.setSessionFromDump(self.session_dump)
88 100
        login.processAuthnRequestMsg(url.split('?', 1)[1])
89 101
        # See
90 102
        # https://docs.python.org/2/library/zlib.html#zlib.decompress
......
151 163
            raise NotImplementedError
152 164
        if login.msgBody:
153 165
            assert b'rsa-sha256' in base64.b64decode(login.msgBody)
166
        if login.identity:
167
            self.identity_dump = login.identity.dump()
168
        else:
169
            self.identity_dump = None
170
        if login.session:
171
            self.session_dump = login.session.dump()
172
        else:
173
            self.session_dump = None
154 174
        return login.msgUrl, login.msgBody, login.msgRelayState
155 175

  
156 176
    def resolve_artifact(self, soap_message):
......
167 187
        assert 'rsa-sha256' in login.msgBody
168 188
        return '<?xml version="1.0"?>\n' + login.msgBody
169 189

  
190
    def init_slo(self, full=False, method=lasso.HTTP_METHOD_REDIRECT, relay_state=None):
191
        logout = lasso.Logout(self.server)
192
        logout.setIdentityFromDump(self.identity_dump)
193
        logout.setSessionFromDump(self.session_dump)
194
        logout.initRequest(None, method)
195
        logout.msgRelayState = relay_state
196
        if full:
197
            logout.request.sessionIndexes = ()
198
            logout.request.sessionIndex = None
199
        logout.buildRequestMsg()
200
        return logout.msgUrl, logout.msgBody, logout.msgRelayState
201

  
202
    def check_slo_return(self, url=None, body=None):
203
        logout = lasso.Logout(self.server)
204
        logout.setIdentityFromDump(self.identity_dump)
205
        logout.setSessionFromDump(self.session_dump)
206
        if body:
207
            logout.processResponseMsg(force_str(body))
208
        else:
209
            logout.processResponseMsg(force_str(url.split('?', 1)[-1]))
210

  
170 211
    def mock_artifact_resolver(self):
171 212
        @all_requests
172 213
        def f(url, request):
......
195 236
    assert urlparse.urlparse(response['Location']).path == '/singleLogout'
196 237

  
197 238

  
239
def test_sso_idp_slo(db, app, idp, caplog, sp_settings):
240
    assert Session.objects.count() == 0
241
    assert User.objects.count() == 0
242

  
243
    # first session
244
    response = app.get(reverse('mellon_login') + '?next=/whatever/')
245
    url, body, relay_state = idp.process_authn_request_redirect(response['Location'])
246
    assert relay_state
247
    assert 'eo:next_url' not in str(idp.request)
248
    assert url.endswith(reverse('mellon_login'))
249
    response = app.post(reverse('mellon_login'), params={'SAMLResponse': body, 'RelayState': relay_state})
250
    assert 'created new user' in caplog.text
251
    assert 'logged in using SAML' in caplog.text
252
    assert urlparse.urlparse(response['Location']).path == '/whatever/'
253

  
254
    # start a new Lasso session
255
    idp.reset_session_dump()
256

  
257
    # second session
258
    app.cookiejar.clear()
259
    response = app.get(reverse('mellon_login') + '?next=/whatever/')
260
    url, body, relay_state = idp.process_authn_request_redirect(response['Location'])
261
    assert relay_state
262
    assert 'eo:next_url' not in str(idp.request)
263
    assert url.endswith(reverse('mellon_login'))
264
    response = app.post(reverse('mellon_login'), params={'SAMLResponse': body, 'RelayState': relay_state})
265
    assert 'created new user' in caplog.text
266
    assert 'logged in using SAML' in caplog.text
267
    assert urlparse.urlparse(response['Location']).path == '/whatever/'
268

  
269
    assert Session.objects.count() == 2
270
    assert User.objects.count() == 1
271

  
272
    # idp logout
273
    url, body, relay_state = idp.init_slo()
274
    response = app.get(url)
275
    assert response.location.startswith('http://idp5/singleLogoutReturn?')
276
    assert Session.objects.count() == 1
277
    idp.check_slo_return(response.location)
278

  
279

  
280
def test_sso_idp_slo_full(db, app, idp, caplog, sp_settings):
281
    assert Session.objects.count() == 0
282
    assert User.objects.count() == 0
283

  
284
    # first session
285
    response = app.get(reverse('mellon_login') + '?next=/whatever/')
286
    url, body, relay_state = idp.process_authn_request_redirect(response['Location'])
287
    assert relay_state
288
    assert 'eo:next_url' not in str(idp.request)
289
    assert url.endswith(reverse('mellon_login'))
290
    response = app.post(reverse('mellon_login'), params={'SAMLResponse': body, 'RelayState': relay_state})
291
    assert 'created new user' in caplog.text
292
    assert 'logged in using SAML' in caplog.text
293
    assert urlparse.urlparse(response['Location']).path == '/whatever/'
294

  
295
    # second session
296
    app.cookiejar.clear()
297
    response = app.get(reverse('mellon_login') + '?next=/whatever/')
298
    url, body, relay_state = idp.process_authn_request_redirect(response['Location'])
299
    assert relay_state
300
    assert 'eo:next_url' not in str(idp.request)
301
    assert url.endswith(reverse('mellon_login'))
302
    response = app.post(reverse('mellon_login'), params={'SAMLResponse': body, 'RelayState': relay_state})
303
    assert 'created new user' in caplog.text
304
    assert 'logged in using SAML' in caplog.text
305
    assert urlparse.urlparse(response['Location']).path == '/whatever/'
306

  
307
    assert Session.objects.count() == 2
308
    assert User.objects.count() == 1
309

  
310
    # idp logout
311
    url, body, relay_state = idp.init_slo(full=True)
312
    response = app.get(url)
313
    assert response.location.startswith('http://idp5/singleLogoutReturn?')
314
    assert Session.objects.count() == 0
315
    idp.check_slo_return(url=response.location)
316

  
317

  
198 318
def test_sso(db, app, idp, caplog, sp_settings):
199 319
    response = app.get(reverse('mellon_login'))
200 320
    url, body, relay_state = idp.process_authn_request_redirect(response['Location'])
......
330 450
    acs_artifact_url = url.split('testserver', 1)[1]
331 451
    with HTTMock(idp.mock_artifact_resolver()):
332 452
        response = app.get(acs_artifact_url, params={'RelayState': relay_state})
333
    assert 'created new user' in caplog.text
453
    assert 'created new user' not in caplog.text
334 454
    assert 'logged in using SAML' in caplog.text
335 455
    assert urlparse.urlparse(response['Location']).path == '/whatever/'
336 456

  
337
-