Permalink
Browse files

Done rewriting the app, I like it a lot now :D

  • Loading branch information...
1 parent b1bd4ea commit 439897b8de4fa1498b9979b7305369c7ff634751 @jpic jpic committed Feb 18, 2013
View
@@ -8,6 +8,51 @@ may be useful for CRMs, intranets, and such projects.
For example, if the user leaves for a coffee break, this app can force logout
after say 5 minutes of inactivity.
+Why not just set the session to expire after X minutes ?
+--------------------------------------------------------
+
+Or "Why does this app even exist" ? Here are the reasons:
+
+- if the user session expires before the user is done reading a page: he will
+ have to login again.
+- if the user session expires before the user is done filling a form: his work
+ will be lost, and he will have to login again, and probably yell at you, dear
+ django dev ... at least I know I would !
+
+This app allows to short circuit those limitations in session expiry.
+
+How does it work ?
+------------------
+
+When the user loads a page, SessionSecurity middleware will set the last
+activity to now. The last activity is stored as datetime
+in ``request.session['_session_security']``. To avoid having the middleware
+update that last activity datetime for a URL, add the url to
+``settings.SESSION_SECURITY_PASSIVE_URLS``.
+
+When the user moves mouse, click, scroll or press a key, SessionSecurity will
+save the DateTime as a JavaScript attribute. It will send the number of seconds
+since when the last user activity was recorded to PingView, next time it should
+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".
+
+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
+shorter than what it already has - ie. another more recent activity was
+detected in another browser tab. The PingView will respond with the number of
+seconds since the last activity - all browser tab included.
+
+If there was no other, more recent, activity recorded by the server: it will
+show the warning. Otherwise it will update the last activity in javascript from
+the PingView response.
+
+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.
+
Requirements
------------
@@ -1,4 +1,16 @@
-import datetime
+"""
+SessionSecurityMiddleware is the heart of the security that this application
+attemps to provide.
+
+To install this middleware, add to your ``settings.MIDDLEWARE_CLASSES``::
+
+ 'session_security.middleware.SessionSecurityMiddleware'
+
+Make sure that it is placed **after** authentication middlewares.
+"""
+
+import time
+from datetime import datetime, timedelta
from django import http
from django.contrib.auth import logout
@@ -8,42 +20,40 @@
class SessionSecurityMiddleware(object):
"""
- The heart of the security that this application attemps to provide.
-
- To install this middleware, add to your ``settings.MIDDLEWARE_CLASSES``::
-
- 'session_security.middleware.SessionSecurityMiddleware'
-
- Make sure that it is placed **after** authentication middlewares.
+ In charge of maintaining the real 'last activity' time, and log out the
+ user if appropriate.
"""
def process_request(self, request):
- """
- Set up ``request.session['session_security']`` if unset, logout and
- redirect the user to ``LOGIN_URL?next=/the/path/`` if his session has
- expired.
-
- - If the user is not authenticated: do nothing.
- - If the request url is in ``PASSIVE_URLS``: do nothing.
- - If ``request.session['session_security']`` is unset: set it up.
- - If the seconds elapsed since
- ``request.session['session_security']['last_activity']`` exceeds
- ``EXPIRE_AFTER``:
- - Logout the user,
- - Redirect to ``LOGIN_URL?next=/the/path/``.
- - Otherwise: update
- ``request.session['session_security']['last_activity']`` to now.
- """
-
+ """ Update last activity time or logout. """
if not request.user.is_authenticated():
return
- now = datetime.datetime.now()
- request.session.setdefault('_session_security', now)
+ now = datetime.now()
+ self.update_last_activity(request, now)
delta = now - request.session['_session_security']
- if delta.seconds > EXPIRE_AFTER and request.path_info != LOGIN_URL:
+ if delta.seconds >= EXPIRE_AFTER:
logout(request)
- if request.is_ajax():
- return http.HttpResponseRedirect(
- '%s?next=%s' % (LOGIN_URL, request.path_info))
+ elif request.path not in PASSIVE_URLS:
+ request.session['_session_security'] = now
+
+ def update_last_activity(self, request, now):
+ """
+ If ``request.POST['idleFor']`` is set, check if it refers to a more
+ recent activity than ``request.session['_session_security']`` and
+ update it in this case.
+ """
+ request.session.setdefault('_session_security', now)
+ last_activity = request.session['_session_security']
+ server_idle_for = (now - last_activity).seconds
+
+ if 'idleFor' in request.POST:
+ client_idle_for = int(request.POST['idleFor'])
+
+ if client_idle_for < server_idle_for:
+ # Client has more recent activity than we have in the session
+ last_activity = now - timedelta(seconds=client_idle_for)
+
+ # Update the session
+ request.session['_session_security'] = last_activity
@@ -46,7 +46,6 @@
PASSIVE_URLS = getattr(settings, 'SESSION_SECURITY_PASSIVE_URLS', [])
PASSIVE_URLS += [
urlresolvers.reverse('session_security_ping'),
- LOGOUT_URL,
]
if not getattr(settings, 'SESSION_EXPIRE_AT_BROWSER_CLOSE', False):
@@ -1,77 +1,109 @@
// Use 'yourlabs' as namespace.
if (window.yourlabs == undefined) window.yourlabs = {};
-// Session security class.
-yourlabs.SessionSecurity = function(pingUrl) {
- // Url to PingView.
- this.pingUrl = pingUrl;
-
+// Session security constructor. These are the required options:
+//
+// - pingUrl: url to ping with last activity in this tab to get global last
+// activity time,
+// - warnAfter: number of seconds of inactivity before warning,
+// - expireAfter: number of seconds of inactivity before expiring the session.
+yourlabs.SessionSecurity = function(options) {
// **HTML element** that should show to warn the user that his session will
// expire.
this.$warning = $('#session_security_warning');
- // A hack to anticipate clock skews. If the next event (warn or expire) is
- // in 13 seconds and that timeRatio is 1.3, then it will hit PingView after
- // 10 seconds (10/1.3). Adjust to your needs when you are asked to fine-tune.
- this.timeRatio = 1.3;
-
// Last recorded activity datetime.
this.lastActivity = new Date();
+
+ // Merge the options dict here.
+ $.extend(this, options);
// Bind common activity events to update this.lastActivity.
$(document)
.scroll($.proxy(this.activity, this))
.keyup($.proxy(this.activity, this))
.mousemove($.proxy(this.activity, this))
.click($.proxy(this.activity, this))
-
- // Ping to get the next event type and in how many seconds.
- this.ping();
+
+ // Initialize timers.
+ this.apply()
}
yourlabs.SessionSecurity.prototype = {
- // Called when PingView responds with ['expire', <something lower than 0>].
+ // Called when there has been no activity for more than expireAfter
+ // seconds.
expire: function() {
window.location.reload()
},
- // Called when PingView responds with
- // ['expire', <something higher than 0>].
+ // Called when there has been no activity for more than warnAfter
+ // seconds.
showWarning: function() {
this.$warning.fadeIn('slow');
},
- // Called when PingView responds with ['warn', ...]
+ // Called to hide the warning, for example if there has been activity on
+ // the server side - in another browser tab.
hideWarning: function() {
this.$warning.hide();
},
// Called by click, scroll, mousemove, keyup.
activity: function() {
- this.hideWarning();
this.lastActivity = new Date();
+
+ if (this.$warning.is(':visible')) {
+ // Inform the server that the user came back manually, this should
+ // block other browser tabs from expiring.
+ this.ping();
+ }
+
+ this.hideWarning();
},
// Hit the PingView with the number of seconds since last activity.
ping: function() {
- var inactiveSince = Math.floor((new Date() - this.lastActivity)
- / 1000);
+ var idleFor = Math.floor((new Date() - this.lastActivity) / 1000);
- $.post(this.pingUrl, {inactiveSince: inactiveSince},
- $.proxy(this.pong, this), 'json');
+ $.ajax(this.pingUrl, {
+ data: {idleFor: idleFor},
+ cache: false,
+ success: $.proxy(this.pong, this),
+ // In case of network error, we still want to hide potentially
+ // confidential data !!
+ error: $.proxy(this.apply, this),
+ dataType: 'json',
+ type: 'post',
+ });
},
// Callback to process PingView response.
pong: function(data) {
- this.action = data[0];
- this.time = data[1];
+ if (data == 'logout') return this.expire();
- if (this.action == 'expire') {
- this.time <= 0 ? this.expire() : this.showWarning();
+ this.lastActivity = new Date();
+ this.lastActivity.setSeconds(this.lastActivity.getSeconds() - data);
+ this.apply();
+ },
+
+ // Apply warning or expiry, setup next ping
+ apply: function() {
+ // Cancel timeout if any, since we're going to make our own
+ clearTimeout(this.timeout);
+
+ var idleFor = Math.floor((new Date() - this.lastActivity) / 1000);
+
+ if (idleFor >= this.expireAfter) {
+ this.lastChance = !this.lastChance;
+ return this.lastChance ? this.ping() : this.expire();
+ } else if (idleFor >= this.warnAfter) {
+ this.showWarning();
+ nextPing = this.expireAfter - idleFor;
} else {
this.hideWarning();
+ nextPing = this.warnAfter - idleFor;
}
- this.timeout = setTimeout($.proxy(this.ping, this),
- (this.time / this.timeRatio) * 1000);
- },
+
+ this.timeout = setTimeout($.proxy(this.ping, this), nextPing * 1000);
+ }
}
@@ -6,6 +6,7 @@
{% endcomment %}
+{% load session_security_tags %}
{% load i18n %}
{% load url from future %}
@@ -70,6 +71,10 @@
}
});
- var sessionSecurity = new yourlabs.SessionSecurity('{% url 'session_security_ping' %}');
+ var sessionSecurity = new yourlabs.SessionSecurity({
+ pingUrl: '{% url 'session_security_ping' %}',
+ warnAfter: {{ request|warn_after }},
+ expireAfter: {{ request|expire_after }},
+ });
</script>
{% endif %}
No changes.
@@ -0,0 +1,15 @@
+from django import template
+
+from session_security.settings import WARN_AFTER, EXPIRE_AFTER
+
+register = template.Library()
+
+
+@register.filter
+def expire_after(request):
+ return EXPIRE_AFTER
+
+
+@register.filter
+def warn_after(request):
+ return WARN_AFTER
@@ -1,2 +1,3 @@
from script import ScriptTestCase
from views import ViewsTestCase
+from middleware import MiddlewareTestCase
@@ -0,0 +1,39 @@
+import time
+import unittest
+
+from django.test.client import Client
+from django.test.utils import override_settings
+
+
+
+class MiddlewareTestCase(unittest.TestCase):
+ def setUp(self):
+ self.client = Client()
+
+ def test_auto_logout(self):
+ self.client.login(username='test', password='test')
+ response = self.client.get('/admin/')
+ self.assertTrue('_auth_user_id' in self.client.session)
+ time.sleep(12)
+ response = self.client.get('/admin/')
+ self.assertFalse('_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/')
+ time.sleep(8)
+ response = self.client.get('/admin/')
+ self.assertTrue('_auth_user_id' in self.client.session)
+ time.sleep(4)
+ response = 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/')
+ time.sleep(8)
+ response = self.client.post('/session_security/ping/', {'idleFor': '1'})
+ self.assertTrue('_auth_user_id' in self.client.session)
+ time.sleep(4)
+ response = self.client.get('/admin/')
+ self.assertTrue('_auth_user_id' in self.client.session)
Oops, something went wrong.

0 comments on commit 439897b

Please sign in to comment.