Saturday, May 16, 2009

Client-side session termination

One of the most annoying things about session timeout, especially in a web site that uses AJAX, is that often the user is unaware of it. They come back to their computer after an extended time away, the page is right there as they left it, but when they start interacting with it, it exhibits strange behavior. "Why is the login page showing up where my report ought to be?" Or "Where did my data go? I just clicked the sort by name column and all of my data disappeared."

Of course, we developers know what happened. Our AJAX code got caught in the authentication/authorization trap because the session expired. The automatic methods we've set up to prevent unauthenticated users from accessing our site worked too well and the AJAX request got redirected as well.

Rather than build session awareness into all of my AJAX code, I've decided to take a different tack. I've created a jQuery plugin that works client-side to warn the user that their session is about to expire and, absent a confirmation to continue the session, redirects the user to the logout action of my MVC web site to formally end the session.

Requirements

I had a few requirements for my plugin. First, it needed to work with jQuery, obviously. In fact I had some existing non-jQuery code that I converted to a jQuery plugin. I did this partly as a learning experience in developing jQuery plugins and partly because I wanted to declutter the global javascript scope. It also reduces the complexity of my master page as I moved the code to its own javascript file (it's an intranet application so I'm not particularly concerned about adding to the number of requests being made).

Second, I wanted the plugin to use a dialog to inform the user of the imminent expiration of their session and allow them to continue it if they wanted or end it immediately. To this end, the plugin requires that the developer supply a URL that can be accessed to continue the session and another that will end the session: refreshUrl and logoutUrl. In my use I picked an existing lightweight page as the refresh url, though you could create a separate action. I use sliding windows so any request to the server will reset the server-side session timer so this worked for me. If your requirements are more complex a separate action may be preferable. For the logout, I used the same logout action that's connected to the logout button. Additional data can be added to the URLs by appending query parameters. The plugin only supports the GET method at this time.

Since I'm already using jQuery UI, my plugin relies on the jQuery UI Dialog widget. To this end the plugin requires that you have an element on the page that will function as the prompt dialog. The plugin is actually chained off this element. It will add OK and Logoutbuttons to the dialog.

Third, I wanted the session timeout and dialog wait times to be configurable so that I could supply actual timeout values from the real session via ViewData. I decided to provide sensible, for me, defaults based on the default ASP.NET session timeout of 18 minutes and 1.5 minutes, respectively. That is, the dialog will pop up after 18 minutes of "inactivity" and after an additional 1.5 minutes of "inactivity" the logout action will be invoked.

Since the default timeout for an ASP.NET session is 20 minutes, this means that the client-side will terminate the session 30 seconds before the server-side. This prevents the logout action itself from being redirected to the logon page. Strange things can happen if you're not careful about this. You don't want the redirection to end up with the logon page redirecting back to the logout action as can happen if your logout action requires authentication. This is a strictly a problem for my plugin, but I decided to avoid the potential complication by ending the session early. Developers need to be aware that whatever value you they supply for the timeouts will be adjusted so that the actual invocation of the timeout will occur 30 seconds prior to the supplied session timeout value.

Lastly, I wanted the plugin to restart its timers on any interaction with the server, rather than on client-side interaction. I decided that it was too much overhead and additional complexity to reset the timers on client-side interaction. To do so I would have to detect all events on the page and periodically "phone home" to ensure that the server side session was refreshed. I did, however, want to reset the timers on AJAX interactivity so the plugin inserts some default behavior into the ajaxStart chain that does this.

Implementation

I use the jQuery autocomplete plugin as my implementation template. I felt that it provided a clean and easy to understand implementation. Below is my current code with a sample implementation.

/*
* Autologout - jQuery plugin 0.1
*
* Copyright (c) 2009 Tim Van Fosson
*
* Dependencies: jQuery 1.3.2, jQuery UI 1.7.1, bgiframe (by default)
*
*/

;(function($) {

$.fn.extend({
autologout: function(refreshUrl, logoutUrl, options) {
options = $.extend( {}, $.Autologout.defaults, options );

this.each(function() {
new $.Autologout(this, refreshUrl, logoutUrl, options);
return false;
});
return;
}
});

$.Autologout = function( prompt, refreshUrl, logoutUrl, options ) {
var logoutTimer = null;
var sessionTimer = null;
var dialogWait = Math.max( 0, Number( options.sessionDialogWait * 60000 ) );
var timeout = Math.max( 30000, (Number(options.sessionTimeout) * 60000) - dialogWait) - 30000;

$(prompt).dialog( {
autoOpen: false,
bgiframe: options.bgiframe,
modal: true,
buttons: {
OK: function() {
$(this).dialog('close');
$.get( refreshUrl, resetTimers, 'html' );
},
Logout: sessionExpired
},
open: function() {
if (options.pageSelector) {
var height = $(options.pageSelector).outerHeight();
$('.ui-widget-overlay').animate( { 'height' : height }, 'fast' );
}
}
}).ajaxStart( function() { resetTimers(); } );

resetTimers();

function resetTimers()
{
if (logoutTimer) clearTimeout(logoutTimer);
if (sessionTimer) clearTimeout(sessionTimer);

sessionTimer = setTimeout( sessionExpiring, timeout );
}

function sessionExpiring()
{
logoutTimer = setTimeout( sessionExpired, dialogWait );
$(prompt).dialog('open');
}

function sessionExpired()
{
window.location.href = logoutUrl;
}
};

$.Autologout.defaults = {
sessionTimeout: 20,
sessionDialogWait: 1.5,
bgiframe: true,
pageSelector: null
};

})(jQuery);


Update: Note that I've added in the ability to specify a selector for your page content. In instances where the content is generated dynamically on the page, I've found that the dialog overlay can be too small for the generated content. If you specify a selector for an element that takes up the entire page, the overlay will be resized when the dialog is shown so that it covers all the content.

Sample implementation -- from my master page. Notice how I only include the code on the page if the request is authenticated. It shouldn't be run on any page that doesn't have an authenticated session, obviously.


<% if (this.Request.IsAuthenticated)
{
double sessionDialogWait = 1.5;
double sessionTimeout = 30;
if (ViewData["sessionTimeout"] != null)
{
sessionTimeout = Convert.ToDouble( ViewData["sessionTimeout"] );
}
%>
<%= Html.Javascript( Url.Content( "~/Scripts/autologout.js" ) ) %>

<script type="text/javascript">
$(document).ready( function() {
$('#sessionEndDialog').autologout( '<%= Url.Action( "About", "Home" ) %>', '<%= Url.Action( "Logout", "Account" ) %>', {
sessionTimeout : Number('<%= sessionTimeout %>'),
sessionDialogWait: Number('<%= sessionDialogWait %>')
});
});
</script>

<% } %>


...snip...


<div id="sessionEndDialog" title="Session Expiring" style="display: none;">
<p>
Your session is about to expire. Click OK to renew your session or Logout to logout
of the application.
</p>
</div>