Ask Ben: Creating Single Location Logins
Ben, I was hoping you could point us in the right direction. We are creating an application that can be accessed from the public and as a security precaution we want to make sure that one account can only be used from one location at a time. This way, if I sign on using one terminal, I know that any left-open sessions on another terminal will automatically be disabled. I've done logins before, but not sure how to go about this. Thanks in advance!!!
When creating a single-location login style application, the key is to have a way for each session to announce itself as the currently "Active" session for a particular set of credentials. This way, when a user logs in using one computer, their new session will announce itself as the rightful session and any existing sessions (for the same set of credentials) will detect this, see that they are no longer valid sessions, and automatically logout (or however else you may want to handle this).
Because the clients (browsers) are distributed on the internet, the data regarding which sessions are the currently active ones must be centralized around the application. This might be in a database table or some other form of cached memory structure. The larger your application, the more likely you will need to store this type of information in a database so as not to run out of RAM. However, since this is just a demo of the single-location sign-on principle and I know that RAM is not an issue, I will be caching this data in the APPLICATION scope.
Now, in order for this concept to work, every session related to a single set of credentials needs a way to identify itself both as part of this one group and also as unique, individual session. To do this, I am using a "User ID" (unique identifier for a user record with the given credentials) to index the group and a UUID (universally unique identifier) to index each individual session.
Once we have these data values in place, we simply need to enforce a relationship between the active session's UUID and the cached group ID for the given credentials. And, since a new session might begin at any moment, this relationship has to be checked for each page request. Because this check needs to happen so often, I prefer using cached data structures rather than database calls for as long as is practical.
To get a better understanding of this, let's take a look at our sample Application.cfc. Notice that in the APPLICATION scope, we have a SessionKeys cache which is indexed using the user IDs:
<cfcomponent
output="false"
hint="I provide application level event handlers and settings.">
<!--- Define the application settings. --->
<cfset THIS.Name = "SingleLocationSessionDemo" />
<cfset THIS.ApplicationTimeout = CreateTimeSpan( 0, 0, 5, 0 ) />
<cfset THIS.SessionManagement = true />
<cfset THIS.SessionTimeout = CreateTimeSpan( 0, 0, 5, 0 ) />
<cffunction
name="OnApplicationStart"
access="public"
returntype="boolean"
output="false"
hint="I run when the application is being (re)initialized.">
<!---
Clear the application scope (in case we are
re-initializing rather than booting up for the
first time).
--->
<cfset StructClear( APPLICATION ) />
<!---
Create a structure to hold onto the unique session
keys (based on user ID). For this demo, I am caching
in-memory, but in a large app, this would probably
be a database table.
--->
<cfset APPLICATION.SessionKeys = {} />
<!--- Return continue-loading flag. --->
<cfreturn true />
</cffunction>
<cffunction
name="OnSessionStart"
access="public"
returntype="void"
output="false"
hint="I run when the session is being (re)initialized.">
<!--- Define the local scope. --->
<cfset var LOCAL = {} />
<!--- Cache the ID/Token so that we don't lose them. --->
<cfset LOCAL.CFID = SESSION.CFID />
<cfset LOCAL.CFTOKEN = SESSION.CFTOKEN />
<!---
Clear the session scope (in case we are
re-initializing rather than booting up for the
first time).
--->
<cfset StructClear( SESSION ) />
<!---
Re-set the ID/Token values to maintain session
hooks properly.
--->
<cfset SESSION.CFID = LOCAL.CFID />
<cfset SESSION.CFTOKEN = LOCAL.CFTOKEN />
<!--- Param the default values of the user. --->
<cfset SESSION.User = {
ID = 0,
SessionID = "",
LoggedIn = false
} />
<!--- Return out. --->
<cfreturn />
</cffunction>
<cffunction
name="OnRequestStart"
access="public"
returntype="boolean"
output="false"
hint="I run when the request is being initialized.">
<!--- Define arguments. --->
<cfargument
name="Page"
type="string"
required="true"
hint="I am the template being requested."
/>
<!--- Define the local scope. --->
<cfset var LOCAL = {} />
<!---
Check to see if the application is being reset
manually with a URL flag.
--->
<cfif StructKeyExists( URL, "reset" )>
<!--- Manually reset app and session. --->
<cfset OnApplicationStart() />
<cfset OnSessionStart() />
</cfif>
<!---
Check to see if this user is logged in at all. We
only care about checking the single-location session
if they are logged-in.
--->
<cfif SESSION.User.LoggedIn>
<!---
Now that we know this user is logged-in, we need
to check to see if their login is still valid.
Because we are reading / writing to this share
data in multiple places, this feels like it might
be an appropriate place for a lock. To limit the
bottle-necking affects of the lock, I am making
it session-id-based.
--->
<cflock
name="login-check-#SESSION.User.ID#"
type="exclusive"
timeout="5">
<!---
Check to see if the current session is the
ACTIVE session location for this user. In
order for that to be true, the session-cached
ID must be the same as that cached in our
APPLICATION.
--->
<cfif (
(NOT StructKeyExists( APPLICATION.SessionKeys, SESSION.User.ID )) OR
(APPLICATION.SessionKeys[ SESSION.User.ID ] NEQ SESSION.User.SessionID)
)>
<!---
Either the user's session ID has not been
cached in the APPLICATION yet, OR this
same user has signed on in a different
location, rendering the current session
as NO LONGER VALID.
Make sure to flag this user as being no
longer logged-in. In our simple demo,
this is denoted by the user ID and
logged-in flag.
--->
<cfset SESSION.User.ID = 0 />
<cfset SESSION.user.LoggedIn = false />
</cfif>
</cflock>
</cfif>
<!--- Return continue-loading flag. --->
<cfreturn true />
</cffunction>
<cffunction
name="OnRequest"
access="public"
returntype="void"
output="true"
hint="I execute the appropriate page template.">
<!--- Define arguments. --->
<cfargument
name="Page"
type="string"
required="true"
hint="I am the template being requested."
/>
<!---
Check to see if this user is logged-in. If so, then
include the requested template. If not, then include
the login page (unless of course the user is already
requesting the login-style pages (including the login
processing page).
--->
<cfif (
SESSION.User.LoggedIn OR
REFind( "login(_process)?\.cfm$", CGI.script_name )
)>
<!--- Include requested page. --->
<cfinclude template="#ARGUMENTS.Page#" />
<cfelse>
<!---
The user is not logged-in. So, no matter what
template they requested, force them to view the
login page.
--->
<cfinclude template="login.cfm" />
</cfif>
<!--- Return out. --->
<cfreturn />
</cffunction>
<cffunction
name="OnSessionEnd"
access="public"
returntype="void"
output="false"
hint="I run when the session is being ended.">
<!--- Define arguments. --->
<cfargument
name="Application"
type="struct"
required="true"
hint="I am the application scope used by the session."
/>
<cfargument
name="Session"
type="struct"
required="true"
hint="I am the session scope used by the session."
/>
<!---
Clear out the session ID tracking from the
application. This will make sure that the session
ends at all locations.
--->
<cfset StructDelete(
APPLICATION.SessionKeys,
ARGUMENTS.Session.User.ID
) />
<!--- Return out. --->
<cfreturn />
</cffunction>
</cfcomponent>
The magic here really happens in the OnRequestStart() application event method. At the start of every page request, we are checking two very important facts related to the current session:
- Is my user ID indexed in the application?
- Does the UUID cached at my user ID match my session's UUID?
If either one of these facts is not true, then the current session is not the currently active session. If the user ID has not been cached yet, then it means that this user is either not yet logged in or that a parallel session with the same user ID has explicitly logged out. In either case, the current session must not be considered logged in. If, however, the user ID is indexed in the key cache, but the cached UUID does not match the current session's UUID, it must mean that a parallel session has just logged in using the same credentials and has overwritten the current session's UUID. In that case, the current session must consider itself invalid (and must automatically logout in our scenario).
I am using a CFLock tag around the checking of the SessionKeys cache. In this demo, I would say that CFLock here is definitely not necessary; however, the more complicated the process gets for logging users in and out, the more likely you might want to put locking around this subroutine. Of course, it could easily be argued that since the cached is only ever updated when logging in and logging out, no real corruption or race conditions are possible. Regardless, to help fight against creating a bottle-neck, I am using an ID-based naming convention for the lock. This way, one user's security checks should not slow down another user's security checks.
Now that we see where the handshake / agreement has to occur at the start of every page request, let's take a look at where the SessionKeys cache is actually updated. First, let's take a look at the login processing page. Since this is just a demo, the user ID is hard coded, but the theory is the same:
<!---
Because this is just a demo, we are going to hardcode the
user ID so that all demo users are the same user. The session
key will be auto-generated for us.
--->
<cfset SESSION.User.ID = 4 />
<!---
Create a unique ID to make sure there is no chance that
another location instance of this user will have the same
session ID.
--->
<cfset SESSION.User.SessionID = CreateUUID() />
<!--- Flag user as logged-in. --->
<cfset SESSION.User.LoggedIn = true />
<!---
We need to update the session-cache using the user ID and
the new session ID.
NOTE: Because this is a part of the application where race
conditions might actually apply, I am going to lock this
area. To make this less of a bottle-neck in the system, I am
going to make the name of the lock based on the session ID.
--->
<cflock
name="login-check-#SESSION.User.ID#"
type="exclusive"
timeout="5">
<!--- Cache this single-location session ID. --->
<cfset APPLICATION.SessionKeys[ SESSION.User.ID ] = SESSION.User.SessionID />
</cflock>
<!--- Redirect to home page. --->
<cflocation
url="./index.cfm"
addtoken="false"
/>
As you can see, each logged-in session gets is own UUID. When a user logs in, we take the UUID for the current session and cache it in the SessionKeys. This will make sure the current session location is deemed as the only valid one in the context of other sessions previously logged-in using the same credentials.
Likewise, as a precaution, when a user logs out, we delete the user ID index from the SessionKeys cache to indicate that no session using these credentials can be considered valid any longer:
<!---
We need to remove the session key from the APPLICATION cache
so that this user is fully logged out in every location. Now,
again, I don't really think locking here is necessary, but
the more complex the inner workings of this, the more it may
be worth while.
--->
<cflock
name="login-check-#SESSION.User.ID#"
type="exclusive"
timeout="5">
<!---
Reset the user and erase it from the application's
session cache.
--->
<cfset StructDelete(
APPLICATION.SessionKeys,
SESSION.User.ID
) />
</cflock>
<!--- Reset the user. --->
<cfset SESSION.User.ID = 0 />
<cfset SESSION.User.LoggedIn = false />
<!--- Redirect to homepage. --->
<cflocation
url="./index.cfm"
addtoken="false"
/>
There's a lot more that can go into this type of security including the way in which conflicting sessions are handled; but, I hope that this can at least give you some ideas and help point you in the right direction.
Want to use code from this post? Check out the license.
Reader Comments
Hey Ben... bear with me here till the end of my comment, eh?
Why not just have your login code store the user name for every successful login in the application scope. They've logged in on that machine, in that browser... so if they try to log in again, it's got to be from somewhere else, period. From there it's a simple matter of using onSessionEnd() in tandem with the logout code to remove them from the application scope.
This could be accomplished with less than 15 lines of additional code if you already have a working login/logout mechanism. If you're using the session scope to store your login marker, then you're not logged out till your session times out or you log out, so checking login validity on every request is redundant. With onSessionEnd() you have the ability to toggle session.loggedIn AND remove them from the login tracker in the application scope.
So, I guess what I'm really getting at is this: Either you have some really good reason for making this so complicated that I'm missing and really need you to explain to me (because that happens more often than I like to admit) or you've grossly overcomplicated a very simple solution to a really common problem... if it's the former I really do want to find out what I'm missing, and if it's the latter then I really have to ask:
Why, dude??? Why?? :)
@Jared,
There is a good chance I am grossly over complicating it - I wouldn't put that past me :) But, after reading your comment, I am not sure if we would be accomplishing the same thing. My primary concern is that only one session per credential can be active at a time. I am not concerned with where that new session is coming from.
So, if I got the gist of your comment, here is what I think the difference is:
* You ONLY allow one session until it is explicitly logged out or times out.
* I ONLY allow one session at a time, but no particular session has precedence other than on a purely last-come basis.
Here is the scenario I am playing out in my mind that I think my scenario works to protect:
I use GMail at home, but since I live alone, I tend to just leave the FireFox window open with my GMail logged in. However, I totally forgot that the exterminator is coming today (while I'm at work). Being the nervous nelly that I am, I quickly log into my GMail account from my work computer knowing that this login-action will automatically logout (or disable) my GMail account window at home (thereby preventing the exterminator from being able to snoop through my always-logged-in GMail account at home).
Does that make more sense?
Not related to the topic at all, but did you know that GMail will show you other active sessions and allow you to log them out? Logging in from a different place does not automatically log out the other session, you have to explicitly do it.
Ahh, OK. So I was missing part of the picture... I hadn't read the code closely enough. Sorry. Yeah, that makes a certain amount of sense... beacuse you have to associate usernames with session IDs of some sort (J2EE or otherwise), you have to have more than just a user id cached in the application scope.
As I'm mentally walking thru the process I can see about 4 different ways to accomplish this (accounting for the location-based login) but only one of them really reduces the code... and that's to store something like:
application.sessionStore[userID] = session.urlToken
That simplifies some things... and the cfif could be cut back to
<cfif structKeyExists(application.sessionStore,userID) and application.sessionStore.userID NEQ session.urlToken>
So the reduction is more in the statements themselves, rather than, as before, the structure of the solution. Other than the crazy things you're doing to your session scope ;) I guess I have to say decent job dude. :)
@Todd,
That is bitchy!!! That is exactly what I was envisioning as I was writing this out (although they have the manual sign out vs. the implicit). That is way cool. I had no idea that was there. Thanks for pointing that out.
@Jared,
Thanks man. Your concept was good as well, just solving a slightly different problem.