[1.5.x] Fixed #18194 -- Expiration of file-based sessions

* Prevented stale session files from being loaded
* Added removal of stale session files in django-admin.py clearsessions

Thanks ej for the report, crodjer and Elvard for their inputs.

Backport of 5fec97b from master.
This commit is contained in:
Aymeric Augustin 2012-10-27 23:12:08 +02:00
parent e6b0ee768c
commit 39082494e6
9 changed files with 176 additions and 29 deletions

View File

@ -1,7 +1,6 @@
from __future__ import unicode_literals from __future__ import unicode_literals
import base64 import base64
import time
from datetime import datetime, timedelta from datetime import datetime, timedelta
try: try:
from django.utils.six.moves import cPickle as pickle from django.utils.six.moves import cPickle as pickle
@ -309,3 +308,14 @@ class SessionBase(object):
Loads the session data and returns a dictionary. Loads the session data and returns a dictionary.
""" """
raise NotImplementedError raise NotImplementedError
@classmethod
def clear_expired(cls):
"""
Remove expired sessions from the session store.
If this operation isn't possible on a given backend, it should raise
NotImplementedError. If it isn't necessary, because the backend has
a built-in expiration mechanism, it should be a no-op.
"""
raise NotImplementedError

View File

@ -65,3 +65,7 @@ class SessionStore(SessionBase):
return return
session_key = self.session_key session_key = self.session_key
self._cache.delete(KEY_PREFIX + session_key) self._cache.delete(KEY_PREFIX + session_key)
@classmethod
def clear_expired(cls):
pass

View File

@ -71,6 +71,11 @@ class SessionStore(SessionBase):
except Session.DoesNotExist: except Session.DoesNotExist:
pass pass
@classmethod
def clear_expired(cls):
Session.objects.filter(expire_date__lt=timezone.now()).delete()
transaction.commit_unless_managed()
# At bottom to avoid circular import # At bottom to avoid circular import
from django.contrib.sessions.models import Session from django.contrib.sessions.models import Session

View File

@ -1,3 +1,4 @@
import datetime
import errno import errno
import os import os
import tempfile import tempfile
@ -5,26 +6,35 @@ import tempfile
from django.conf import settings from django.conf import settings
from django.contrib.sessions.backends.base import SessionBase, CreateError from django.contrib.sessions.backends.base import SessionBase, CreateError
from django.core.exceptions import SuspiciousOperation, ImproperlyConfigured from django.core.exceptions import SuspiciousOperation, ImproperlyConfigured
from django.utils import timezone
class SessionStore(SessionBase): class SessionStore(SessionBase):
""" """
Implements a file based session store. Implements a file based session store.
""" """
def __init__(self, session_key=None): def __init__(self, session_key=None):
self.storage_path = getattr(settings, "SESSION_FILE_PATH", None) self.storage_path = type(self)._get_storage_path()
if not self.storage_path: self.file_prefix = settings.SESSION_COOKIE_NAME
self.storage_path = tempfile.gettempdir() super(SessionStore, self).__init__(session_key)
@classmethod
def _get_storage_path(cls):
try:
return cls._storage_path
except AttributeError:
storage_path = getattr(settings, "SESSION_FILE_PATH", None)
if not storage_path:
storage_path = tempfile.gettempdir()
# Make sure the storage path is valid. # Make sure the storage path is valid.
if not os.path.isdir(self.storage_path): if not os.path.isdir(storage_path):
raise ImproperlyConfigured( raise ImproperlyConfigured(
"The session storage path %r doesn't exist. Please set your" "The session storage path %r doesn't exist. Please set your"
" SESSION_FILE_PATH setting to an existing directory in which" " SESSION_FILE_PATH setting to an existing directory in which"
" Django can store session data." % self.storage_path) " Django can store session data." % storage_path)
self.file_prefix = settings.SESSION_COOKIE_NAME cls._storage_path = storage_path
super(SessionStore, self).__init__(session_key) return storage_path
VALID_KEY_CHARS = set("abcdef0123456789") VALID_KEY_CHARS = set("abcdef0123456789")
@ -44,6 +54,18 @@ class SessionStore(SessionBase):
return os.path.join(self.storage_path, self.file_prefix + session_key) return os.path.join(self.storage_path, self.file_prefix + session_key)
def _last_modification(self):
"""
Return the modification time of the file storing the session's content.
"""
modification = os.stat(self._key_to_file()).st_mtime
if settings.USE_TZ:
modification = datetime.datetime.utcfromtimestamp(modification)
modification = modification.replace(tzinfo=timezone.utc)
else:
modification = datetime.datetime.fromtimestamp(modification)
return modification
def load(self): def load(self):
session_data = {} session_data = {}
try: try:
@ -56,6 +78,15 @@ class SessionStore(SessionBase):
session_data = self.decode(file_data) session_data = self.decode(file_data)
except (EOFError, SuspiciousOperation): except (EOFError, SuspiciousOperation):
self.create() self.create()
# Remove expired sessions.
expiry_age = self.get_expiry_age(
modification=self._last_modification(),
expiry=session_data.get('_session_expiry'))
if expiry_age < 0:
session_data = {}
self.delete()
self.create()
except IOError: except IOError:
self.create() self.create()
return session_data return session_data
@ -142,3 +173,19 @@ class SessionStore(SessionBase):
def clean(self): def clean(self):
pass pass
@classmethod
def clear_expired(cls):
storage_path = getattr(settings, "SESSION_FILE_PATH", tempfile.gettempdir())
file_prefix = settings.SESSION_COOKIE_NAME
for session_file in os.listdir(storage_path):
if not session_file.startswith(file_prefix):
continue
session_key = session_file[len(file_prefix):]
session = cls(session_key)
# When an expired session is loaded, its file is removed, and a
# new file is immediately created. Prevent this by disabling
# the create() method.
session.create = lambda: None
session.load()

View File

@ -92,3 +92,7 @@ class SessionStore(SessionBase):
return signing.dumps(session_cache, compress=True, return signing.dumps(session_cache, compress=True,
salt='django.contrib.sessions.backends.signed_cookies', salt='django.contrib.sessions.backends.signed_cookies',
serializer=PickleSerializer) serializer=PickleSerializer)
@classmethod
def clear_expired(cls):
pass

View File

@ -1,11 +1,15 @@
from django.conf import settings
from django.core.management.base import NoArgsCommand from django.core.management.base import NoArgsCommand
from django.utils import timezone from django.utils.importlib import import_module
class Command(NoArgsCommand): class Command(NoArgsCommand):
help = "Can be run as a cronjob or directly to clean out expired sessions (only with the database backend at the moment)." help = "Can be run as a cronjob or directly to clean out expired sessions (only with the database backend at the moment)."
def handle_noargs(self, **options): def handle_noargs(self, **options):
from django.db import transaction engine = import_module(settings.SESSION_ENGINE)
from django.contrib.sessions.models import Session try:
Session.objects.filter(expire_date__lt=timezone.now()).delete() engine.SessionStore.clear_expired()
transaction.commit_unless_managed() except NotImplementedError:
self.stderr.write("Session engine '%s' doesn't support clearing "
"expired sessions.\n" % settings.SESSION_ENGINE)

View File

@ -1,4 +1,5 @@
from datetime import timedelta from datetime import timedelta
import os
import shutil import shutil
import string import string
import tempfile import tempfile
@ -12,6 +13,7 @@ from django.contrib.sessions.backends.file import SessionStore as FileSession
from django.contrib.sessions.backends.signed_cookies import SessionStore as CookieSession from django.contrib.sessions.backends.signed_cookies import SessionStore as CookieSession
from django.contrib.sessions.models import Session from django.contrib.sessions.models import Session
from django.contrib.sessions.middleware import SessionMiddleware from django.contrib.sessions.middleware import SessionMiddleware
from django.core import management
from django.core.cache import DEFAULT_CACHE_ALIAS from django.core.cache import DEFAULT_CACHE_ALIAS
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
from django.http import HttpResponse from django.http import HttpResponse
@ -319,6 +321,30 @@ class DatabaseSessionTests(SessionTestsMixin, TestCase):
del self.session._session_cache del self.session._session_cache
self.assertEqual(self.session['y'], 2) self.assertEqual(self.session['y'], 2)
@override_settings(SESSION_ENGINE="django.contrib.sessions.backends.db")
def test_clearsessions_command(self):
"""
Test clearsessions command for clearing expired sessions.
"""
self.assertEqual(0, Session.objects.count())
# One object in the future
self.session['foo'] = 'bar'
self.session.set_expiry(3600)
self.session.save()
# One object in the past
other_session = self.backend()
other_session['foo'] = 'bar'
other_session.set_expiry(-3600)
other_session.save()
# Two sessions are in the database before clearsessions...
self.assertEqual(2, Session.objects.count())
management.call_command('clearsessions')
# ... and one is deleted.
self.assertEqual(1, Session.objects.count())
@override_settings(USE_TZ=True) @override_settings(USE_TZ=True)
class DatabaseSessionWithTimeZoneTests(DatabaseSessionTests): class DatabaseSessionWithTimeZoneTests(DatabaseSessionTests):
@ -358,6 +384,9 @@ class FileSessionTests(SessionTestsMixin, unittest.TestCase):
# Do file session tests in an isolated directory, and kill it after we're done. # Do file session tests in an isolated directory, and kill it after we're done.
self.original_session_file_path = settings.SESSION_FILE_PATH self.original_session_file_path = settings.SESSION_FILE_PATH
self.temp_session_store = settings.SESSION_FILE_PATH = tempfile.mkdtemp() self.temp_session_store = settings.SESSION_FILE_PATH = tempfile.mkdtemp()
# Reset the file session backend's internal caches
if hasattr(self.backend, '_storage_path'):
del self.backend._storage_path
super(FileSessionTests, self).setUp() super(FileSessionTests, self).setUp()
def tearDown(self): def tearDown(self):
@ -368,6 +397,7 @@ class FileSessionTests(SessionTestsMixin, unittest.TestCase):
@override_settings( @override_settings(
SESSION_FILE_PATH="/if/this/directory/exists/you/have/a/weird/computer") SESSION_FILE_PATH="/if/this/directory/exists/you/have/a/weird/computer")
def test_configuration_check(self): def test_configuration_check(self):
del self.backend._storage_path
# Make sure the file backend checks for a good storage dir # Make sure the file backend checks for a good storage dir
self.assertRaises(ImproperlyConfigured, self.backend) self.assertRaises(ImproperlyConfigured, self.backend)
@ -381,6 +411,37 @@ class FileSessionTests(SessionTestsMixin, unittest.TestCase):
self.assertRaises(SuspiciousOperation, self.assertRaises(SuspiciousOperation,
self.backend("a/b/c").load) self.backend("a/b/c").load)
@override_settings(SESSION_ENGINE="django.contrib.sessions.backends.file")
def test_clearsessions_command(self):
"""
Test clearsessions command for clearing expired sessions.
"""
storage_path = self.backend._get_storage_path()
file_prefix = settings.SESSION_COOKIE_NAME
def count_sessions():
return len([session_file for session_file in os.listdir(storage_path)
if session_file.startswith(file_prefix)])
self.assertEqual(0, count_sessions())
# One object in the future
self.session['foo'] = 'bar'
self.session.set_expiry(3600)
self.session.save()
# One object in the past
other_session = self.backend()
other_session['foo'] = 'bar'
other_session.set_expiry(-3600)
other_session.save()
# Two sessions are in the filesystem before clearsessions...
self.assertEqual(2, count_sessions())
management.call_command('clearsessions')
# ... and one is deleted.
self.assertEqual(1, count_sessions())
class CacheSessionTests(SessionTestsMixin, unittest.TestCase): class CacheSessionTests(SessionTestsMixin, unittest.TestCase):

View File

@ -1200,8 +1200,6 @@ clearsessions
Can be run as a cron job or directly to clean out expired sessions. Can be run as a cron job or directly to clean out expired sessions.
This is only supported by the database backend at the moment.
``django.contrib.sitemaps`` ``django.contrib.sitemaps``
--------------------------- ---------------------------

View File

@ -272,6 +272,13 @@ You can edit it multiple times.
Returns either ``True`` or ``False``, depending on whether the user's Returns either ``True`` or ``False``, depending on whether the user's
session cookie will expire when the user's Web browser is closed. session cookie will expire when the user's Web browser is closed.
.. method:: SessionBase.clear_expired
.. versionadded:: 1.5
Removes expired sessions from the session store. This class method is
called by :djadmin:`clearsessions`.
Session object guidelines Session object guidelines
------------------------- -------------------------
@ -458,22 +465,29 @@ This setting is a global default and can be overwritten at a per-session level
by explicitly calling the :meth:`~backends.base.SessionBase.set_expiry` method by explicitly calling the :meth:`~backends.base.SessionBase.set_expiry` method
of ``request.session`` as described above in `using sessions in views`_. of ``request.session`` as described above in `using sessions in views`_.
Clearing the session table Clearing the session store
========================== ==========================
If you're using the database backend, note that session data can accumulate in As users create new sessions on your website, session data can accumulate in
the ``django_session`` database table and Django does *not* provide automatic your session store. If you're using the database backend, the
purging. Therefore, it's your job to purge expired sessions on a regular basis. ``django_session`` database table will grow. If you're using the file backend,
your temporary directory will contain an increasing number of files.
To understand this problem, consider what happens when a user uses a session. To understand this problem, consider what happens with the database backend.
When a user logs in, Django adds a row to the ``django_session`` database When a user logs in, Django adds a row to the ``django_session`` database
table. Django updates this row each time the session data changes. If the user table. Django updates this row each time the session data changes. If the user
logs out manually, Django deletes the row. But if the user does *not* log out, logs out manually, Django deletes the row. But if the user does *not* log out,
the row never gets deleted. the row never gets deleted. A similar process happens with the file backend.
Django provides a sample clean-up script: ``django-admin.py clearsessions``. Django does *not* provide automatic purging of expired sessions. Therefore,
That script deletes any session in the session table whose ``expire_date`` is it's your job to purge expired sessions on a regular basis. Django provides a
in the past -- but your application may have different requirements. clean-up management command for this purpose: :djadmin:`clearsessions`. It's
recommended to call this command on a regular basis, for example as a daily
cron job.
Note that the cache backend isn't vulnerable to this problem, because caches
automatically delete stale data. Neither is the cookie backend, because the
session data is stored by the users' browsers.
Settings Settings
======== ========