diff --git a/.gitignore b/.gitignore index e70f786..e483278 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ django_session_security.egg-info dist docs/build docs/source/_static/ +.idea \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index 39b88e9..ca71b76 100644 --- a/.travis.yml +++ b/.travis.yml @@ -15,7 +15,7 @@ install: - pip install pep8 --use-mirrors - pip install https://github.com/dcramer/pyflakes/tarball/master - pip install -q -e . --use-mirrors - - pip install selenium==2.37.2 unittest_data_provider six + - pip install selenium==2.42.1 unittest_data_provider six - pip freeze before_script: - "pep8 --exclude=tests,migrations --ignore=E124,E128 session_security" diff --git a/README.rst b/README.rst index a670651..8195352 100644 --- a/README.rst +++ b/README.rst @@ -37,7 +37,9 @@ ping. First, a warning should be shown after ``settings.SESSION_SECURITY_WARN_AFTER`` seconds. The warning displays a text like "Your session is about to expire, -move the mouse to extend it". +move the mouse to extend it". Alternatively, you could set +``settings.SESSION_SECURITY_WARN_BEFORE`` if you don't know the +``settings.SESSION_SECURITY_EXPIRE_AFTER`` value before hand. Before displaying this warning, SessionSecurity will upload the time since the last client-side activity was recorded. The middleware will take it if it is @@ -53,6 +55,12 @@ Same goes to expire after ``settings.SESSION_SECURITY_EXPIRE_AFTER`` seconds. Javascript will first make an ajax request to PingView to ensure that another more recent activity was not detected anywhere else - in any other browser tab. +NOTE: To handle custom ``settings.SESSION_SECURITY_EXPIRE_AFTER`` values +(when there's a need for user-defined value), simply set +``settings.SESSION_SECURITY_CUSTOM_SESSION_KEY``. Middleware will take value +from ``request.session[settings.SESSION_SECURITY_CUSTOM_SESSION_KEY]`` first +before defaulting to ``settings.SESSION_SECURITY_EXPIRE_AFTER`` value. + Requirements ------------ diff --git a/docs/requirements_dev.txt b/docs/requirements_dev.txt new file mode 100644 index 0000000..d097fb3 --- /dev/null +++ b/docs/requirements_dev.txt @@ -0,0 +1,9 @@ +Django==1.6.5 +Markdown==2.4.1 +Pycco==0.3.0 +Pygments==1.6 +pystache==0.5.4 +selenium==2.42.1 +six==1.7.2 +smartypants==1.8.3 +unittest-data-provider==1.0.1 diff --git a/docs/source/conf.py b/docs/source/conf.py index 1789254..c590ed1 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -11,7 +11,9 @@ # All configuration values have a default; values that are commented out # serve to show the default. -import sys, os, os.path +import sys +import os +import os.path # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the @@ -20,7 +22,7 @@ sys.path.insert(0, os.path.abspath('../../../../lib/python2.7/site-packages/')) from django.conf import settings settings.configure() -settings.ROOT_URLCONF='session_security.urls' +settings.ROOT_URLCONF = 'session_security.urls' settings.SESSION_EXPIRE_AT_BROWSER_CLOSE autoclass_content = "both" @@ -190,21 +192,21 @@ # -- Options for LaTeX output -------------------------------------------------- latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', + #The paper size ('letterpaper' or 'a4paper'). + #'papersize': 'letterpaper', -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', + #The font size ('10pt', '11pt' or '12pt'). + #'pointsize': '10pt', -# Additional stuff for the LaTeX preamble. -#'preamble': '', + #Additional stuff for the LaTeX preamble. + #'preamble': '', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ - ('index', 'django-session-security.tex', u'django-session-security Documentation', - u'James Pic', 'manual'), + ('index', 'django-session-security.tex', u'django-session-security Documentation', + u'James Pic', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of @@ -247,9 +249,9 @@ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - ('index', 'django-session-security', u'django-session-security Documentation', - u'James Pic', 'django-session-security', 'One line description of project.', - 'Miscellaneous'), + ('index', 'django-session-security', u'django-session-security Documentation', + u'James Pic', 'django-session-security', 'One line description of project.', + 'Miscellaneous'), ] # Documents to append as an appendix to all manuals. diff --git a/session_security/middleware.py b/session_security/middleware.py index 2cd2f87..fd827af 100644 --- a/session_security/middleware.py +++ b/session_security/middleware.py @@ -17,7 +17,7 @@ from django.core.urlresolvers import reverse from .utils import get_last_activity, set_last_activity -from .settings import EXPIRE_AFTER, PASSIVE_URLS +from .settings import PASSIVE_URLS, get_expire_after class SessionSecurityMiddleware(object): @@ -35,7 +35,8 @@ def process_request(self, request): self.update_last_activity(request, now) delta = now - get_last_activity(request.session) - if delta >= timedelta(seconds=EXPIRE_AFTER): + + if delta >= timedelta(seconds=get_expire_after(request)): logout(request) elif request.path not in PASSIVE_URLS: set_last_activity(request.session, now) diff --git a/session_security/settings.py b/session_security/settings.py index 1a964b1..22bc3f4 100644 --- a/session_security/settings.py +++ b/session_security/settings.py @@ -2,19 +2,30 @@ Settings for django-session-security. WARN_AFTER - Time (in seconds) before the user should be warned that is session will - expire because of inactivity. Default 540. Overridable in + Time (in seconds) counting *forward* from when user session begins + until when user is warned that session will expire because of + inactivity. Default 540. Can be set in ``settings.SESSION_SECURITY_WARN_AFTER``. +WARN_BEFORE + Time (in seconds) counting *back* from session expiration when user + is warned that session will expire because of inactivity. Can be set + in ``settings.SESSION_SECURITY_WARN_BEFORE``. + EXPIRE_AFTER Time (in seconds) before the user should be logged out if inactive. Default - is 600. Overridable in ``settings.SESSION_SECURITY_EXPIRE_AFTER``. + is 600. Can be set in ``settings.SESSION_SECURITY_EXPIRE_AFTER``. + +EXPIRE_AFTER_CUSTOM_SESSION_KEY + Session key to set a custom EXPIRE_AFTER value. Can be set in + ``settings.SESSION_SECURITY_CUSTOM_SESSION_KEY`` + Use case: per-user EXPIRE_AFTER PASSIVE_URLS List of urls that should be ignored by the middleware. For example the ping ajax request of session_security is made without user intervention, as such - it should not be used to update the user's last activity datetime. - Overridable in ``settings.SESSION_SECURITY_PASSIVE_URLS``. + it should not be used to update the user's last activity datetime. Can be + set in ``settings.SESSION_SECURITY_PASSIVE_URLS``. Note that this module will raise a warning if ``settings.SESSION_EXPIRE_AT_BROWSER_CLOSE`` is not True, because it makes no @@ -26,11 +37,22 @@ from django.core import urlresolvers from django.conf import settings -__all__ = ['EXPIRE_AFTER', 'WARN_AFTER', 'PASSIVE_URLS'] +__all__ = ['EXPIRE_AFTER', 'WARN_BEFORE', 'WARN_AFTER', + 'PASSIVE_URLS', 'EXPIRE_AFTER_CUSTOM_SESSION_KEY'] EXPIRE_AFTER = getattr(settings, 'SESSION_SECURITY_EXPIRE_AFTER', 600) -WARN_AFTER = getattr(settings, 'SESSION_SECURITY_WARN_AFTER', 540) +EXPIRE_AFTER_CUSTOM_SESSION_KEY = getattr( + settings, 'SESSION_SECURITY_CUSTOM_SESSION_KEY', None) + +WARN_BEFORE = getattr( + settings, 'SESSION_SECURITY_WARN_BEFORE', None) + +WARN_AFTER = getattr( + settings, + 'SESSION_SECURITY_WARN_AFTER', + (EXPIRE_AFTER - WARN_BEFORE if WARN_BEFORE else 540) +) PASSIVE_URLS = getattr(settings, 'SESSION_SECURITY_PASSIVE_URLS', []) PASSIVE_URLS += [ @@ -39,3 +61,41 @@ if not getattr(settings, 'SESSION_EXPIRE_AT_BROWSER_CLOSE', False): warnings.warn('settings.SESSION_EXPIRE_AT_BROWSER_CLOSE is not True') + + +def get_expire_after(request): + """ + Calculate EXPIRE_AFTER value while accounting for + custom/user-defined value + """ + + if EXPIRE_AFTER_CUSTOM_SESSION_KEY is None: + return EXPIRE_AFTER + + expire_after_value = request.session.get( + EXPIRE_AFTER_CUSTOM_SESSION_KEY + ) + + if isinstance(expire_after_value, int) and expire_after_value > 0: + return expire_after_value + else: + return EXPIRE_AFTER + + +def get_warn_after(request): + """ + Calculate WARN_AFTER value while accounting for case + where EXPIRE_AFTER may be smaller + """ + + expire_after_value = get_expire_after(request) + warn_after_value = WARN_AFTER + + if WARN_BEFORE is not None: + warn_after_value = expire_after_value - WARN_BEFORE + + if (warn_after_value < 0) or \ + (expire_after_value - warn_after_value < 0): + warn_after_value = 1 + + return warn_after_value diff --git a/session_security/templatetags/session_security_tags.py b/session_security/templatetags/session_security_tags.py index 8012803..c161e5a 100644 --- a/session_security/templatetags/session_security_tags.py +++ b/session_security/templatetags/session_security_tags.py @@ -1,15 +1,16 @@ from django import template -from session_security.settings import WARN_AFTER, EXPIRE_AFTER +from session_security.settings import ( + get_expire_after, get_warn_after) register = template.Library() @register.filter def expire_after(request): - return EXPIRE_AFTER + return get_expire_after(request) @register.filter def warn_after(request): - return WARN_AFTER + return get_warn_after(request) diff --git a/session_security/tests/__init__.py b/session_security/tests/__init__.py index f9bd3c2..18d1ede 100644 --- a/session_security/tests/__init__.py +++ b/session_security/tests/__init__.py @@ -1,3 +1,3 @@ from .script import ScriptTestCase from .views import ViewsTestCase -from .middleware import MiddlewareTestCase +from .middleware import MiddlewareTestCase, DynamicSessionLevelTestCase diff --git a/session_security/tests/base.py b/session_security/tests/base.py index 5cbf063..9117fe8 100644 --- a/session_security/tests/base.py +++ b/session_security/tests/base.py @@ -31,11 +31,11 @@ def do_admin_login(self, username, password): self.browser.find_element_by_xpath('//input[@value="Log in"]').click() def new_window(self, name='other'): - self.browser.execute_script('window.open("/admin/", "'+ name +'")') - self.browser.switch_to_window(self.browser.window_handles[1]) + self.browser.execute_script('window.open("/admin/", "' + name + '")') + self.browser.switch_to.window(self.browser.window_handles[1]) while self.warning_element() is False: time.sleep(0.1) - self.browser.switch_to_window(self.browser.window_handles[0]) + self.browser.switch_to.window(self.browser.window_handles[0]) def press_space(self): a = ActionChains(self.browser) diff --git a/session_security/tests/middleware.py b/session_security/tests/middleware.py index a872cf0..8befb3f 100644 --- a/session_security/tests/middleware.py +++ b/session_security/tests/middleware.py @@ -1,9 +1,12 @@ import time import unittest +from datetime import datetime, timedelta +from django.test import TestCase as DjangoTestCase from django.test.client import Client + from session_security.utils import set_last_activity -from datetime import datetime, timedelta +from session_security import settings class MiddlewareTestCase(unittest.TestCase): @@ -12,10 +15,10 @@ def setUp(self): def test_auto_logout(self): self.client.login(username='test', password='test') - response = self.client.get('/admin/') + self.client.get('/admin/') self.assertTrue('_auth_user_id' in self.client.session) time.sleep(12) - response = self.client.get('/admin/') + self.client.get('/admin/') self.assertFalse('_auth_user_id' in self.client.session) def test_last_activity_in_future(self): @@ -23,25 +26,71 @@ def test_last_activity_in_future(self): now = datetime.now() future = now + timedelta(0, 30) set_last_activity(self.client.session, future) - response = self.client.get('/admin/') + self.client.get('/admin/') self.assertTrue('_auth_user_id' in self.client.session) def test_non_javascript_browse_no_logout(self): self.client.login(username='test', password='test') - response = self.client.get('/admin/') + self.client.get('/admin/') time.sleep(8) - response = self.client.get('/admin/') + self.client.get('/admin/') self.assertTrue('_auth_user_id' in self.client.session) time.sleep(4) - response = self.client.get('/admin/') + self.client.get('/admin/') self.assertTrue('_auth_user_id' in self.client.session) def test_javascript_activity_no_logout(self): self.client.login(username='test', password='test') - response = self.client.get('/admin/') + self.client.get('/admin/') time.sleep(8) - response = self.client.get('/session_security/ping/?idleFor=1') + self.client.get('/session_security/ping/?idleFor=1') + self.assertTrue('_auth_user_id' in self.client.session) + time.sleep(4) + self.client.get('/admin/') + self.assertTrue('_auth_user_id' in self.client.session) + + +class DynamicSessionLevelTestCase(DjangoTestCase): + def monkey_patch(self, saved_settings=None): + values = {'EXPIRE_AFTER': 3, + 'WARN_BEFORE': 2, + 'WARN_AFTER': 1, + 'EXPIRE_AFTER_CUSTOM_SESSION_KEY': 'user-session-key'} + old_settings = {} + for k, v in values.items(): + old_settings[k] = getattr(settings, k, None) + setattr( + settings, k, + v if saved_settings is None else saved_settings.get(k)) + return old_settings + + def setUp(self): + self.client = Client() + self.client.login(username='test', password='test') + self.saved_settings = self.monkey_patch() + + def tearDown(self): + self.monkey_patch(saved_settings=self.saved_settings) + + def set_custom_expire_after_value(self, value): + s = self.client.session + s['user-auto-logout'] = value + s.save() + + def test_global_session_value_logout(self): + self.client.get('/admin/') self.assertTrue('_auth_user_id' in self.client.session) time.sleep(4) - response = self.client.get('/admin/') + self.client.get('/admin/') + self.assertFalse('_auth_user_id' in self.client.session) + + def test_dynamic_session_value_logout(self): + self.set_custom_expire_after_value(2) + + self.client.get('/admin/') self.assertTrue('_auth_user_id' in self.client.session) + self.assertTrue('user-auto-logout' in self.client.session) + self.assertEqual(self.client.session.get('user-auto-logout'), 2) + time.sleep(3) + self.client.get('/admin/') + self.assertFalse('_auth_user_id' in self.client.session) \ No newline at end of file diff --git a/session_security/tests/script.py b/session_security/tests/script.py index 5886677..f0ef361 100644 --- a/session_security/tests/script.py +++ b/session_security/tests/script.py @@ -1,5 +1,5 @@ -from datetime import datetime import time +from datetime import datetime from selenium.webdriver.common.keys import Keys from selenium.webdriver.common.action_chains import ActionChains @@ -8,6 +8,7 @@ from .base import BaseLiveServerTestCase + class ScriptTestCase(BaseLiveServerTestCase): def warning_element(self): try: @@ -23,7 +24,7 @@ def assertWarningShows(self, max_seconds): now = datetime.now() for win in self.browser.window_handles: - self.browser.switch_to_window(win) + self.browser.switch_to.window(win) while self.warning_element() is False: time.sleep(0.1) @@ -32,7 +33,7 @@ def assertWarningShows(self, max_seconds): self.fail('Warning did not make it into DOM') for win in self.browser.window_handles: - self.browser.switch_to_window(win) + self.browser.switch_to.window(win) while self.warning_element().is_displayed() is False: time.sleep(0.1) @@ -44,7 +45,7 @@ def assertWarningHides(self, max_seconds): now = datetime.now() for win in self.browser.window_handles: - self.browser.switch_to_window(win) + self.browser.switch_to.window(win) while self.warning_element().is_displayed() is not False: time.sleep(0.1) @@ -56,7 +57,7 @@ def assertExpires(self, max_seconds): now = datetime.now() for win in self.browser.window_handles: - self.browser.switch_to_window(win) + self.browser.switch_to.window(win) while self.warning_element() is not False: time.sleep(0.1) @@ -66,20 +67,19 @@ def assertExpires(self, max_seconds): def assertWarningShown(self): for win in self.browser.window_handles: - self.browser.switch_to_window(win) + self.browser.switch_to.window(win) self.assertTrue(self.warning_element().is_displayed()) def assertWarningHidden(self): for win in self.browser.window_handles: - self.browser.switch_to_window(win) + self.browser.switch_to.window(win) self.assertFalse(self.warning_element().is_displayed()) def assertWarningNotInPage(self): for win in self.browser.window_handles: - self.browser.switch_to_window(win) + self.browser.switch_to.window(win) self.assertTrue(self.warning_element() is False) - def test_single_window_inactivity(self): self.wait_for_pages_loaded() self.assertWarningHidden() diff --git a/session_security/urls.py b/session_security/urls.py index 7520be1..edeadab 100644 --- a/session_security/urls.py +++ b/session_security/urls.py @@ -21,7 +21,8 @@ from .views import PingView -urlpatterns = patterns('', +urlpatterns = patterns( + '', url( 'ping/$', PingView.as_view(), diff --git a/session_security/utils.py b/session_security/utils.py index eaba318..5d1fbd7 100644 --- a/session_security/utils.py +++ b/session_security/utils.py @@ -13,5 +13,5 @@ def get_last_activity(session): Get the last activity datetime string from the session and return the python datetime object. """ - return datetime.strptime(session['_session_security'], - '%Y-%m-%dT%H:%M:%S.%f') + return datetime.strptime( + session['_session_security'], '%Y-%m-%dT%H:%M:%S.%f') diff --git a/setup.py b/setup.py index d1200b0..091e142 100644 --- a/setup.py +++ b/setup.py @@ -1,5 +1,7 @@ +import os +import sys from setuptools import setup, find_packages, Command -import os, sys + # Utility function to read the README file. # Used for the long_description. It's nice, because now 1) we have a top level @@ -29,8 +31,8 @@ def run(self): os.environ["DJANGO_SETTINGS_MODULE"] = 'test_project.settings' settings_file = os.environ["DJANGO_SETTINGS_MODULE"] settings_mod = __import__(settings_file, {}, {}, ['']) - execute_from_command_line(argv=[ __file__, "test", - "session_security"]) + execute_from_command_line( + argv=[__file__, "test", "session_security"]) os.chdir(this_dir) if 'sdist' in sys.argv: @@ -51,13 +53,13 @@ def run(self): include_package_data=True, zip_safe=False, long_description=read('README.rst'), - license = 'MIT', - keywords = 'django session', + license='MIT', + keywords='django session', cmdclass={'test': RunTests}, install_requires=[ 'django', ], - classifiers = [ + classifiers=[ 'Development Status :: 5 - Production/Stable', 'Environment :: Web Environment', 'Framework :: Django', @@ -71,4 +73,3 @@ def run(self): 'Topic :: Software Development :: Libraries :: Python Modules', ] ) - diff --git a/test_project/test_project/settings.py b/test_project/test_project/settings.py index a72c732..73af94e 100644 --- a/test_project/test_project/settings.py +++ b/test_project/test_project/settings.py @@ -18,7 +18,7 @@ DATABASES = { 'default': { - 'ENGINE': 'django.db.backends.sqlite3', # Add 'postgresql_psycopg2', 'mysql', 'sqlite3' or 'oracle'. + 'ENGINE': 'django.db.backends.sqlite3', # Add 'postgresql_psycopg2', 'mysql', 'sqlite3' or 'oracle'. 'NAME': 'db.sqlite', # Or path to database file if using sqlite3. 'USER': '', # Not used with sqlite3. 'PASSWORD': '', # Not used with sqlite3. @@ -88,7 +88,7 @@ STATICFILES_FINDERS = ( 'django.contrib.staticfiles.finders.FileSystemFinder', 'django.contrib.staticfiles.finders.AppDirectoriesFinder', -# 'django.contrib.staticfiles.finders.DefaultStorageFinder', + #'django.contrib.staticfiles.finders.DefaultStorageFinder', ) # Make this unique, and don't share it with anybody. @@ -98,7 +98,7 @@ TEMPLATE_LOADERS = ( 'django.template.loaders.filesystem.Loader', 'django.template.loaders.app_directories.Loader', -# 'django.template.loaders.eggs.Loader', + #'django.template.loaders.eggs.Loader', ) MIDDLEWARE_CLASSES = ( @@ -108,24 +108,26 @@ 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'session_security.middleware.SessionSecurityMiddleware', - # Uncomment the next line for simple clickjacking protection: - # 'django.middleware.clickjacking.XFrameOptionsMiddleware', + #Uncomment the next line for simple clickjacking protection: + #'django.middleware.clickjacking.XFrameOptionsMiddleware', ) -TEMPLATE_CONTEXT_PROCESSORS = ('django.contrib.auth.context_processors.auth', - 'django.core.context_processors.debug', - 'django.core.context_processors.i18n', - 'django.core.context_processors.media', - 'django.core.context_processors.static', - 'django.core.context_processors.request', - 'django.core.context_processors.tz', - 'django.contrib.messages.context_processors.messages') +TEMPLATE_CONTEXT_PROCESSORS = ( + 'django.contrib.auth.context_processors.auth', + 'django.core.context_processors.debug', + 'django.core.context_processors.i18n', + 'django.core.context_processors.media', + 'django.core.context_processors.static', + 'django.core.context_processors.request', + 'django.core.context_processors.tz', + 'django.contrib.messages.context_processors.messages' +) ROOT_URLCONF = 'test_project.urls' -LOGIN_URL='/admin/' -LOGOUT_URL='/admin/logout/' +LOGIN_URL = '/admin/' +LOGOUT_URL = '/admin/logout/' # Python dotted path to the WSGI application used by Django's runserver. WSGI_APPLICATION = 'test_project.wsgi.application' @@ -151,8 +153,8 @@ # 'django.contrib.admindocs', ) -SESSION_SECURITY_EXPIRE_AFTER=10 -SESSION_SECURITY_WARN_AFTER=5 +SESSION_SECURITY_EXPIRE_AFTER = 10 +SESSION_SECURITY_WARN_AFTER = 5 # A sample logging configuration. The only tangible logging # performed by this configuration is to send an email to @@ -168,9 +170,9 @@ } }, 'handlers': { - 'console':{ - 'level':'DEBUG', - 'class':'logging.StreamHandler', + 'console': { + 'level': 'DEBUG', + 'class': 'logging.StreamHandler', }, }, 'loggers': { diff --git a/test_project/test_project/urls.py b/test_project/test_project/urls.py index 09d6e66..9516464 100644 --- a/test_project/test_project/urls.py +++ b/test_project/test_project/urls.py @@ -1,5 +1,6 @@ import time +from django.conf import settings from django.conf.urls import patterns, include, url # Uncomment the next two lines to enable the admin: @@ -15,7 +16,10 @@ def get(self, request, *args, **kwargs): time.sleep(int(request.GET.get('seconds', 0))) return super(SleepView, self).get(request, *args, **kwargs) -urlpatterns = patterns('', +urlpatterns = patterns( + '', + url(r'^favicon\.ico$', generic.RedirectView.as_view( + url=settings.STATIC_URL + '/favicon.ico')), url(r'^$', generic.TemplateView.as_view(template_name='home.html')), url(r'^sleep/$', login_required( SleepView.as_view(template_name='home.html')), name='sleep'),