Last week a follower on Twitter let me know that RIAForge doesn't nicely handle session time outs and forms. He had been working on a new project submission and had let his session time out. When he submitted the form he lost what he had typed in. While I haven't quite fixed that yet - I did work on a small demo at lunch time that both demonstrates this problem and shows one way of working around it. Let's begin by looking at the application in it's current "dumb" form.
Let's begin with the critical piece - the Application.cfc file:
component {
this.name="logindemo";
this.sessionManagement="true";
this.sessionTimeOut = createTimeSpan(0,0,2,0);
public boolean function onApplicationStart() {
return true;
}
public boolean function onRequestStart(string req) {
var append = "";
//handle an authentication
if(structKeyExists(form, "login") && structKeyExists(form, "username") && structKeyExists(form, "password")) {
if(form.username == "admin" && form.password == "password") session.loggedin = true;
else {
append = "?error=1";
}
}
//force login if not authenticated
if(!session.loggedin && !find("login.cfm", arguments.req)) location(url='login.cfm#append#',addtoken=false);
return true;
}
public void function onSessionStart() {
session.loggedin=false;
}
}
As this is fairly standard I won't go over every part. The critical parts are within onRequestStart. I have code to detect a login as well as code to force you to a login page if you aren't logged in. The login.cfm file is just a basic form so I won't post it here. (I've got a zip at the end of this entry though with the complete source code.) Now let's imagine a simple form:
<cfparam name="form.name" default="">
<cfparam name="form.email" default="">
<cfparam name="form.comment" default="">
<!--- super fast, simple validation --->
<cfset errors = "">
<cfset showForm = true>
<cfif structKeyExists(form, "send")>
<!--- quickly trim/htmlEditFormat --->
<cfloop item="field" collection="#form#">
<cfset form[field] = trim(htmlEditFormat(form[field]))>
</cfloop>
<cfif not len(form.name)>
<cfset errors &= "Please include your name.<br/>">
</cfif>
<cfif not len(form.email) or not isValid("email", form.email)>
<cfset errors &= "Please include your valid email address.<br/>">
</cfif>
<cfif not len(form.comment)>
<cfset errors &= "Please include your comments.<br/>">
</cfif>
<cfif errors is "">
<!--- here is where we would email the comments --->
<cfset showForm = false>
</cfif>
</cfif>
<cfif showForm>
<cfoutput>
<p>
Use the form below to send us contact information.
</p>
<cfif len(variables.errors)>
<p>
<b>Please correct the following error(s):<br/>#variables.errors#</b>
</p>
</cfif>
<form action="contact.cfm" method="post">
<p>
Your name:<br/>
<input type="text" name="name" value="#form.name#">
</p>
<p>
Your email address:<br/>
<input type="text" name="email" value="#form.email#">
</p>
<p>
Your comments:<br/>
<textarea name="comment">#form.comment#</textarea>
</p>
<p>
<input type="submit" name="send" value="Send">
</p>
</form>
</cfoutput>
<cfelse>
<p>
Thank you for your feedback.
</p>
</cfif>
This form consists of three simple fields. Normally I'd have the error checking in a controller file, but hopefully this won't offend my Model-Glue friends. Now used as is - and with the quick 2 minute session timeout I setup - it would be easy for a user to end up losing their form when they fill it out. If they take longer than two minutes to fill it out - their data is essentially lost. Lost in time. Like tears in the rain. (Sorry - got distracted.)
Let's look at how we could handle this nicer. In my 'dream' world the user hits submit on the form - is asked to relogin - and is then returned to the form as if nothing had happened. If the form data was all good, then the form process is complete. If there was some error, then it is displayed. Again - it should act as if the session timeout never happened. Here is my new Application.cfc file:
component {
this.name="logindemo";
this.sessionManagement="true";
this.sessionTimeOut = createTimeSpan(0,0,0,15);
public boolean function onApplicationStart() {
return true;
}
public boolean function onRequestStart(string req) {
var append = "";
var togo = "";
//handle an authentication
if(structKeyExists(form, "login") && structKeyExists(form, "username") && structKeyExists(form, "password")) {
if(form.username == "admin" && form.password == "password") {
session.loggedin = true;
if(structKeyExists(session, "requestedurl")) {
togo = session.requestedurl;
structDelete(session, "requestedurl");
location(url=togo, addtoken=false);
}
} else {
append = "?error=1";
}
}
//force login if not authenticated
if(!session.loggedin && !find("login.cfm", arguments.req)) {
session.requestedurl = arguments.req & "?" & cgi.query_string;
if(!structIsEmpty(form) && !structKeyExists(form, "login")) session.formdata = serializeJSON(form);
location(url='login.cfm#append#',addtoken=false);
}
//Got Form?
if(session.loggedin && structKeyExists(session, "formData") and isJSON(session.formData)) {
structAppend(form,deserializeJSON(session.formData));
structDelete(session, "formData");
}
return true;
}
public void function onRequestEnd(string req) {
}
public void function onSessionStart() {
session.loggedin=false;
}
}
Ok, we've got a few changes here so let's pick them apart. First, let's focus on the block that occurs when you aren't logged in:
//force login if not authenticated
if(!session.loggedin && !find("login.cfm", arguments.req)) {
session.requestedurl = arguments.req & "?" & cgi.query_string;
if(!structIsEmpty(form) && !structKeyExists(form, "login")) session.formdata = serializeJSON(form);
location(url='login.cfm#append#',addtoken=false);
}
I made two changes here. First - I noticed what your original request was. Both the file and the query string. Secondly I look to see if the form contained any data. I want to ensure I'm not posting a login itself so I check for that as well. If so, I copy the data into the session scope. (I just realized that I serialized it and I really didn't need to. But using JSON would allow me to do other things - like perhaps use the client scope.) Now let's go back up to the 'you logged in' block:
//handle an authentication
if(structKeyExists(form, "login") && structKeyExists(form, "username") && structKeyExists(form, "password")) {
if(form.username == "admin" && form.password == "password") {
session.loggedin = true;
if(structKeyExists(session, "requestedurl")) {
togo = session.requestedurl;
structDelete(session, "requestedurl");
location(url=togo, addtoken=false);
}
} else {
append = "?error=1";
}
}
The main change here is that we now look for the 'requestedurl' value. If it exists, we push you there. This will handle returning the user to the contact form. Now let's look at the final block:
//Got Form?
if(session.loggedin && structKeyExists(session, "formData") and isJSON(session.formData)) {
structAppend(form,deserializeJSON(session.formData));
structDelete(session, "formData");
}
The final bit simply looks for the stored form data - deserializes it - and appends it to the Form scope. And that's it. To the contact form nothing has changed at all. It's the exact same code. But you can now handle a session time out gracefully and not lose anything in terms of the user's content.
This system is not perfect of course. File uploads will be lost. But - it is certainly better than nothing. How have other people solved this problem on their web sites? Click the big demo button to check it out (and note that I've set the session timeout to 15 seconds). You can download the code as well.
Archived Comments
It seems like this would be a great place to use the SessionStorage and LocalStorage capabilities of modern browsers. Add an onblur/setTimeout to your forms to save the data into the storage, and then pull it back out if the CF Session times out.
http://hacks.mozilla.org/20...
http://dev.w3.org/html5/web...
If you know the session timeout value, say 30 minutes, you could send an ajax request in the background after every 29 or so minutes which will extend the session timeout.
The call doesn't need to return anything but let the server know you're still active.
Damn Ray! How long are your lunches!
Very useful Ray but I have a question: Is there a benefit to writing your Application.cfc in CFScript instead of using CF tags?
Yes. Doing it in script is cooler and makes you more attractive.
Ok - maybe not. No - I prefer script for my CFCs.
I see. Guess I'm switching to CFScript from now on ;)
Technically it has a few issues still - it is still not 100% compatible with tags. But that's now off topic. ;)
This CFC addresses the same problem and is worth a look:
http://userlove.riaforge.org/
Omg - I love that url:
"userlove"
:)
I solved this problem by creating a UUID when the user logs in and passing that UUID in the form scope.
If the session timed out, Application.cfc would look up the UUID.
So you could login with your username/password, or login with the last UUID that was created when you logged in with username/password.