Some time ago I wrote this article about how to store an entire User class, which could contain User Authentication,
Role(s), and Profile information in a Forms Authentication ticket (cookie), thereby
essentially eliminating the chatty paradigm of using an RDBMS such as SQL Server
for Membership, Roles, and Profile. Of course the database still got hit, but
only when a user needed to log in, since all the user-related information was
being carried around with them neatly in their Forms Authentication cookie.
The article featured using LZMA (7Zip) compression to be able to pack more info into
the cookie. Now that MongoDb is available and there are decent C# Drivers for
it (I use the NoRM driver), it makes a lot more sense to store this UserData
object directly into the database. Think of it this way: Your SQL Server is already
probably working overtime because of all the other cool stuff you’re doing on
your site or sites, so anytime you have the opportunity to take some load off
the database by performing an often-used function somewhere else, you’re probably
doing yourself a favor from a performance standpoint.
So what I’ve done here is to rewrite the approach using MongoDb as the back-end,
and I’ve also replaced the LZMA compression with a more lightweight implementation
of MiniLZO. Compression is really not even needed since you can store about 4000
characters of info in a cookie before it will blow up on you, (the typical serialized
amount here is only about 900 bytes) but it’s there because it’s fast and gives
you some extra breathing room to store “bigger” UserData classes containing more
Profile info fields.
To put this to work, you’ll first need to download and install MongoDb as a Windows
Service. I have a very simple 5 – step guide in this article, so if you want, go ahead and download the bits, install it, and then resume reading
here.
A copy of the NoRM MongoDb driver is in my downloadable Visual Studio 2010 Solution,
but you may also want to get a full copy of the source from Andrew Theken’s GitHub repository here. Just use the “Download Source” button at the top right of the page.
There are over 400 unit tests in the solution, so just about anything you want to
learn how to do with the NoRM driver has a matching test that you can study.
This implementation uses the Application_AuthenticateRequest event, which is available
in Global.asax, and which is fired for every Request. Let’s have a look at that
code first:
protected void Application_AuthenticateRequest(Object sender, EventArgs e)
{
// this method is fired on every request. We use it to get our FormsAuth cookie and
rehydrate
// the Stored UserData object, complete with Profile info
if (HttpContext.Current.User != null)
{
if (HttpContext.Current.User.Identity.IsAuthenticated)
{
if (HttpContext.Current.User.Identity is FormsIdentity)
{
// Get Forms Identity From Current User
FormsIdentity id = (FormsIdentity)HttpContext.Current.User.Identity;
// Get Forms Ticket From Identity object
FormsAuthenticationTicket ticket = id.Ticket;
// Retrieve stored user-data object from Ticket cookie
string userData = ticket.UserData;
// uncompress and deserialize the compressed UserData instance:
CustomAuth.UserData u = Auth.ConvertCompressedStringToUserData(userData);
// assign our previously compressed UserData instance to the Current.User property
HttpContext.Current.User =(IPrincipal) u;
}
}
}
This code simply looks to see if the Request is Authenticated with a FormsIdentity,
and if so, grabs the userData from the forms ticket and converts it to my UserData
class which conveniently attaches to the User property of the current HttpContext.
This is therefore now carried around the site with the user during their travels.
All standard stuff, we’re just making use of how ASP.NET Forms Authentication
works. Forms Authentication supports a UserData property, and the HttpContext
already has a User property; no need to reinvent the wheel. As long as your User
object implements IPrincipal, which is a no-brainer, you're good to go. You can
have as many additional user/role/profile properties as you want in it.
Now let’s switch over to the “Join” or “Register” page to see how this data actually
gets in there. Here is 100% of the logic when you fill out the form and press
the submit button:
protected void btnSubmit_Click(object sender, EventArgs e)
{
try
{
string userName = txtUserName.Text;
string password = txtPassword.Text;
string email = txtEmail.Text;
string userUrl = txtUserUrl.Text;
if (!userUrl.ToLower().StartsWith("http://")) userUrl = "http://" + userUrl;
bool chkNews = this.chkNewsLetter.Checked;
bool chkNotify = this.chkNotification.Checked;
Guid gooid = Guid.NewGuid();
string userBio = txtBio.Text;
IIdentity ident = new GenericIdentity(userName);
string[] roles = {"user"};
string extraData = ""; // use this field for additional data
UserData uData =
new CustomAuth.UserData(gooid,ident, roles, userName , password,email ,chkNews,
chkNotify , userUrl , userBio ,extraData);
string cpUserData = Auth.ConvertUserDataToCompressedString(uData);
uData._roles = new string[]{cpUserData};
using (Mongo mongo = Mongo.Create(MongoHelper.ConnectionString()))
{
Norm.Collections.MongoCollection<UserData> coll =
(MongoCollection<UserData>)mongo.GetCollection<UserData>();
coll.Insert(uData);
}
lblMessage.Text = "<b>You have joined!</b> <br/>Please <a href=\"Default.aspx\">
Log in </a>.";
}
catch(Exception ex)
{
lblMessage.Text = ex.Message;
}
}
And here is the UserData Class that is being populated:
using System;
using System.Security.Principal;
using Norm;
using Norm.Attributes;
namespace CustomAuth
{
[Serializable]
public class UserData : IPrincipal
{
[MongoIdentifier]
public Guid ID { get; set; }
public string UserName { get; set; }
public string Password { get; set; }
public string Email { get; set; }
public bool Notification { get; set; }
public string UserUrl { get; set; }
public bool Newsletter { get; set; }
public string UserBio { get; set; }
public string[] _roles { get; set; }
public string ExtraData { get; set; }
private IIdentity _identity;
public UserData()
{
}
public UserData(Guid id, IIdentity identity, string[] roles, string userName, string password,
string email, bool notification,
bool newsLetter, string userUrl, string userBio, string extraData)
{
_identity = identity;
_roles = new string[roles.Length];
roles.CopyTo(_roles, 0);
Array.Sort(_roles);
UserName = userName;
Password = password;
Email = email;
Newsletter = newsLetter;
Notification = notification;
ID = id;
UserUrl = userUrl;
UserBio = userBio;
ExtraData = extraData;
}
public bool IsInRole(string role)
{
return Array.BinarySearch(_roles, role) >= 0 ? true : false;
}
// This tells MongoDb to ignore this property. It doesn't like interfaces, and we
don't need it stored anyway.
// Implementing IPrincipal allows us to attach this to the HttpContext.
[MongoIgnore]
public IIdentity Identity
{
get { return _identity; }
}
public bool IsInAllRoles(params string[] roles)
{
foreach (string searchrole in roles)
{
if (Array.BinarySearch(_roles, searchrole) < 0)
return false;
}
return true;
}
public bool IsInAnyRoles(params string[] roles)
{
foreach (string searchrole in roles)
{
if (Array.BinarySearch(_roles, searchrole) > 0)
return true;
}
return false;
}
}
}
The Login page (which in this simple demo is the Default.aspx page) has this logic:
protected void Button1_Click(object sender, System.EventArgs e)
{
// Initialize FormsAuthentication (reads the configuration and gets
// the cookie values and encryption keys for the given application)
FormsAuthentication.Initialize();
UserData foundUser = null;
// see if the person logging in is in our Users Collection:
using (Mongo mongo = Mongo.Create(MongoHelper.ConnectionString()))
{
Norm.Collections.MongoCollection<UserData> coll =
(MongoCollection<UserData>)mongo.GetCollection<UserData>();
UserData u = new UserData() {UserName = this.txtUserName.Text, Password = this.txtPassword.Text};
// You can do this in a simpler way, I just wanted to illustrate LINQ with AsQueryable
var list =
coll.AsQueryable().Where(
x => x.UserName == this.txtUserName.Text && x.Password == this.txtPassword.Text).ToList();
if (list.Count > 0)
foundUser = list[0];
}
if (foundUser==null)
{
Label1.Text = "Not Found. Please Join first.";
return;
}
// remember we have the entire UserData class in the roles property
string cpUserData = foundUser._roles[0];
// Create a new ticket used for authentication
FormsAuthenticationTicket ticket = new FormsAuthenticationTicket(
1, // Ticket version
txtUserName.Text, // Username to be associated with this ticket
DateTime.Now, // Date/time issued
DateTime.Now.AddMinutes(30000), // Date/time to expire
true, // "true" for a persistent user cookie (could be a checkbox on form)
cpUserData, // User-data (the string roles column from this user record in our database)
FormsAuthentication.FormsCookiePath); // Path cookie is valid for
// Hash the cookie for transport over the wire
string encTicket = FormsAuthentication.Encrypt(ticket);
HttpCookie cookie = new HttpCookie(
FormsAuthentication.FormsCookieName, // Name of auth cookie (it's the name specified in web.config)
encTicket); // Hashed ticket
cookie.HttpOnly = false; // we want a persistent cookie
cookie.Expires = DateTime.Now.AddDays(365);
// Add the cookie to the list for outbound response
Response.Cookies.Add(cookie);
// Redirect to requested URL, or homepage if no previous page requested
string returnUrl = Request.QueryString["ReturnUrl"];
if (returnUrl == null) returnUrl = "default.aspx";
// Don't call the FormsAuthentication.RedirectFromLoginPage method since it could
// replace the authentication ticket we just added...
Response.Redirect(returnUrl +"?msg=" +txtUserName.Text +"logged in.");
}
For production you'll want to add an index on the username and password fields for
faster lookup.
And finally, we have an EditProfile.aspx page, that lets a user fill in additional
Profile – type data and saves it:
protected void btnSave_Click(object sender, EventArgs e)
{
UserData uDataCurrent = (UserData)Page.User;
try
{
string userName = txtUserName.Text;
string email = txtEmail.Text;
string userUrl = txtUserUrl.Text;
string password = txtPassword.Text;
if (!userUrl.ToLower().StartsWith("http://")) userUrl = "http://" + userUrl;
bool chkNews = this.chkNewsLetter.Checked;
bool chkNotify = this.chkNotification.Checked;
Guid gooid = uDataCurrent.ID;
string userBio = txtBio.Text;
IIdentity ident = new GenericIdentity(userName);
string[] roles = { "user" };
string extraData = txtExtraData.Text; // use this field for additional data
UserData uData =
new CustomAuth.UserData(gooid, ident, roles, userName, password,email, chkNews,
chkNotify, userUrl, userBio, extraData);
string cpUserData = Auth.ConvertUserDataToCompressedString(uData);
uData._roles = new string[] {cpUserData};
using (Mongo mongo = Mongo.Create(MongoHelper.ConnectionString()))
{
Norm.Collections.MongoCollection<UserData> coll =
(MongoCollection<UserData>)mongo.GetCollection<UserData>();
coll.Save(uData);
}
lblMessage.Text = "Data saved. Return to <a href=default.aspx>Home page</a> </br>
";
}
catch (Exception ex)
{
lblMessage.Text = ex.Message;
}
}
To use Role information, you can employ code such as the following:
protected void Page_Load(object sender, EventArgs e)
{
if ( User.Identity.IsAuthenticated && User.Identity.Name !=null )
{
bool role = User.IsInRole("user");
lblnewmessage.Text = "User " + User.Identity.Name + " logged in. User Role:" +role.ToString( );
hlEditProfile.Visible = true;
}
}
That’s everything – Custom provider-less Forms Authentication with Roles and Profile,
all stored in the Forms cookie, with a MongoDb back-end! You can download the full solution here.