Expert Software Development, Staffing & Consulting


Handling Session Timeout Gracefully

Written by Noah Heldman on Jan 20 2012 - 7 Comments

Nerd-Face

Have you ever been to those sites that show you a nice helpful popup to let you know that your session will expire in a few minutes?  You know, the ones that show you a countdown timer, and let you Continue Your Session, or Log Out?  And if you’ve been gone so long that your session really did expire because you were feeding your nerd-face, it automatically logs you out and shows you a nice message letting you know it was only to protect you?  That it was For Your Own Good?

Yeah, me too!   Then I said, “I want one!” and set out to build my own.

Gravy

Here’s what it looks like when it first pops up:

image

When you click “Continue”, the dialog box closes and re-establishes the session.  If you then stay idle for a while, the dialog box will reopen and the countdown starts again.

When you click “Log Out”, the dialog box closes, logs you out, and sends you to the home page.  It includes a return URL, so if you log in again, then Hey!, you’re right back where you were.

Oh, and check this out:  it dynamically updates the HTML title, which (at least on Windows) will show you the countdown in your task bar.  That’s just gravy!

image

But that’s just how I do it.  Everything in the code is easily tweakable.  And there’s just not that much code.

Bottle Opener

Bloggers have to make assumptions.  And sometimes the code we write has dependencies.  These are mine:

  • I assume you are clever and funky
  • I assume you know HTML, JavaScript, and a little jQuery
  • I assume you own a bottle opener
  • I assume you value quality
  • My code depends on jQuery and jQuery UI.  You don’t have to have them to make this approach work, but it simplifies some things.
  • My server-side code is written in ASP.NET MVC 3.  It is part of a larger framework that includes ASP.NET Membership, T4MVC, and StructureMap for dependency injection.  Again, you don’t have to have these, but you will need some sort of back-end that supports sessions.  Otherwise, why are you here?  Were you just searching for “gravy” and found this?
  • I assume you like pumpkin pie

But really, I wish I didn’t have to assume and depend so much.  But where I do, I’ll try to at least link to info that will be helpful.

Gnarly Diamond

The magic in this code comes from the power of JavaScript, which is a dark and dangerous art in the wrong hands, and some kind of gnarly diamond if you’re Douglas CrockfordHis book is awesome, very dense, and makes a whole lot of assumptions about its audience.

My code follows the Module Pattern (invented by Mr. Crockford), which minimizes global variables, and allows you to decide what in your code is public and private.  As for the countdown timer, that’s just those old stand-bys setTimeout(functionName, milliseconds), which waits a specified number of milliseconds before calling your function, and setInterval(functionName, milliseconds), which calls your function relentlessly every [milliseconds] milliseconds.

Viola!

At this point, you are probably itchy to see some code.  So then, viola!  Let’s build up the JavaScript SessionManager module step by step:

Structure

First, here’s the basic JavaScript structure we’ll be using.  Lots of pseudocode here, but it should give the basic idea.  If you read the comments, you might learn a thing or two.

var SessionManager = function() {
    // Private Variables will go here...

    // Private Functions

    // endSession: Called when the session expires
    var endSession = function() {
        // Close the dialog
        // Redirect to the "expire" URL
    };

    // displayCountdown: Wrapper for updating the countdown display every second
    var displayCountdown = function() {

        // Inner countdown function, which we can call right away, then
        // call every second (setInterval waits before its first function call)
        var countdown = function() {
            // Get countdown minutes and seconds
            // Update the HTML title
            // Update the countdown display
            // If the countdown timer reaches zero,
            //   Update the HTML title to "Session Expired"
            //   Call the endSession() function

            // Decrement the counter
        };

        // Call the countdown() function immediately
        countdown();

        // Call the countdown() function every second thereafter
        window.setInterval(countdown, 1000);
    };

    // promptToExtendSession: Display the jQuery dialog
    // and kick off the countdown timer
    var promptToExtendSession = function() {
        // Build up a jQuery dialog
        // with buttons to "Continue" (extend session)
        // and "Log Out" (end session)

        displayCountdown();
    };

    // startSessionManager: Calls promptToExtendSession() after 5 minutes
    var startSessionManager = function() {
        window.setTimeout(promptToExtendSession, 300000);
    };

    // refreshSession: Refresh the session
    var refreshSession = function() {
        // Refresh the session using the "extend" session url

        // Restart the countdown to the popup
        startSessionManager();
    };

    // Public Functions

    // These will be the only public methods available to outside callers
    return {
        // Start the session (call using SessionManager.start())
        start: function() {
            startSessionManager();
        },

        // Extend the session (call using SessionManager.extend())
        extend: function() {
            refreshSession();
        }
    };
}(); // See those parentheses?  They will execute this function immediately and
     // return the anonymous object with two public functions (start and extend)

// And now, we can call our public method to plant the popup seed!
SessionManager.start();

Fill ‘Er Up

Okay, so that’s the basic layout. Let’s fill in the blanks.  Here we’ll add our private variables, our jQuery Dialog with real live countdown display, and implement the rest of our functions.

I’ll highlight some of the neato stuff in the comments.

var SessionManager = function() {
    // Private Variables
    var sessionTimeoutSeconds = 20 * 60,                                 // Session timeout is 20 minutes
        promptSeconds = 5 * 60,                                          // Prompt shows for 5 minutes
        secondsBeforePrompt = sessionTimeoutSeconds - countdownSeconds,  // 15 minutes until prompt pops up
        $dlg,                                                            // jQuery Dialog
        displayCountdownIntervalId,                                      // setInterval id, for clean up
        promptToExtendSessionTimeoutId,                                  // setTimeout id, for clean up
        originalTitle = document.title,                                  // grab the HTML <title> (for later)
        extendSessionUrl = '/Session/Extend',                            // URL to call when extending session
        expireSessionUrl = '/Session/Expire';                            // URL to call when expiring session

    // Private Functions
    var endSession = function() {
        $dlg.dialog('close');                                            // Close the jQuery Dialog
        location.href = expireSessionUrl;                                // Redirect to the expiration URL
    };

    var displayCountdown = function() {
        var countdown = function() {
            var cd = new Date(count * 1000),                             // Returns milliseconds since 01/01/70
                minutes = cd.getUTCMinutes(),                            // Grab the minutes
                seconds = cd.getUTCSeconds();                            // Grab the seconds

            document.title = 'Expire in ' + minutes + ':' + seconds;     // Update the HTML title
            $('#sm-countdown').html(minutes + ':' + seconds);            // Update the countdown display
            if (count === 0) {                                           // If we reached zero,
                document.title = 'Session Expired';                      // update the HTML title
                endSession();                                            // and end the session
            }
            count--;
        };
        countdown();                                                      // Call the function once
        displayCountdownIntervalId = window.setInterval(countdown, 1000); // Call the function every second
    };

    var promptToExtendSession = function() {
        $dlg = $('#sm-countdown-dialog').dialog({                         // See the HTML below
            title: 'Session Timeout Warning',
            buttons: {
                'Continue': function() {
                    $(this).dialog('close');                              // Close the dialog
                    refreshSession();                                     // Refresh the session
                    document.title = originalTitle;                       // Change the title back
                },
                'Log Out': function() {
                    endSession(false);                                    // End the session
                }
            }
        });
        count = promptSeconds;                                            // Set our counter
        displayCountdown();                                               // Show that dialog!
    };

    var refreshSession = function() {
        window.clearInterval(displayCountdownIntervalId);                 // Stop calling countdown so
                                                                          // we can start a new timer
        var img = new Image(1, 1);                                        // Create a tiny image
        img.src = extendSessionUrl;                                       // and set its source to the
                                                                          // extend session url (like
                                                                          // poor man's Ajax!)
        window.clearTimeout(promptToExtendSessionTimeoutId);              // Clear the timeout so we can...
        startSessionManager();                                            // ... start it all over!
    };

    // Just a private implementation to actually start our countdown before popup
    var startSessionManager = function() {
        promptToExtendSessionTimeoutId = window.setTimeout(promptToExtendSession, secondsBeforePrompt * 1000);
    };

    // Public Functions
    return {
        start: function() {
            startSessionManager();
        },

        extend: function() {
            refreshSession();
        }
    };
}();

Two Monologues Do Not Make a Dialog

Here’s the HTML for the dialog:

<div id="sm-countdown-dialog" style="display:none">
    <p>Your session will expire in:</p>
    <div id="sm-countdown"><!-- Placeholder for dynamic countdown --></div>
    <p>Click "Continue" to keep working, or "Log Out" if you are finished.</p>
</div>

Impatience

I also added support for testing this when you don’t want to wait 15 minutes for your popup to show up, and you don’t want to keep changing your session timeout on the back end and restarting your server…

It uses a couple query string variables to set the total session timeout (smt) and the number of seconds before the countdown dialog appears (smc). You can use it like this on any page where the JavaScript exists:

http://flumko.me/pork/edit?smt=30&smc=20

What that does is show the dialog after 20 seconds, and it will start a 10 second countdown before it expires the session.

Helpy Helperton

Things like getting a query string and padding a string are really utilities, and should be relegated to their own modules.  To support this, I’m adding a new module called HtmlHelpers, so we can grab the query string value easily, then I just use that if provided, otherwise I fall back to the default (that’s the coolness of the || operator):

// HtmlHelpers Module
// Call by using HtmlHelpers.getQueryStringValue("myname");
var HtmlHelpers = function() {
    return {
        // Based on http://stackoverflow.com/questions/901115/get-query-string-values-in-javascript
        getQueryStringValue: function(name) {
            var match = RegExp('[?&]' + name + '=([^&]*)').exec(window.location.search);
            return match && decodeURIComponent(match[1].replace( /+/g , ' '));
        }
    };
}();

var SessionManager = function() {
    // Private Variables
    var countdownSeconds = HtmlHelpers.getQueryStringValue('smc') || 300,
        sessionTimeoutSeconds = HtmlHelpers.getQueryStringValue('smt') || 1200;

    // Lots of code deleted here...
}();

I do something similar with another module called StringHelpers, which adds a padLeft function to pretty up our countdown display:

// StringHelpers Module
// Call by using StringHelpers.padLeft("1", "000");
var StringHelpers = function() {
    return {
        // Pad string using padMask.  string '1' with padMask '000' will produce '001'.
        padLeft: function(string, padMask) {
            string = '' + string; // If it ain't a string, make it one (ye olde type coercion!)
            return (padMask.substr(0, (padMask.length - string.length)) + string);
        }
    };
}();

The Final Countdown

So, here’s our final JavaScript code, incorporating everything we’ve seen so far, and a little bit more.  Super shiny!

<!-- Countdown Dialog HTML -->
<div id="sm-countdown-dialog" style="display:none">
    <p>Your session will expire in:</p>
    <div id="sm-countdown"><!-- Placeholder for dynamic countdown --></div>
    <p>Click "Continue" to keep working, or "Log Out" if you are finished.</p>
</div>

<script type="text/javascript">
$(function() { // Wrap it all in jQuery documentReady because we use jQuery UI Dialog
    // HtmlHelpers Module
    // Call by using HtmlHelpers.getQueryStringValue("myname");
    var HtmlHelpers = function() {
        return {
            // Based on http://stackoverflow.com/questions/901115/get-query-string-values-in-javascript
            getQueryStringValue: function(name) {
                var match = RegExp('[?&]' + name + '=([^&]*)').exec(window.location.search);
                return match && decodeURIComponent(match[1].replace( /+/g , ' '));
            }
        };
    }();

    // StringHelpers Module
    // Call by using StringHelpers.padLeft("1", "000");
    var StringHelpers = function() {
        return {
            // Pad string using padMask.  string '1' with padMask '000' will produce '001'.
            padLeft: function(string, padMask) {
                string = '' + string;
                return (padMask.substr(0, (padMask.length - string.length)) + string);
            }
        };
    }();

    // SessionManager Module
    var SessionManager = function() {
        // NOTE:  I use @Session.Timeout here, which is Razor syntax, and I am pulling that value
        //        right from the ASP.NET MVC Session variable.  Dangerous!  Reckless!  Awesome-sauce!
        //        You can just hard-code your timeout here if you feel like it.  But I might cry.
        var sessionTimeoutSeconds = HtmlHelpers.getQueryStringValue('smt') || (@Session.Timeout * 60),
            countdownSeconds = HtmlHelpers.getQueryStringValue('smc') || 300,
            secondsBeforePrompt = sessionTimeoutSeconds - countdownSeconds,
            $dlg,
            displayCountdownIntervalId,
            promptToExtendSessionTimeoutId,
            originalTitle = document.title,
            count = countdownSeconds,
            extendSessionUrl = '/Session/Extend',
            expireSessionUrl = '/Session/Expire?returnUrl=' + location.pathname;

        var endSession = function() {
            $dlg.dialog('close');
            location.href = expireSessionUrl;
        };

        var displayCountdown = function() {
            var countdown = function() {
                var cd = new Date(count * 1000),
                    minutes = cd.getUTCMinutes(),
                    seconds = cd.getUTCSeconds(),
                    minutesDisplay = minutes === 1 ? '1 minute ' : minutes === 0 ? '' : minutes + ' minutes ',
                    secondsDisplay = seconds === 1 ? '1 second' : seconds + ' seconds',
                    cdDisplay = minutesDisplay + secondsDisplay;

                document.title = 'Expire in ' +
                    StringHelpers.padLeft(minutes, '00') + ':' +
                        StringHelpers.padLeft(seconds, '00');
                $('#sm-countdown').html(cdDisplay);
                if (count === 0) {
                    document.title = 'Session Expired';
                    endSession();
                }
                count--;
            };
            countdown();
            displayCountdownIntervalId = window.setInterval(countdown, 1000);
        };

        var promptToExtendSession = function() {
            $dlg = $('#sm-countdown-dialog')
                .dialog({
                    title: 'Session Timeout Warning',
                    height: 205,
                    width: 250,
                    bgiframe: true,
                    modal: true,
                    buttons: {
                        'Continue': function() {
                            $(this).dialog('close');
                            refreshSession();
                            document.title = originalTitle;
                        },
                        'Log Out': function() {
                            endSession(false);
                        }
                    }
                });
            count = countdownSeconds;
            displayCountdown();
        };

        var refreshSession = function() {
            window.clearInterval(displayCountdownIntervalId);
            var img = new Image(1, 1);
            img.src = extendSessionUrl;
            window.clearTimeout(promptToExtendSessionTimeoutId);
            startSessionManager();
        };

        var startSessionManager = function() {
            promptToExtendSessionTimeoutId =
                window.setTimeout(promptToExtendSession, secondsBeforePrompt * 1000);
        };

        // Public Functions
        return {
            start: function() {
                startSessionManager();
            },

            extend: function() {
                refreshSession();
            }
        };
    }();

    SessionManager.start();

    // Whenever an input changes, extend the session,
    // since we know the user is interacting with the site.
    $(':input').change(function() {
        SessionManager.extend();
    });
});
</script>

Honey Badger

But Noah, your variable names are comically long and descriptive!

You’re right. I love that. But it may make you angrier than a honey badger, so please allow me to talk you down.

Because all of my verbose variable names are truly private inside the module, your favorite minifier/compressor can safely mangle those variable names to a single letter, reducing client load by eleventy billion percent.  Huzzah for the shopkeep!

My Back End Sings

Fantastic. All this JavaScript, and nothing on the server side to make it work. Let’s fix that.

We are using ASP.NET MVC 3 on my current project, so here’s how I made my back end sing. First, we have our two urls from the JavaScript:

var extendSessionUrl = '/Session/Extend',
    expireSessionUrl = '/Session/Expire';

So we’ll add an ASP.NET MVC 3 SessionController with Extend and Expire actions, using the SeeSharp language all the kids are talking about:

using System.Web.Mvc;

namespace Fairway.Web.Controllers
{
    public partial class SessionController : BaseController
    {
        private readonly IFormsAuthenticationService _formsAuthenticationService;

        public SessionController(IFormsAuthenticationService formsAuthenticationService)
        {
            _formsAuthenticationService = formsAuthenticationService;
        }

        // This is used from JavaScript to re-establish the user's session
        [Authorize]
        [OutputCache(NoStore = true, Duration = 0, VaryByParam = "*")] // Never Cache
        public virtual ActionResult Extend()
        {
            // Re-establish the session timeout
            Session.Timeout = 20;
            return new EmptyResult();
        }

        [Authorize]
        public virtual ActionResult Expire(string returnUrl)
        {
            _formsAuthenticationService.SignOut();

            // Redirect to the role-specified "session expired" view
            // This needs to be a separate Action because we need to issue a separate
            // request once the session has been abandoned in order to have the correct
            // context (that the user is logged out).
            return RedirectToAction(MVC.Session.SessionExpired(returnUrl));
        }

        public virtual ActionResult SessionExpired(string returnUrl)
        {
            ViewData["ReturnUrl"] = returnUrl;

            return View(MVC.Account.Views.SessionExpired);
        }
    }
}

Now we can respond to those calls from JavaScript! The key points here are:

  • The Extend() method cannot be cached, or your session may not refresh correctly
  • The Expire() method signs the user out, but has to redirect to a new action, which will know that the user is logged out

Nerd-Glue

We’ve got some JavaScript.  We’ve got some HTML.  We’ve got some back end.  How do you stick it all together in a useful way?  With nerd-glue, naturally!

  1. Add the HTML and JavaScript from “The Final Countdown” (above) to the bottom of every page in your site where you want the session timeout logic
    1. Of course, you should probably put it in an include, or a partial view, and then drop it in your site layout, master page, or equivalent
    2. You should probably also wrap the JavaScript and HTML code in logic that ensures that the request is coming from an authenticated user with an active session
  2. Add the server-side code to handle the “extend” and “expire” urls
  3. I guess that’s it

Imperial

I’ve told you a few things about my approach for gracefully handling session timeout.  Here are some things I haven’t told you:

  • It’s not a jQuery plugin, but could be (shameless plug for FlexBox plugin!)
  • My favorite color is mercury
  • The code could use more cleanup (but most code could)
  • Hess Brewing makes a mean Rye Imperial Stout

Enjoy!

7 Comments

  • Michael Fraser / May 23, 2012

    1st things 1st Noah… I enjoyed reading your tutorial. You are most entertaining. :) I am guessing I am the 1st to comment and ill be sure to share this link around.

    Question: I followed your tut carefully and when I stuck the SessionController : BaseController class in it is expecting a using reference or another class or something. I am hoping and guessing that you left some code out or perhaps I am just too stupid..lol.

    What does that base controller depend on?

    Thanks in advance Michael

    Reply
  • Noah Heldman / May 24, 2012

    Michael,

    Thanks for your comment!

    The IFormsAuthenticationService in the SessionController constructor is being injected by StructureMap, and that code is not represented in my example. If you just want to get it to run, you can remove the constructor, and instantiate FormsAuthenticationService() inside the Expire() method.

    Noah

    Reply
  • Ranu Mandan / May 28, 2012

    Thank you buddy..you saved my day. Excellent post

    Reply
  • Jeff in Seattle / June 8, 2012

    Great article, but do you mind posting the solution within zip file?

    I tried pulling it all together, but I must be missing some pieces to get it to work.

    Thanks

    Reply
  • santosh kumar patro / April 12, 2013

    Hi ,

    I am able to implement the functionality by going through the code. I have made one change in the code as mentioned below:

    location.href = expireSessionUrl; in the endSession method to the following code:

    window.location.replace(expireSessionUrl);

    and once user clicks on the Log Out button present in the Session warning message dialog box he is navigated to the LogOut view. But here if he clicks the browser back button he is navigated to the previous page.

    I have the following modified Expire method as mentioned bleow:

    [Authorize]
    public virtual ActionResult Expire()
    {
    Session.Clear();
    FormsService.SignOut();
    HttpContext.Response.Cache.SetExpires(DateTime.UtcNow.AddDays(-1));
    HttpContext.Response.Cache.SetValidUntilExpires(false);
    HttpContext.Response.Cache.SetRevalidation(HttpCacheRevalidation.AllCaches);
    HttpContext.Response.Cache.SetCacheability(HttpCacheability.NoCache);
    HttpContext.Response.Cache.SetNoStore();
    return Redirect(“/”);
    }

    Can you please guide me in resolving the issue.

    Thanks In Advance
    Santosh Kumar Patro

    Reply
  • chandu / April 23, 2013

    Grt article. Can you please provide us with downloadable code?

    Thanks

    Reply
  • Greg / February 25, 2014

    This was brilliant and saved me a ton of time, thanks!

    Reply

Leave a Comment

Current day month ye@r *