Whenever a browser session times out, any saved session data will not be available which will likely cause some type of exception when trying to access it. There are different approaches to address this issue.
- Set a timer in the browser for the same length as the timeout value and then redirect to a “SessionExpired” page when the timer expires.
- Create a custom ActionFilterAttribute which overrides the OnActionExecuting method and check the actual session status before each action and redirect to the “SessionExpired” page as needed.
- Set a timer at the server for each session ID and notify the browser when the timer has expired so it can direct the user to the “SessionExpired” page.
For all of these approaches you will need a “SessionExpired” page.
Here is an example of the SessionExpired.cshtml page source:
@{
ViewBag.Title = "Session Expired";
}
<h2>@ViewBag.Title</h2>
<div class="row">
<div class="col-lg-9 col-md-12 col-sm-12 col-xs-12">
<p>Your session has expired.</p>
<p>Please press Continue to start a new session.</p>
<div>
@Html.ActionLink("Continue", "Index", "Home", null, htmlAttributes: new { @style = "display: inline-block;", @class = "btn btn-sm btn-primary" })
</div>
</div>
</div>
<script type="text/javascript">
// Keep user from going back to previous page with Back button
history.pushState(null, null, location.href);
history.back();
history.forward();
window.onpopstate = function () { history.go(1); };
</script>
The session timeout value can be set in the web.config file in the section with the following line:
<sessionState timeout="20"></sessionState>
where the timeout value is the number of minutes.
Be sure to create an ActionResult based method in the controller for the SessionExpired page like this:
public ActionResult SessionExpired()
{
return View();
}
Approach 1 : Using a timer in the browser/client
Using jQuery/JavaScript, set a timer which will automatically redirect the browser to a “SessionExpired” page. Here is the code to include in the <script> section of the HTML page (Note: In an MVC application, put this in the _Layout.cshtml page so it will work for all pages that use that layout page). The URL generated by the @Url.Action is the page that tells the user that the session has expired. The code checks to see if it is already on the “SessionExpired” page so the timer doesn’t keep getting activated.
if (window.location.pathname != '@Url.Action("SessionExpired", "Home")') setTimeout(SessionEnd, @Session.Timeout*60000 - 30000); // timeout 30 sec before session
function SessionEnd() {
window.location = '@Url.Action("SessionExpired", "Request")';
}
There are potential problems with this approach. There is no actual connection between the timer and the actual browser session status other than timing. The timer is set for the length of time equivalent to the session timeout (or some number of seconds before that as a buffer to insure that you don’t try to access session variables after the session has really timed out) and it is assumed that when the timer trips, the session has timed out. One of the major breakdowns in this line of thinking is that it intrinsically assumes that there is only one page associated with the session at a time. However, what if another page is opened in a new tab? This page is associated with the same session but will have a different timer and the timer on the first page will continue and eventually timeout even though actions taken with the second page would reset the session timeout.
Approach 2 : Using a custom ActionFilterAttribute
Create a custom ActionFilterAttribute which overrides the OnActionExecuting method and check the actual session status before each action and redirect to the “SessionExpired” page as needed. Here is an example of the code:
public class SessionTimeoutAttribute : ActionFilterAttribute
{
public override void OnActionExecuting(ActionExecutingContext filterContext)
{
string path = HttpContext.Current.Request.Url.AbsolutePath;
string referrer = HttpContext.Current.Request.UrlReferrer == null ? "" : HttpContext.Current.Request.UrlReferrer.AbsolutePath;
string expiredPath = "/Home/SessionExpired";
// limit to last 2 segment of path (if they exist)
string beforeLastSlash = path.Substring(0, path.LastIndexOf('/'));
if (beforeLastSlash.Length > 0)
path = path.Substring(beforeLastSlash.LastIndexOf('/'));
// no need to redirect if already at the session expired page
if (path != expiredPath && referrer != expiredPath)
{
HttpContext ctx = HttpContext.Current;
string sessionID = ctx.Session.SessionID;
if (ctx.Session.IsNewSession && HttpContext.Current.Request.UrlReferrer != null)
{
string sessionCookie = ctx.Request.Headers["Cookie"];
if ((null != sessionCookie) && (sessionCookie.IndexOf("ASP.NET_SessionId") >= 0))
{
filterContext.Result = new RedirectResult("~/Home/SessionExpired");
return;
}
}
}
base.OnActionExecuting(filterContext);
}
}
This is implemented as a decoration at the controller or action level within the controller code.
[SessionTimeout]
The main issue with this approach is that it will not route to the “SessionExpired” page until an action is attempted within the controller code so the user will not know that the session has timed out until he tries to do something and then is directed to the “SessionExpired” page.
Approach 3 : Using a timer at the server
This approach is similar to the first one in that it uses a timer and there is no actual connection between the timer and the actual session status other than timing. However, instead of keeping a timer in the browser for each page, a timer is kept at the server for each session ID. This can be implemented using the SignalR library (https://docs.microsoft.com/en-us/aspnet/signalr/). This approach is more involved than the other two and could be used in conjunction with the custom ActionFilterAttribute approach (see above).
Be sure and add a SessionExpired view as explained at the beginning of this document.
Add the NuGet package Microsoft.AspNet.SignalR to the MVC project.
In the project’s web.config, add the following within the <appSettings> section:
<add key="owin:AutomaticAppStartup " value="true" />
<add key="owin:appStartup" value="StartupConfiguration" />
Add a class file to the project called Startup.cs with the following contents (it should be at the same level as web.config and Global.asax):
using Microsoft.AspNet.SignalR;
using Microsoft.Owin;
using Owin;
[assembly: OwinStartup("StartupConfiguration", typeof(SignalRChat.Startup))]
using Microsoft.AspNet.SignalR;
using Microsoft.Owin;
using Owin;
[assembly: OwinStartup("StartupConfiguration", typeof(SignalRChat.Startup))]
namespace SignalRChat
{
public class Startup
{
public void Configuration(IAppBuilder app)
{
// SignalR Hub Startup
var hubConfiguration = new HubConfiguration();
hubConfiguration.EnableDetailedErrors = true;
hubConfiguration.EnableJavaScriptProxies = true;
hubConfiguration.EnableJSONP = false;
app.MapSignalR();
}
}
}
In App_Start\BundleConfig.cs add the reference to the signalR script file (change name for version installed):
bundles.Add(new ScriptBundle("~/bundles/custom").Include( "~/Scripts/js/jquery.maskedinput.js",
"~/Scripts/custom-functions.js",
"~/Scripts/js/jquery-ui.custom.js",
"~/Scripts/jquery.signalR-2.4.0.js" ));
Add a new folder in the project named SignalR and in that folder add a new class, NotificationHub.cs with the following contents:
using Microsoft.AspNet.SignalR;
using Microsoft.AspNet.SignalR.Hubs;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using System.Timers;
namespace StudentTransferAdmin.WebUI.SignalR
{
[HubName("notificationHub")]
public class NotificationHub : Hub
{
// timer for each session ID
private static Dictionary<string, Timer> _timers = new Dictionary<string, Timer>();
// keep track of connectionId/sessionID pairs so they can be removed from Hub groups
private static List<KeyValuePair<string, string>> myGroups = new List<KeyValuePair<string, string>>();
public Task JoinSession(string sessionName)
{
myGroups.Add(new KeyValuePair<string, string>(Context.ConnectionId, sessionName));
return Groups.Add(Context.ConnectionId, sessionName);
}
public void SetSessionTimer(int minutes, string sessionName)
{
try
{
_timers.Add(sessionName, new Timer());
_timers[sessionName].Interval = minutes * 60 * 1000;
_timers[sessionName].Elapsed += delegate { SessionTimeout(sessionName); };
_timers[sessionName].AutoReset = false;
_timers[sessionName].Enabled = true;
_timers[sessionName].Start();
}
catch (ArgumentException)
{ // already exists, reset timer
_timers[sessionName].Stop();
_timers[sessionName].Start();
}
}
private void SessionTimeout(string sessionName)
{
_timers[sessionName].Enabled = false;
_timers.Remove(sessionName);
// Call browser pages linked to this session to notify of timeout
Clients.Group(sessionName).timeout();
}
public override Task OnDisconnected(bool stopCalled)
{
// delete the association between the current connection id and session.
// Remove connections from the Hub group
foreach (KeyValuePair<string, string> kv in myGroups)
{
if (kv.Key == Context.ConnectionId)
{
Groups.Remove(kv.Key, kv.Value);
}
}
myGroups.RemoveAll(item => item.Key == Context.ConnectionId);
return base.OnDisconnected(stopCalled);
}
}
}
In the _Layout.cshtml, add the following right after the @RenderSection statement:
<script src="~/signalr/hubs" type="text/javascript"></script>
In _Layout.cshtml, in the script section:
- Create a reference to the SignalR Hub:
var signal = $.connection.notificationHub;
- Start and connect to the Hub (put this in the ‘document ready’ code):
$(document).ready(function () {
$.connection.hub.start().done(function () {
if (window.location.pathname != "/Home/SessionExpired") {
signal.server.joinSession("@Session.SessionID");
signal.server.setSessionTimer(@Session.Timeout, "@Session.SessionID");
}
}).fail(function (reason) {
alert("SignalR connection failed: " + reason);
});
});
- Add the callback function that will be called from the server Hub when the session timer expires:
signal.client.timeout = function () {
if (window.location.pathname != "/Home/SessionExpired")
window.location = '@Url.Action("SessionExpired", "Home")';
}
Logistically, this is how this all works:
- When a web page (that uses _Layout.cshtml) is loaded, it will make a connection with the server via SignalR
- Once the connection is complete, the client sends the server the SessionID (via function joinSession) which is saved into a group on the server which associates the SessionID with the ConnectionId.
- Then the client browser calls to the server (via function setSessionTimer) to start the timer associated with the current SessionID.
- If another web page is opened in a new tab, it has the same SessionID so the call to setSessionTimer will reset the timer associated with that SessionID.
- If a web page disconnects its SignalR connection, the OnDisconnected function at the server is called and the ConnectionId/SessionID is removed from the group that the server is maintaining. This is done whenever an existing web page refreshes or loads a new page in the current browser tab.
- If one of the session timers times out, the server will perform a callback to every client connection that is associated with that SessionID (via the timeout function defined at each client). This causes the browser to automatically redirect to the “Session Expired” page.