| 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".
Download
the code that accompanies this article
| Peter Bromberg is a C# MVP, MCP, and .NET consultant who has worked in the banking and financial industry for 20 years. He has architected and developed web - based corporate distributed application solutions since 1995, and focuses exclusively on the .NET Platform. Pete's samples at GotDotNet.com have been downloaded over 41,000 times. You can read Peter's UnBlog Here. --><-- NOTE: Post QUESTIONS on FORUMS! |  |
Do you have a question or comment about this article? Have a programming problem you need to solve? Post it at eggheadcafe.com forums and receive immediate email notification of responses.
|