Archive for December, 2008

Tracking User Statistics with ColdFusion

I love to dabble in new things, about 3 months ago I started helping a friend fix up their Cold Fusion 7 website. I noticed a bunch of things I consider important missing from the site and was curious about the user demographics/stats (who are they, what browser are they using, what is the max resolution we can use on the site, etc…). After talking a bit, I decided to implement the following items to gather the data:

  1. Implement AWSTAT to monitor the website logs.
  2. Implement a custom internal stat tracker to gather info about users.

Here are the fields we required:

  1. User Name
  2. Browser Type & Version (e.g. IE5, IE6, IE7, FF3, Chrome ???)
  3. Operating System (e.g. Linux, Max, Windows)
  4. Default Language Supported (e.g. en, jp )
  5. Screen Resolution (e.g. monitor resolution 1024×768, 1280×1024, or 800×600)
  6. Screen Colors (e.g. 16bit, 24bit, 32bit)
  7. IP Address
  8. URL (with only the first query string parameter, since he uses FuseBox)
  9. Date
  10. Time

Some of the variables in our list are not available in Cold Fusion, they are browser based data that the DOM has available via JavaScript. To get the data to the server I created a a page called “count.cfm” to accept a query string parameters with the data from the client. This is just JS 101, as far as I know there is no other solution to getting client side information about the user to the server. There are a lot more stats you can obtain from the client, to find them look at the JavaScript “screen” and “navigator” objects . I started to stub out a little extra in my code below (e.g. Navigator.javaEnabled ), if you want to use this data you’ll need to add the logic to add the data in a GET request to “count.cfm”.

    var file = '/fusebox/count.cfm';
    w = screen.width;
    h = screen.height;
    v = navigator.appName;
    j = navigator.javaEnabled();

    if (v != 'Netscape') {
        c = screen.colorDepth;
    }
    else {
        c = screen.pixelDepth;
    }

    info = 'res=' + w + 'x' + h + '&js=' + j + '&col=' + c;

    document.write('<img style="display: none;" src="http://www.zachhunter.net/blog/' + file + '?' + info + '" alt="" />');

After the data is received in the “count.cfm” page, I normalize the data by replace some of the data with foreign keys. This keeps the stat log normalized and decreases the table size.

function LogStats()
{
if(isDefined("session.userid"))
{
    UserId = session.userid;
    UserName = session.username;
    AuthorityLevel = session.authoritylevel;
}
else
{
    UserName = "Anonymous";
    UserId = "";
    AuthorityLevel = "";
}

OS = getOs(CGI.HTTP_USER_AGENT);
BrowserType = getBrowser(CGI.HTTP_USER_AGENT);

if(isDefined("CGI.HTTP_ACCEPT_LANGUAGE"))
{
    Language = CGI.HTTP_ACCEPT_LANGUAGE;

    if(Language.IndexOf(";") GT 0)
    {
        Language = Mid(Source, 1, Source.IndexOf(";"));
    }
}
else
{
    Language = "(Unknown)";
}

IP = CGI.REMOTE_ADDR;
LogDate = DateFormat(Now(),"mm/dd/yyyy");
LogTime = TimeFormat(Now(), "hh:mm");

if(isDefined("CGI.HTTP_REFERER"))
{
    Source = CGI.HTTP_REFERER;

    if(Source.IndexOf("&amp;") GT 0)
    {
        Source = Mid(Source, 1, Source.IndexOf("&amp;"));
    }
}
else
{
    Source = "(Home Page)";
}

if(isDefined("CGI.HTTP_REFERER"))
{
    Referrer = CGI.HTTP_REFERER;
}
else
{
    Referrer = "";
}

if(isDefined("url.res"))
{
    Resolution = url.res;
}
else
{
    Resolution = "(Unknown)";
}

if(isDefined("url.js"))
{
    JavaScriptEnabled = url.js;
}
else
{
    JavaScriptEnabled = "(Unknown)";
}

if(isDefined("url.col"))
{
    Colors = url.col;
}
else
{
    Colors = "(Unknown)";
}

IdOs = GetIdOS(OS);
IdColors = GetIdColors(Colors);
IdRes = GetIdRes(Resolution);
IdBrowser = GetIdBrowser(BrowserType);
IdLang = GetIdLang(Language);
IdPath =  GetIdPath(Source);

Insert = "SET NOCOUNT ON;
          INSERT INTO tbl_Stats_Stats
          (username, [date], [time], ip, osid, colorid, browserid, resid, pathid,languageid)
          VALUES
          ('#UserName#', '#LogDate#', '#LogTime#', '#IP#', #IdOs#, #IdColors#, #IdBrowser#, #IdRes#, #IdPath#, #IdLang#);
          SELECT @@identity as LanguageId from tbl_stats_Languages";
InsertResults = cfquery(dsn="",sqlstring=Insert);
return InsertResults.LanguageId;
}

This probably took about 2 hours to throw together. I’m sure there is more than can be done to optimize the code but this way my first time programming with Cold Fusion. Additionally, since my script is targeted for an intranet application, I didn’t implement parameter validation which is a must for external websites! The largest amount of work was building the logic to parse the USER_AGENT so I could determine the browser and OS. These are very important, since our goal was to determine how we could redesign without negatively impacting the users.

This was deployed by putting the JS into the footer template (footer.cfm). The database script is included in the source code download, along with a data reset script.

Hopefully the logic or code can help you solve your problem.

Cold Fusion Web User Statistics

ASP.NET Mixing Forms & Windows Authentication

This logic can be used to give you a SINGLE SIGN-ON / AUTOLOGIN solution with ASP.NET 2.0 or higher using a combination of authentication providers on IIS 5.1 (Windows 2000). The demo is using C# and does require a network administrator account to make the magic work. I’ve tried using other accounts, but was only able to get this to work with a “Domain Administrator” account.

I tried so many solutions (application sub folder using Windows auth, web service, every possible IIS 5.1 settings, WMI query) and nothing worked. After trying to figure this out for a few weeks, I realized that I had a single sign-on solution being used for our SonicWall content filtering system appliances. I looked into how this was being done and I decided to put the same logic in the page_load event of my log in control.

After an hour of playing around, I had a working ASP.NET single sign-on solution. Using impersonation, I create a thread that uses a domain administrator account (this is a requirement, which is also required by the SonicWall solution) to perform a remote call to the client workstation to obtain the currently logged on user(s). The magic is done by using “netapi32.dll”, by passing the NetWkstaUserEnum method a hostname your able to obtain an array of users logged into the computer.

protected void Page_Load(object sender, EventArgs e)
{
     if (!IsPostBack) { // Attempt Autologin
         string account = String.Empty;
         string domain = String.Empty;
         string user = String.Empty;
         string email = String.Empty;

         try
         {

             ImpersonateUser iu = new ImpersonateUser();
             if (iu.impersonateValidUser("", "", ""))
             {

                 NetWorkstationUserEnum nws = new NetWorkstationUserEnum();
                 string host = nws.DNSLookup(Request.UserHostAddress);

                 string[] users = nws.ScanHost(host);

                 if (nws.ScanHost(host).Length &amp;gt; 0)
                 {
                     account = users[users.Length - 1];
                     domain = account.Substring(0, account.IndexOf("\\"));
                     user = account.Substring(account.IndexOf("\\") + 1, account.Length - account.IndexOf("\\") - 1);
                 }

                 iu.undoImpersonation();
             }
         }
         catch (Exception logex)
         {
             Log.Debug("Autologin Failure: " + logex.Message);
         }

         if (!String.IsNullOrEmpty(account))
         {
             Log.Info("Account: " + account);

             if (domain.ToUpper() == "")
             {
                 email = user.ToLower() + "@";
                 String strRole = AssignRoles(email);

                 if (!String.IsNullOrEmpty(strRole))
                 {
                     FormsAuthentication.Initialize();

                     //The AddMinutes determines how long the user will be logged in after leaving
                     //the site if he doesn't log off.
                     FormsAuthenticationTicket fat = new FormsAuthenticationTicket(1,
                         email, DateTime.Now,
                         DateTime.Now.AddDays(7), true, strRole,
                         FormsAuthentication.FormsCookiePath);

                     HttpCookie ck;
                     ck = new HttpCookie(FormsAuthentication.FormsCookieName, FormsAuthentication.Encrypt(fat));
                     ck.Expires = fat.Expiration;
                     ck.Path = FormsAuthentication.FormsCookiePath;

                     Response.Cookies.Add(ck);
                     Response.Redirect(FormsAuthentication.GetRedirectUrl(email, true));
                 }
                 else
                 {
                     Log.Info("Unable to create FAT, user does not have any roles assigned.");
                 }
             }
         }

     }
}

I always take the last user in the list, since the machine account “domain\machinename$” is always first. I’ve not had it return the wrong person yet. Even if a user is not automatically logged in, they can still manually enter their log in/password to access the site.

** On my development machine, when I checked the array results I would see a bunch of accounts that were generated each time I ran VS. Each time I ran my demo application, I’d see a different account like (ASPNET) as the last user. I think this has something to do with the thread VS users for the built-in web server.

Too keep my original Forms Authentication solution in place, I parse the account (e.g. DOMAIN\User => User) and I check the Forms authentication database for the user’s name, skipping the password check. If they exist, I creates a FormsAuthenticationTicket and the process is complete.

** This code is part of a C#, ASP.NET Website Project. I’ve tested this code on a few websites all using C# and this does work. The cavaet of requiring a high-security account is a pain but it works. If anybody knows of another way to pull this off, let me know…

By default, the log in control is only displayed when a user is not authenticated. This is because the auto log in logic is hooked onto the modules load event.

This solution is currently working on Windows 2000 with IIS 5.1, for a portal developed with ASP.NET 2.0 and C#. The portal uses forms authentication but existing on an internal domain. The site has a a few hundred users, some are remote agents that connect via VPN and are not part of the domain.

This has made a huge difference, since we now set the company default web page to the site and key that they’ll be able to see everything since we’ve automatically logged them in. The first day I made this change, I got 3 emails about the some major changes users saw done to the site. I found this comment really funny, since we didn’t make any changes to the content… it just so happened that this was the first time they had ever logged in!

Good References for the solution:

http://www.codeproject.com/KB/IP/LoggedOnUsersPart2.aspx (had a good note about security)

Other Notes:

I found another solution saying that a WMI Query should be able to obtain the same data, but when I tried this using impersonation it did not work. I read that WMI queries, even when run via Impersonation, are limited by the ASPNET WP thread. I can’t explain the details but I did find two posts about this and took them at face value.

You can download the C# ASP.NET code here. This is an excerpt from my project but it’s working code. If anybody else has a working solution that does not require a domain admin account, let me know. Having a Single Sign-On (“SSO”) solution for an ASP.NET intranet site is awesome!

FormsWindowsMixxed_Source.zip