Projet

Général

Profil

0008-misc-clean-SessionIndex-during-logout-69740.patch

Benjamin Dauvergne, 05 octobre 2022 19:55

Télécharger (11,1 ko)

Voir les différences:

Subject: [PATCH 8/8] misc: clean SessionIndex during logout (#69740)

SessionIndex are deleted when the linked session does not exist anymore
and 5 minutes after the creation of the logout request.
 .../0008_add_timestamp_to_session_index.py    | 27 +++++++++
 mellon/models.py                              | 37 +++++++++---
 mellon/views.py                               | 10 +++-
 tests/test_models.py                          | 58 +++++++++++++++++++
 tests/test_sso_slo.py                         |  8 ++-
 5 files changed, 129 insertions(+), 11 deletions(-)
 create mode 100644 mellon/migrations/0008_add_timestamp_to_session_index.py
 create mode 100644 tests/test_models.py
mellon/migrations/0008_add_timestamp_to_session_index.py
1
# Generated by Django 2.2.26 on 2022-10-04 09:10
2

  
3
import django.utils.timezone
4
from django.db import migrations, models
5

  
6

  
7
class Migration(migrations.Migration):
8

  
9
    dependencies = [
10
        ('mellon', '0007_sessionindex_transient_name_id'),
11
    ]
12

  
13
    operations = [
14
        migrations.AddField(
15
            model_name='sessionindex',
16
            name='created',
17
            field=models.DateTimeField(
18
                auto_now_add=True, default=django.utils.timezone.now, verbose_name='created'
19
            ),
20
            preserve_default=False,
21
        ),
22
        migrations.AddField(
23
            model_name='sessionindex',
24
            name='logout_timestamp',
25
            field=models.DateTimeField(null=True, verbose_name='Timestamp of the last logout'),
26
        ),
27
    ]
mellon/models.py
14 14
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
15 15

  
16 16

  
17
import datetime
17 18
from importlib import import_module
18 19

  
19 20
from django.conf import settings
20 21
from django.db import models
22
from django.utils.timezone import now
21 23
from django.utils.translation import gettext_lazy as _
22 24

  
23 25

  
......
50 52
    saml_identifier = models.ForeignKey(
51 53
        verbose_name=_('SAML identifier'), to=UserSAMLIdentifier, on_delete=models.CASCADE
52 54
    )
55
    created = models.DateTimeField(verbose_name=_('created'), auto_now_add=True)
56
    logout_timestamp = models.DateTimeField(verbose_name=_('Timestamp of the last logout'), null=True)
53 57

  
54
    @staticmethod
55
    def cleanup(cls):
58
    @classmethod
59
    def cleanup(cls, delay_in_minutes=5, chunk_size=None):
56 60
        session_engine = import_module(settings.SESSION_ENGINE)
57 61
        store = session_engine.SessionStore()
58 62

  
59
        ids = []
60
        for si in cls.objects.all():
61
            if not store.exists(si.session_key):
62
                ids.append(si.id)
63
        cls.objects.filter(id__in=ids).delete()
63
        try:
64
            Session = store.model
65
        except AttributeError:
66
            Session = None
67

  
68
        candidates = cls.objects.filter(
69
            models.Q(logout_timestamp__lt=now() - datetime.timedelta(minutes=delay_in_minutes))
70
            | models.Q(created__lt=now() - datetime.timedelta(days=1))
71
        )
72
        if chunk_size:
73
            candidates = candidates[:chunk_size]
74
        candidates_session_keys = candidates.values_list('session_key', flat=True)
75
        if Session is not None:
76
            # fast path
77
            existing_session_keys = Session.objects.filter(
78
                session_key__in=candidates_session_keys
79
            ).values_list('session_key', flat=True)
80
            dead_session_keys = candidates_session_keys.difference(existing_session_keys)
81
        else:
82
            dead_session_keys = []
83
            for session_key in candidates_session_keys:
84
                if not store.exists(session_key):
85
                    dead_session_keys.append(session_key)
86
        cls.objects.filter(session_key__in=dead_session_keys).delete()
64 87

  
65 88
    class Meta:
66 89
        verbose_name = _('SAML SessionIndex')
mellon/views.py
35 35
from django.urls import reverse
36 36
from django.utils.encoding import force_str
37 37
from django.utils.http import urlencode
38
from django.utils.timezone import now
38 39
from django.utils.translation import gettext as _
39 40
from django.views.decorators.csrf import csrf_exempt
40 41
from django.views.generic import View
......
753 754
                        try:
754 755
                            session_indexes = models.SessionIndex.objects.filter(
755 756
                                saml_identifier__user=request.user, saml_identifier__issuer__entity_id=issuer
756
                            ).order_by('-id')[:1]
757
                            ).order_by('-id')
757 758
                            if not session_indexes:
758 759
                                self.log.error('unable to find lasso session dump')
759 760
                            else:
760
                                session_dump = utils.make_session_dump(session_indexes)
761
                                session_dump = utils.make_session_dump(session_indexes[:1])
761 762
                                logout.setSessionFromDump(session_dump)
763
                            session_indexes.update(logout_timestamp=now())
762 764
                            logout.initRequest(issuer, lasso.HTTP_METHOD_REDIRECT)
763 765
                            logout.buildRequestMsg()
764 766
                        except lasso.Error as e:
......
801 803
        response = HttpResponseRedirect(next_url)
802 804
        if cookie_name in request.COOKIES:
803 805
            response.delete_cookie(cookie_name)
806
        models.SessionIndex.cleanup(chunk_size=100)
804 807
        return response
805 808

  
806 809
    TOKEN_SALT = 'mellon-logout-token'
......
845 848
            return None
846 849
        session_indexes = models.SessionIndex.objects.filter(
847 850
            saml_identifier__user=request.user, saml_identifier__issuer__entity_id=issuer
848
        ).order_by('-id')[:1]
851
        ).order_by('-id')
849 852
        if not session_indexes:
850 853
            return None
851 854

  
......
854 857
            'session_index_pk': session_indexes[0].pk,
855 858
        }
856 859
        token = signing.dumps(token_content, salt=cls.TOKEN_SALT)
860
        session_indexes.update(logout_timestamp=now())
857 861
        return reverse('mellon_logout') + '?' + urlencode({'token': token})
858 862

  
859 863

  
tests/test_models.py
1
# django-mellon - SAML2 authentication for Django
2
# Copyright (C) 2014-2022 Entr'ouvert
3
# This program is free software: you can redistribute it and/or modify
4
# it under the terms of the GNU Affero General Public License as
5
# published by the Free Software Foundation, either version 3 of the
6
# License, or (at your option) any later version.
7

  
8
# This program is distributed in the hope that it will be useful,
9
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11
# GNU Affero General Public License for more details.
12

  
13
# You should have received a copy of the GNU Affero General Public License
14
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
15

  
16
import datetime
17
from importlib import import_module
18

  
19
import pytest
20
from django.utils.timezone import now
21

  
22
from mellon import models
23

  
24

  
25
@pytest.mark.parametrize(
26
    'session_engine_path',
27
    [
28
        'mellon.sessions_backends.db',
29
        'django.contrib.sessions.backends.cache',
30
    ],
31
)
32
def test_session_index_cleaning(session_engine_path, db, settings, django_user_model, freezer):
33
    settings.SESSION_ENGINE = session_engine_path
34
    session_engine = import_module(settings.SESSION_ENGINE)
35
    store = session_engine.SessionStore(None)
36
    user = django_user_model.objects.create(username='user')
37
    issuer = models.Issuer.objects.create(entity_id='https://idp.example.com/', slug='idp')
38
    usi = models.UserSAMLIdentifier.objects.create(user=user, issuer=issuer, name_id='1234')
39
    store['x'] = 1
40
    store.set_expiry(86400 * 31)  # expire session after one month
41
    store.save()
42
    models.SessionIndex.objects.create(
43
        session_index='abcd', session_key=store.session_key, saml_identifier=usi
44
    )
45
    assert models.SessionIndex.objects.count() == 1
46

  
47
    # check SessionIndex is only cleaned if the session is dead,
48
    # logout_timestamp being only used as a hint
49
    freezer.move_to(datetime.timedelta(days=10))
50
    models.SessionIndex.cleanup()
51
    assert models.SessionIndex.objects.count() == 1
52
    models.SessionIndex.objects.update(logout_timestamp=now())
53
    models.SessionIndex.cleanup()
54
    assert models.SessionIndex.objects.count() == 1
55

  
56
    store.flush()  # delete the session
57
    models.SessionIndex.cleanup()
58
    assert models.SessionIndex.objects.count() == 0
tests/test_sso_slo.py
866 866
    assert response.location == '/'
867 867

  
868 868

  
869
def test_sso_slo_token(db, app, rf, idp, caplog, django_user_model):
869
def test_sso_slo_token(db, app, rf, idp, caplog, django_user_model, freezer):
870 870
    from mellon.views import LogoutView
871 871

  
872 872
    caplog.set_level(logging.WARNING)
......
874 874
    url, body, relay_state = idp.process_authn_request_redirect(response['Location'])
875 875
    response = app.post('/login/', params={'SAMLResponse': body, 'RelayState': relay_state})
876 876

  
877
    assert models.SessionIndex.objects.count() == 1
878
    assert models.SessionIndex.objects.filter(logout_timestamp__isnull=True).count() == 1
877 879
    request = rf.get('/whatever/')
878 880
    request.session = app.session
879 881
    request.user = django_user_model.objects.get()
880 882
    token_logout_url = LogoutView.make_logout_token_url(request, next_url='/somepath/')
883
    assert models.SessionIndex.objects.count() == 1
884
    assert models.SessionIndex.objects.filter(logout_timestamp__isnull=False).count() == 1
881 885
    assert token_logout_url
882 886
    app.session.flush()
883 887
    assert '_auth_user_id' not in app.session
......
885 889
    assert urlparse.urlparse(response['Location']).path == '/singleLogout'
886 890
    url = idp.process_logout_request_redirect(response.location)
887 891
    caplog.clear()
892
    freezer.move_to(datetime.timedelta(minutes=6))
888 893
    response = app.get(url)
889 894
    assert len(caplog.records) == 0, 'logout failed'
890 895
    assert response.location == '/somepath/'
896
    assert models.SessionIndex.objects.count() == 0
891
-