Forms Auth: Membership, Roles and Profile with no Providers and no Session

Shows how to use plain-old Forms Authentication with a single database table and compressed userData in the forms ticket to handle simplified Membership, Roles and Profile with no providers and no need for Session state.

One of the things you notice when you have been working with a particular technology / platform for more than a few years is the difficulty that relative newcomers experience in implementing certain aspects of it. A case in point is the use of ASP.NET Membership, Role and Profile providers in ASP.NET 2.0.

Many developers have difficulty understanding the Provider model, or in other cases they want to do certain things and then find that the opaque way that the default providers store data in the database is simply not conducive to supporting their wishes, and then they find out that if they want to "roll their" own and derive from the base Provider classes, it's not quite as easy as it may seem at first blush.

So, this can be a cause for great frustration; I've seen a number of instances where developers choose to abandon the Provider model entirely and just use a database lookup on the username / password, and Session state to store their custom data about the user, completely bypassing the providers and in some cases not even using Forms Authentication. Er, 'Back to the future', anyone?

This caused me to do a little thinking. I've done several articles here about Forms Authentication, and a couple more about custom Membership and Roles providers, including a set of providers that I wrote for the SQLite database. I reasoned that in many cases, the use of Roles and Profiles, and even in some cases, Membership, is not necessary.  I also thought about Forms Authentication and how one might be able to retrofit it to support more functionality.

Then I remembered that the Forms Authentication Ticket class has a "userdata" field that accepts a string. Almost all the time, this is used to hold a comma-delimited list of role names for that user, allowing us as developers to call the User.IsInRole( ...) method for authorization checks in our code. However, there is no rule that says this is what you have to put there - I also remembered that I had built a custom "Compressed Cookie" Utility that uses LZMA (7Zip) compression to pack up to 9000 lines of data into a cookie that would normally only support a fraction of that amount of data.

So, I added the two together, and came up with a concept that you can have a CustomPrincipal class that stores all your user - related data, preferences, etc. This can be serialized and compressed, and stuck right there in that little userdata string property of the Forms Ticket, and it will nicely travel around in the cookie. So I built a prototype and tested it, and it works great.  At runtime in the AuthenticateRequest handler for each page request, this is simply decompressed and rehydrated from the userData field in the Forms ticket, and assigned the User property of the page.

This article explains how you can use plain old ASP.NET 1.1 - style Forms Authentication, with no requirement at all for Session state, and have Membership, Role, and Profile data all stored in one place,  in the Forms Authentication cookie, and not have to rely on the providers at all.

It's fast, its secure because you can encrypt the cookie, and it should scale well across web farms provided the machine keys and site numbers are all the same, and that enableCrossAppRedirects is set. Sure, the cookie may be a little bigger than most, but the browser will happily send it with each request just like any other cookie. Another nice advantage is that all the user "stuff" is stored in the columns of a single Users table, and is eminently searchable via normal SQL statements. So for example if you want to send out an email newsletter to all your users who have selected that they want to receive one, it is a no-brainer. And, you can use the new ASP.NET 2.0 Login, etc. controls with it if you like.


First, if you would like to get a little background on 7-Zip compression, you can visit an earlier article here. I use my handy "SevenZipHelper" class to handle the compression and decompression.

Now, let's take a look at the actual UserData class. This is what gets serialized and compressed, and stored in the userData field of the Forms Ticket:


using System;
using System.Security.Principal;

namespace CustomAuth
{
    [Serializable]
    public class UserData : IPrincipal
    {
        public Guid ID;
        public string UserName;
        public string Email;
        public bool Notification;

        public string UserUrl;
        public bool Newsletter;
        public string UserBio;
        private IIdentity _identity;
        private string[] _roles;
        public string ExtraData;

        public UserData(Guid id, IIdentity identity, string[] roles, string userName, 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;
            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;
        }

        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;
        }
    }

Since this derives from IPrincipal, you can attach it to the User property of each Request. It carries a lot of information, and there is room for plenty more if you need it - just add new fields. Note that I didn't even bother to make the fields properties - it's probably not necessary here, unless you are a "property purist".

The GUID property matches the Primary Key of the row in the Users SQL Server table, and the other properties are simply the kinds of things you'd need for a discussion forums application user's profile info. You can put any fields you want in it, each would have its own column in the Users table in your database. Most importantly, I've kept the roles column intact; that column in the database no longer holds a comma-delimited string  of the user's roles -- it now holds the entire Base64 encoded, compressed, Binary serialized UserData class instance for this user.

If you are wondering how big this compressed string is, the roles column for the above class is about 1,120 bytes - well within the 4000 byte limit for an Http cookie, even with the rest of the Forms ticket baggage, which brings it up to 2,475 bytes total. You can use this for profile information, address, email, style or theme preferences, a short bio of the user, whatever fields are important for your business model

To allow the user to edit their profile info, you simply unwind the UserData class from the Page.User property and use the fields to populate an edit form. To save the information, you create a new UserData object with the same Guid, update the fields in the database, then do a SignOut to trash the old cookie and an Authenticate to populate back your new one with their updated data. You can redirect back to the same or any other page to set the new cookie.

AuthenticateRequest, which is overriden in Global.asax, looks like this:

protected void Application_AuthenticateRequest(Object sender, EventArgs e)
        {
            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;
                        CustomAuth.UserData u = Auth.ConvertCompressedStringToUserData(userData);
                        // assign to Current User
                        HttpContext.Current.User = u;

                    }
                }
            }
        }



My "DataAccess" class has an InsertUser and an UpdateUser method to handle this, and my "Auth" class has all the methods to handle authentication, compression and so on:

using System;
using System.Configuration;
using System.Data;
using System.Data.SqlClient;
using System.IO;
using System.Runtime.Serialization.Formatters.Binary;
using System.Security.Principal;
using System.Web;
using System.Web.Security;
using SevenZip.Compression.LZMA;

namespace CustomAuth

{
    public static class Auth
    {
        public static string ConvertUserDataToCompressedString(UserData u)
        {
            string cUserData = "";

            int siz = 0;
            try
            {
                BinaryFormatter bf = new BinaryFormatter();
                MemoryStream ms = new MemoryStream();
                bf.Serialize(ms, u);
                byte[] inbyt = ms.ToArray();
                byte[] b = SevenZipHelper.Compress(inbyt);
                cUserData = Convert.ToBase64String(b);
            }
            catch (Exception ex)
            {
                throw;
            }
            return cUserData;
        }


        public static UserData ConvertCompressedStringToUserData(string cpString)
        {
            UserData retval = null;
            try
            {
                byte[] bytCook = Convert.FromBase64String(cpString);

                byte[] outByt = SevenZipHelper.Decompress(bytCook);


                MemoryStream outMs = new MemoryStream(outByt);
                outMs.Seek(0, 0);
                BinaryFormatter bf = new BinaryFormatter();
                retval = (UserData) bf.Deserialize(outMs, null);
            }
            catch (Exception ex)
            {
                throw;
            }
            return retval;
        }


        public static bool Authenticate(string username, string password, string returnUrlFull)
        {
            FormsAuthentication.Initialize();

            // Create connection and command objects
            SqlConnection conn =
                new SqlConnection(ConfigurationManager.AppSettings["TESTConnectionString"]);
            conn.Open();
            SqlCommand cmd = conn.CreateCommand();
            cmd.CommandText = "SELECT * FROM Users WHERE username=@username " +
                              "AND password=@password AND ACTIVE =1";

            // Fill our parameters
            cmd.Parameters.Add("@username", SqlDbType.VarChar, 150).Value = username;
            cmd.Parameters.Add("@password", SqlDbType.VarChar, 15).Value = password;
            //  FormsAuthentication.HashPasswordForStoringInConfigFile(txtPassword.Text, "sha1");
            // you can use the above method for encrypting passwords to be stored in the database
            // Execute the command
            SqlDataReader reader = cmd.ExecuteReader(CommandBehavior.CloseConnection);
            //ID   UserName    password  email roles UserUrl Notification
            if (reader.Read())
            {
                IIdentity ident = new GenericIdentity((string) reader["UserName"]);
                Guid ID = (Guid) reader["ID"];

                string[] roles = {"user"};
                UserData uData =
                    new UserData(ID, ident, roles, (string) reader["UserName"], (string) reader["Email"],
                                 (bool) reader["Newsletter"], (bool) reader["Notification"],
                                 (string) reader["UserUrl"], (string) reader["UserBio"], (string) reader["ExtraData"]);

                string cpUserData = ConvertUserDataToCompressedString(uData);         

                // Create a new ticket used for authentication
                FormsAuthenticationTicket ticket = new FormsAuthenticationTicket(
                    1, // Ticket version
                    username, // Username to be associated with this ticket
                    DateTime.Now, // Date/time issued
                    DateTime.Now.AddMinutes(30), // Date/time to expire
                    true, // "true" for a persistent user cookie (could be a checkbox on form)

                    // here is where you will place the compressed UserData String
                    cpUserData, // User-data (the 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 hash = FormsAuthentication.Encrypt(ticket);
                HttpCookie cookie = new HttpCookie(
                    FormsAuthentication.FormsCookieName, // Name of auth cookie (it's the name specified in web.config)
                    hash); // Hashed ticket

                reader.Close();
                // Add the cookie to the list for outbound response
                HttpContext.Current.Response.Cookies.Add(cookie);

                // Redirect to requested URL, or homepage if no previous page requested
                string returnUrl = returnUrlFull; 
                if (returnUrl == null) returnUrl = "Default.aspx";

                // Don't call the FormsAuthentication.RedirectFromLoginPage since it could
                // replace the authentication ticket we just added...

                HttpContext.Current.Response.Redirect(returnUrl);
                return true;
            }
            else
            {
                // Username and or password not found in our database...
                return false;
            }
        }
    }
}

NOTE: If you are using this locally "out of the box", it wants to send an email to new users with a hyperlink to a page that will update their row's "Active" status to true (1) from their GUID on the querystring. So if you add yourself as a user and don't set up email along with a target page that will accept their GUID on the querystring and update their row to Active, you'll need to go into your SQL table and make yourself "Active" first before you can log in. This is standard procedure for any kind of membershp-based site; we want to make sure you aren't a spammer, and we also want a real email address so we can punish you with regular newsletters and other "stuff".

To install the sample:
1) Unzip into a folder
2) Create a new SQL Server database "CustomAuth"
3) Run the Sql script included to create the table and sprocs.
4) Modify the web.config to have correct connection strings and SMTP info.
5) Build and run.

And there you have it: Authentication, Membership, Roles, and Profile with no providers and no requirement for Session state, all in one SQL table with compressed Forms Authentication ticket cookies!

You can download the Visual Studio 2005 Solution here.

By Peter Bromberg   Popularity  (7168 Views)