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.
This commit is contained in:
Benjamin Dauvergne 2022-10-04 11:33:07 +02:00
parent f335a403c1
commit 45f81514bc
5 changed files with 129 additions and 11 deletions

View File

@ -0,0 +1,27 @@
# Generated by Django 2.2.26 on 2022-10-04 09:10
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mellon', '0007_sessionindex_transient_name_id'),
]
operations = [
migrations.AddField(
model_name='sessionindex',
name='created',
field=models.DateTimeField(
auto_now_add=True, default=django.utils.timezone.now, verbose_name='created'
),
preserve_default=False,
),
migrations.AddField(
model_name='sessionindex',
name='logout_timestamp',
field=models.DateTimeField(null=True, verbose_name='Timestamp of the last logout'),
),
]

View File

@ -14,10 +14,12 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import datetime
from importlib import import_module
from django.conf import settings
from django.db import models
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
@ -50,17 +52,38 @@ class SessionIndex(models.Model):
saml_identifier = models.ForeignKey(
verbose_name=_('SAML identifier'), to=UserSAMLIdentifier, on_delete=models.CASCADE
)
created = models.DateTimeField(verbose_name=_('created'), auto_now_add=True)
logout_timestamp = models.DateTimeField(verbose_name=_('Timestamp of the last logout'), null=True)
@staticmethod
def cleanup(cls):
@classmethod
def cleanup(cls, delay_in_minutes=5, chunk_size=None):
session_engine = import_module(settings.SESSION_ENGINE)
store = session_engine.SessionStore()
ids = []
for si in cls.objects.all():
if not store.exists(si.session_key):
ids.append(si.id)
cls.objects.filter(id__in=ids).delete()
try:
Session = store.model
except AttributeError:
Session = None
candidates = cls.objects.filter(
models.Q(logout_timestamp__lt=now() - datetime.timedelta(minutes=delay_in_minutes))
| models.Q(created__lt=now() - datetime.timedelta(days=1))
)
if chunk_size:
candidates = candidates[:chunk_size]
candidates_session_keys = candidates.values_list('session_key', flat=True)
if Session is not None:
# fast path
existing_session_keys = Session.objects.filter(
session_key__in=candidates_session_keys
).values_list('session_key', flat=True)
dead_session_keys = candidates_session_keys.difference(existing_session_keys)
else:
dead_session_keys = []
for session_key in candidates_session_keys:
if not store.exists(session_key):
dead_session_keys.append(session_key)
cls.objects.filter(session_key__in=dead_session_keys).delete()
class Meta:
verbose_name = _('SAML SessionIndex')

View File

@ -35,6 +35,7 @@ from django.shortcuts import render, resolve_url
from django.urls import reverse
from django.utils.encoding import force_str
from django.utils.http import urlencode
from django.utils.timezone import now
from django.utils.translation import gettext as _
from django.views.decorators.csrf import csrf_exempt
from django.views.generic import View
@ -753,12 +754,13 @@ class LogoutView(ProfileMixin, LogMixin, View):
try:
session_indexes = models.SessionIndex.objects.filter(
saml_identifier__user=request.user, saml_identifier__issuer__entity_id=issuer
).order_by('-id')[:1]
).order_by('-id')
if not session_indexes:
self.log.error('unable to find lasso session dump')
else:
session_dump = utils.make_session_dump(session_indexes)
session_dump = utils.make_session_dump(session_indexes[:1])
logout.setSessionFromDump(session_dump)
session_indexes.update(logout_timestamp=now())
logout.initRequest(issuer, lasso.HTTP_METHOD_REDIRECT)
logout.buildRequestMsg()
except lasso.Error as e:
@ -801,6 +803,7 @@ class LogoutView(ProfileMixin, LogMixin, View):
response = HttpResponseRedirect(next_url)
if cookie_name in request.COOKIES:
response.delete_cookie(cookie_name)
models.SessionIndex.cleanup(chunk_size=100)
return response
TOKEN_SALT = 'mellon-logout-token'
@ -845,7 +848,7 @@ class LogoutView(ProfileMixin, LogMixin, View):
return None
session_indexes = models.SessionIndex.objects.filter(
saml_identifier__user=request.user, saml_identifier__issuer__entity_id=issuer
).order_by('-id')[:1]
).order_by('-id')
if not session_indexes:
return None
@ -854,6 +857,7 @@ class LogoutView(ProfileMixin, LogMixin, View):
'session_index_pk': session_indexes[0].pk,
}
token = signing.dumps(token_content, salt=cls.TOKEN_SALT)
session_indexes.update(logout_timestamp=now())
return reverse('mellon_logout') + '?' + urlencode({'token': token})

58
tests/test_models.py Normal file
View File

@ -0,0 +1,58 @@
# django-mellon - SAML2 authentication for Django
# Copyright (C) 2014-2022 Entr'ouvert
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import datetime
from importlib import import_module
import pytest
from django.utils.timezone import now
from mellon import models
@pytest.mark.parametrize(
'session_engine_path',
[
'mellon.sessions_backends.db',
'django.contrib.sessions.backends.cache',
],
)
def test_session_index_cleaning(session_engine_path, db, settings, django_user_model, freezer):
settings.SESSION_ENGINE = session_engine_path
session_engine = import_module(settings.SESSION_ENGINE)
store = session_engine.SessionStore(None)
user = django_user_model.objects.create(username='user')
issuer = models.Issuer.objects.create(entity_id='https://idp.example.com/', slug='idp')
usi = models.UserSAMLIdentifier.objects.create(user=user, issuer=issuer, name_id='1234')
store['x'] = 1
store.set_expiry(86400 * 31) # expire session after one month
store.save()
models.SessionIndex.objects.create(
session_index='abcd', session_key=store.session_key, saml_identifier=usi
)
assert models.SessionIndex.objects.count() == 1
# check SessionIndex is only cleaned if the session is dead,
# logout_timestamp being only used as a hint
freezer.move_to(datetime.timedelta(days=10))
models.SessionIndex.cleanup()
assert models.SessionIndex.objects.count() == 1
models.SessionIndex.objects.update(logout_timestamp=now())
models.SessionIndex.cleanup()
assert models.SessionIndex.objects.count() == 1
store.flush() # delete the session
models.SessionIndex.cleanup()
assert models.SessionIndex.objects.count() == 0

View File

@ -866,7 +866,7 @@ def test_sso_slo_transient_name_identifier(db, app, idp, caplog, sp_settings):
assert response.location == '/'
def test_sso_slo_token(db, app, rf, idp, caplog, django_user_model):
def test_sso_slo_token(db, app, rf, idp, caplog, django_user_model, freezer):
from mellon.views import LogoutView
caplog.set_level(logging.WARNING)
@ -874,10 +874,14 @@ def test_sso_slo_token(db, app, rf, idp, caplog, django_user_model):
url, body, relay_state = idp.process_authn_request_redirect(response['Location'])
response = app.post('/login/', params={'SAMLResponse': body, 'RelayState': relay_state})
assert models.SessionIndex.objects.count() == 1
assert models.SessionIndex.objects.filter(logout_timestamp__isnull=True).count() == 1
request = rf.get('/whatever/')
request.session = app.session
request.user = django_user_model.objects.get()
token_logout_url = LogoutView.make_logout_token_url(request, next_url='/somepath/')
assert models.SessionIndex.objects.count() == 1
assert models.SessionIndex.objects.filter(logout_timestamp__isnull=False).count() == 1
assert token_logout_url
app.session.flush()
assert '_auth_user_id' not in app.session
@ -885,6 +889,8 @@ def test_sso_slo_token(db, app, rf, idp, caplog, django_user_model):
assert urlparse.urlparse(response['Location']).path == '/singleLogout'
url = idp.process_logout_request_redirect(response.location)
caplog.clear()
freezer.move_to(datetime.timedelta(minutes=6))
response = app.get(url)
assert len(caplog.records) == 0, 'logout failed'
assert response.location == '/somepath/'
assert models.SessionIndex.objects.count() == 0