0008-misc-clean-SessionIndex-during-logout-69740.patch
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 clean_session_indexes_after_logout(cls, delay_in_minutes=5, chunk_size=1000):
|
|
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 |
)[:chunk_size] |
|
72 |
candidates_session_keys = candidates.values_list('session_key', flat=True) |
|
73 |
if Session is not None: |
|
74 |
# fast path |
|
75 |
existing_session_keys = Session.objects.filter( |
|
76 |
session_key__in=candidates_session_keys |
|
77 |
).values_list('session_key', flat=True) |
|
78 |
dead_session_keys = candidates_session_keys.difference(existing_session_keys) |
|
79 |
else: |
|
80 |
dead_session_keys = [] |
|
81 |
for session_key in candidates_session_keys: |
|
82 |
if not store.exists(session_key): |
|
83 |
dead_session_keys.append(session_key) |
|
84 |
cls.objects.filter(session_key__in=dead_session_keys).delete() |
|
64 | 85 | |
65 | 86 |
class Meta: |
66 | 87 |
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.clean_session_indexes_after_logout() |
|
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.clean_session_indexes_after_logout() |
|
51 |
assert models.SessionIndex.objects.count() == 1 |
|
52 |
models.SessionIndex.objects.update(logout_timestamp=now()) |
|
53 |
models.SessionIndex.clean_session_indexes_after_logout() |
|
54 |
assert models.SessionIndex.objects.count() == 1 |
|
55 | ||
56 |
store.flush() # delete the session |
|
57 |
models.SessionIndex.clean_session_indexes_after_logout() |
|
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 |
- |