Saturday, April 9, 2011

Preventing Multiple Logins in ASP.NET

Recently my site partner and chief server farm engineer Robbe Morris and I went back and forth a couple of times about how you could prevent multiple users of an ASP.NET application from using the same credentials to log in at the same time from different machines, thereby circumventing a particular licensing scheme that was based on the allowed number of concurrent users.   A pretty common problem, actually. Well, we searched the Net and really were unable to come up with any resources that provided a real answer. So that made us start to think... And now, coming to computers everywhere...
We talked about the fact that the classic ASP Session_OnEnd handler is widely known to be pretty unreliable. However, in ASP.NET the corresponding Global class handler, Session_End, is very reliable. Then we talked about "what if" scenarios, such as what if the ASP.NET worker process was recycled? If so, I reasoned, it didn't matter whether you were using Session, Application or Cache, all of your stuff would be lost. The only exceptions to this would be if you were using the ASP.NET State Server service for your Session, or the SQL Server Session option. In particular, there is a second script available for the SQL Server Session option that does not use the TempDB, and this means that even if the whole machine goes down, when it comes back up, the Session data will still be there. Both StateServer and SQL Server Session options run out of process, so it really doesn't matter if the ASPNET_WP.EXE worker process is recycled - the sessions, which run out of the ASP.NET worker process and rely on the Session Cookie that's stored at the browser, will still be there.
The main issue is that if you put some sort of "lock" on the user record because somebody has logged in, and then they close their browser and you don't have a reliable way of determining that their session has expired so you can remove the lock, you are likely to get calls to your Tech Support desk from users complaining they cannot log in! (trust me, I have good reports that this has happened...)
The big problem, it turns out, is that with StateServer and SQL Server Sessions, the Session_End event in Global is never fired. Only InProc mode fires this. So in order to avoid Tech Support coming after us with hatchets and knives, we would need to come up with some sort of reliable surrogate for the Session_End event.   Robbe took off on his own angle here and wrote an excellent article about using the Cache class to handle some of these issues. You can  read it here. Robbe also discusses how to use the callback mechanism in the Cache class to handle the situation where the item is removed from the Cache. In fact, he's determined that this even fires when the ASP.NET worker process recycles under normal conditions (such as when specified in machine.config), thereby enabling us to serialize Cache items to a database for later rehydration.
As it often turns out, sometimes the simplest solution to a problem is also the most elegant and even the most scalable. The solution to the multiple login problem that I came up with and present here simply uses the Cache with SlidingExpiration as a surrogate for a Session_End event. First, here's the logic:
1) User logs in, we check the Cache using username+password as the key for the Cache Item. If the Cache item exists, we know that the login is already in use, so we kick them out. Otherwise, we authenticate them (database, etc) and let them in.
2) After we have let them in, we set a new Cache item entry with a key consisting of their username+password, with a sliding expiration equal to the current Session Timeout value. We can also set a new Session variable, Session["user"], with a value of the username+password, so that we can do continuous page request checking and Cache updating on every page request during the user's session. This gives us the infrastructure for "duplicating" the missing Session_End functionality.
3) Now we need a way to update the Cache expiration on each page request. You can do this very elegantly in the Application_PreRequestHandlerExecute handler in Global, because the Session object is available and "live" in this handler. In addition, this event is fired on every page request, so we don't need to put a single line of extra code in any of our pages. We use the Session["user"] value to get this user's key to retrieve their Cache Item, thus resetting it and automatically setting the sliding expiration to a fresh timeout value. Whenever you access a Cache item, its SlidingExpiration property (if properly configured) is automatically updated. When a user abandons their session and no pages are requested for a period of time, the SlidingExpiration of their Cache Item eventually expires, and the item is automatically removed from the Cache, thereby allowing somebody with the same username and password to log in again. No fuss, no muss! Works with InProc, StateServer and SQL Server Session modes!
Now let's take a look at some code as to how this can be implemented, in its most basic form:
In web.config (StateServer mode, with a one minute timeout to make testing easier):
<sessionState
mode="StateServer"
stateConnectionString="tcpip=127.0.0.1:42424"
sqlConnectionString="data source=127.0.0.1;user id=sa;password=letmein"
cookieless="false"
timeout="1"
/>

In Global.asax.cs:
protected void Application_PreRequestHandlerExecute(Object sender, EventArgs e)
{
// Let's write a message to show this got fired---
Response.Write("SessionID: " +Session.SessionID.ToString() + "User key: " +(string)Session["user"]);
if(Session["user"]!=null) // e.g. this is after an initial logon
{
string sKey=(string)Session["user"];
// Accessing the Cache Item extends the Sliding Expiration automatically
string sUser=(string) HttpContext.Current.Cache[sKey];
}
}

In your Login Page "Login" button handler:
private void Button1_Click(object sender, System.EventArgs e)
{
//validate your user here (Forms Auth or Database, for example)
// this could be a new "illegal" logon, so we need to check
// if these credentials are already in the Cache

string sKey=TextBox1.Text+TextBox2.Text;
string sUser=Convert.ToString(Cache[sKey]);
if (sUser==null || sUser==String.Empty){
// No Cache item, so sesion is either expired or user is new sign-on
// Set the cache item and Session hit-test for this user---

TimeSpan SessTimeOut=new TimeSpan(0,0,HttpContext.Current.Session.Timeout,0,0);
HttpContext.Current.Cache.Insert(sKey,sKey,null,DateTime.MaxValue,SessTimeOut,
   System.Web.Caching.CacheItemPriority.NotRemovable,null);
Session["user"]=TextBox1.Text+TextBox2.Text;
// Let them in - redirect to main page, etc.
Label1.Text="<Marquee><h1>Welcome!</h1></marquee>";

}
else
{
// cache item exists, so too bad...
Label1.Text="<Marquee><h1><font color=red>ILLEGAL LOGIN ATTEMPT!!!</font></h1></marquee>";
return;
}

}

You can try logging in with any username / password you want. If you try again, you won't get in (unless you wait long enough for the Cache Item to expire). Each time you try, the SlidingCache timeout property of the Cache item gets updated (same with any page request). You can try logging in from another browser window, or even another machine. It doesn't matter, you won't be able to abuse the Big Brother license login policy.
What about a Web Farm?
There are certainly trade-offs to be considered when dealing with Sessions on a web farm. StateServer normally is set up to act as a central session server for all the servers in a web farm. By definition, you have to pick a machine and all the web.config entries point to the IP address of that machine. However, I know of at least one organization that uses StateServer on each and every machine on a web farm, and sticky IP to make sure that everybody always returns to the machine where their Session was started. While this configuration might seem like "shooting yourself in the foot", it is conceiveable that an organization might opt for this where redundancy, rather than scalability, is the overriding consideration. (Of course, if you have StateServer and Sticky IP on every machine in the farm, and only one SQL Server with no clustering and failover, the jury might still be out on how much redundancy you have actually achieved).

If your overriding concern is that the particular StateServer machine may "go down" then your only other option would be to use the SQL Server session mode and choose the SQL Script "InstallPersistSqlState.sql" which specifically does not use the TempDB (TempDB disappears when a machine is rebooted).
There is no sharing of cache between web applications on a farm. Also it was brought to my attention by reader Paul Abraham (who has provided helpful comments on more than one occasion here) that if we have a multi-processor machine, we can configure it to webgarden mode, in which case we will have more than one worker process. Consequently, we will then have more than one instance of the System.Web.Caching.Cache class operative in our application. (one instance of this class is created per application domain) In this context, we would then have the same problem synchronizing Cache in WebGarden mode that we would in a web farm scenario.
In these situations, you can be creative with CacheDependency and CacheItemRemovedCallback. For example, on each web server (or AppDomain) your cache objects can depend on a special file, and on cache addition or removal touch that file so that cache objects on other web servers can get notified and be removed. Now that I think of it, you could even use the very same file that the dependency is created on to store the data that each server needs to get in order to update its Cache.
There is a bug in ASP.NET 1.0 where multiple web applications having cachedependency on a file at a UNC share is not working. So one workaround is to have one file per web application per web server, and during update you would touch all of them. Another thing to remember about a server farm - if you are sharing Session state with StateServer or SQL Server, the SessionID, which is contained in a browser cookie or munged on the URL does get transmitted for the particular user no matter which server their request lands on.
So if you match the ASP.NET Session ID to the username+Password of the login, you have a method to check the Cache on any of the servers to handle both session checking and timeout updating. There is also an excellent article by David Burgett on MSDN about using in-memory Datasets and a WebService to synchronize data in a farm.
Cache Synchronization Down on the Farm
While creating a shared Cache object among servers on a farm is beyond the scope of this article, it is definitely "do-able" and hopefully the above ideas will give you some food for thought. Synchronization of Cache on a server farm is one thing that Mircrosoft left out of the Cache class. However, based on the ideas brought up in this article, it can be seen that there are likely a number of uses for such an arrangement.

One way to set up Cache synchronization among servers in a web farm is to use SQL Server and have two tables - one with a list of the servers currently active in the web farm, and a second table to hold "Update" information for the cache. This "CacheItems" table would probably need at least three or four columns: a varchar column for the cache "key" (in this case username+password), a DateTime column for current Sliding Expiration value, another DateTime column for Absolute Expiration (if used), and finally an IMAGE column to hold the byte stream from the serialized Object Graph of the Cache item, using the BinaryFormatter., in order to store complex objects from the Cache in the same way that SQL Server Session state does. In this manner it would be possibly not only to synchronize the Cache among servers in a farm, but to actually create a backup "Persistent Cache" datastore from which a rebooting or first - time farm member machine can hydrate its Cache and "join the chorus" , so to speak.
So for example, when a session expires in the Cache on one server, you can make an update using the SQL Server to a Cache persistent storage table. This update can made through a WebRequest which is sent to each of the servers on the farm to a special aspx receiver page that is in each app domain. This receiver page basically gets the "notification" and instructs the page to go to the SQL server and update it's resident copy of the Cache from the SQL Server table described above. Each machine would have a page that is capable of handling this process, and thus every machine on the Farm would have the capability both to update the backup store and notify the other webservers, as well as to receive a notification that it needs to retrieve and process the update record(s) from SQL Server.

The zip below is a full test solution. Simply unzip it into a folder called "logintest", right - click the folder, choose "Web Sharing" and share it as an IIS virtual root, and you are "good to go".

1 comment:

  1. This is a nice article..
    Its easy to understand ..
    And this article is using to learn something about it..

    c#, dot.net, php tutorial, Ms sql server

    Thanks a lot..!
    ri80

    ReplyDelete