Welcome to CSharp Labs

Transmitting Compressed User Data in a FormsAuthenticationTicket

Monday, July 15, 2013

The FormsAuthenticationTicket class is used in ASP .NET to provide forms authentication to identify users through an encrypted cookie. The ticket also provides a UserData property which can be used to store additional encrypted data. I have created the FormsAuthenticationCookie class which represents a collection of keys and values that are serialized into binary, compressed and encoded into the UserData property.

How it Works

To create an instance of FormsAuthenticationCookie for an authenticated user, the FormsAuthenticationCookie.Current static property attempts to locate a cookie from the current response or from the user's identity:

        /// <summary>
        /// Creates a new instance of the FormsAuthenticationCookie for an authenticated user or null if the user is not authenticated.
        /// </summary>
        public static FormsAuthenticationCookie Current
        {
            get
            {
                //get the current context:
                HttpContext context = HttpContext.Current;
                FormsAuthenticationTicket ticket;

                //check if cookie exists in response without creating it
                if (context.Response.Cookies.AllKeys.Contains(FormsAuthentication.FormsCookieName))
                {
                    //if existing authentication cookie in response, get the cookie and decrypt the FormsAuthenticationTicket
                    HttpCookie existing = context.Response.Cookies[FormsAuthentication.FormsCookieName];
                    ticket = FormsAuthentication.Decrypt(existing.Value);
                }
                //otherwise, if the user is authenticated:
                else if (context.User != null && context.User.Identity.IsAuthenticated && context.User.Identity is FormsIdentity)
                    //gets the FormsAuthenticationTicket from the user identity:
                    ticket = ((FormsIdentity)context.User.Identity).Ticket;
                else
                    //not authenticated
                    return null;

                //return new FormsAuthenticationCookie
                return new FormsAuthenticationCookie(ticket);
            }
        }

User data is read by converting from base-64 encoding to binary, decompressing and separating data into keys and values:

                //convert base=64 encoded data to binary
                byte[] binary = Convert.FromBase64String(userdata);

                //output stream is used to read uncompressed data
                MemoryStream output = null;

                try
                {
                    if (binary[0] == 1) //if compression flag
                    {
                        //using compression
                        CompressUserData = true;

                        //create stream to compressed data
                        using (MemoryStream compressed = new MemoryStream(binary))
                        {
                            //skip initial compression flag byte
                            compressed.Seek(1, SeekOrigin.Current);

                            //create deflate stream to decompress data:
                            using (DeflateStream deflate = new DeflateStream(compressed, CompressionMode.Decompress, true))
                            {
                                //create output stream
                                output = new MemoryStream();

                                //decompress data to output
                                deflate.CopyTo(output);

                                //seek to beginning of stream
                                output.Seek(0, SeekOrigin.Begin);
                            }
                        }
                    }
                    else
                    {
                        //no compression
                        CompressUserData = false;

                        //create output stream
                        output = new MemoryStream(binary);

                        //skip initial compression flag byte
                        output.Seek(1, SeekOrigin.Current);
                    }

                    using (BinaryReader reader = new BinaryReader(output, Encoding.UTF8, true))
                    {
                        do
                        {
                            string key = reader.ReadString();
                            DataType type = (DataType)reader.ReadByte();
                            object value;

                            switch (type)//get the property data type
                            {
                                case DataType.Bool:
                                    //read Boolean:
                                    value = reader.ReadBoolean();
                                    break;
                                case DataType.Int32:
                                    //read integer:
                                    value = reader.ReadInt32();
                                    break;
                                case DataType.Int64:
                                    //read long:
                                    value = reader.ReadInt64();
                                    break;
                                case DataType.String:
                                    //read string:
                                    value = reader.ReadString();
                                    break;
                                case DataType.Char:
                                    //read char
                                    value = reader.ReadChar();
                                    break;
                                case DataType.DateTime:
                                    //read long and convert to date time:
                                    value = DateTime.FromBinary(reader.ReadInt64());
                                    break;
                                default:
                                    throw new NotSupportedException("Type not supported.");
                            }

                            //add data:
                            _UserData.Add(key, value); 
                        }
                        while (output.Position < output.Length); //read until end of stream
                    }
                }
                finally
                {
                    if (output != null)
                        output.Dispose();
                }

To save user data, the process is reversed. Keys and values are saved to binary, compressed and then converted to a base-64 encoded string:

            string encodedUserData;

            //stream to write binary data to:
            using (MemoryStream stream = new MemoryStream())
            {
                //if this stream will not be compressed
                if (!CompressUserData)
                    //write no compression flag
                    stream.WriteByte(0);

                //write all the values in the user data dictionary
                using (BinaryWriter writer = new BinaryWriter(stream, Encoding.UTF8, true))
                {
                    //write each key and value
                    foreach (KeyValuePair<string, object> item in _UserData)
                    {
                        //write the item key
                        writer.Write(item.Key);
                        //write the item value
                        _WriteObjectSwitch.Switch(item.Value.GetType(), writer, item.Value);
                    }
                }

                //if stream should be compressed
                if (CompressUserData)
                {
                    //seek to the beginning
                    stream.Seek(0, SeekOrigin.Begin);

                    //create a stream to output compressed data to
                    using (MemoryStream output = new MemoryStream())
                    {
                        //write the compression flag
                        output.WriteByte(1); 

                        //compress stream using deflate algorithm
                        using (DeflateStream deflate = new DeflateStream(output, CompressionLevel.Optimal, true))
                            stream.CopyTo(deflate);

                        //convert binary to base-64 encoded string
                        encodedUserData = Convert.ToBase64String(output.ToArray());
                    }
                }
                else
                    //convert binary to base-64 encoded string
                    encodedUserData = Convert.ToBase64String(stream.ToArray());
            }

When the cookie is saved, a new FormsAuthenticationTicket is created using the original ticket or FormsAuthentication values:

            DateTime issueDate, expirationDate;
            int version;
            string cookiePath;

            if (_Ticket == null)
            {
                //ticket issue date
                issueDate = DateTime.UtcNow;
                //ticket expiration date
                expirationDate = issueDate.Add(FormsAuthentication.Timeout);
                //use version 2, default FormsAuthenticationTicket.Version
                version = 2;
                //ticket cookie path
                cookiePath = FormsAuthentication.FormsCookiePath;
            }
            else //otherwise, use original values
            {
                //get ticket issue date
                issueDate = _Ticket.IssueDate;
                //get ticket expiration date
                expirationDate = _Ticket.Expiration;
                //get ticket version
                version = _Ticket.Version;
                //get ticket cookie path
                cookiePath = _Ticket.CookiePath;
            }

            //create a new ticket:
            FormsAuthenticationTicket ticket = new FormsAuthenticationTicket(version, _User, issueDate, expirationDate, _IsPersistent, encodedUserData, cookiePath);

            //create a new cookie
            HttpCookie cookie = new HttpCookie(FormsAuthentication.FormsCookieName);
            //encrypt ticket
            cookie.Value = FormsAuthentication.Encrypt(ticket);
            //set expiration
            cookie.Expires = ticket.Expiration;
            //set ssl requirements:
            cookie.Secure = FormsAuthentication.RequireSSL;
            //no script access:
            cookie.HttpOnly = true;

            //get current http context
            HttpContext context = HttpContext.Current;

            //if no ticket exists and cookie is not set in response
            if (_Ticket == null && !context.Response.Cookies.AllKeys.Contains(FormsAuthentication.FormsCookieName))
                //add the cookie
                context.Response.Cookies.Add(cookie);
            else //otherwise
                //set the cookie
                context.Response.Cookies.Set(cookie); //set cookie

Data can be get or set by using the indexer or the generic Get/Set properties:

        /// <summary>
        /// Gets or sets the element with the specified key.
        /// </summary>
        /// <param name="key">The key of the element to get or set.</param>
        /// <returns>The element with the specified key.</returns>
        public object this[string key]
        {
            get
            {
                object obj;
                if (_UserData.TryGetValue(key, out obj))
                    return obj;
                else
                    return null;
            }
            set
            {
                ThrowIfInvalidKey(key);

                if (value == null)
                    _UserData.Remove(key);
                else
                    _SetTypeSwitch.Switch(value.GetType(), key, _UserData, value);
            }
        }

        /// <summary>
        /// Gets the value with the specified key.
        /// </summary>
        /// <typeparam name="T">The type of element to get.</typeparam>
        /// <param name="key">The key of the element to get.</param>
        /// <returns>The value associated with the specified key.</returns>
        public T Get<T>(string key)
        {
            return (T)this[key];
        }

        /// <summary>
        /// Sets the specified key and value in the cookie.
        /// </summary>
        /// <typeparam name="T">The type of element to set.</typeparam>
        /// <param name="key">The key of the element to set.</param>
        /// <param name="value">The value of the element to set.</param>
        public void Set<T>(string key, T value)
        {
            this[key] = value;
        }

Static ActionTypeSwitch instances are created and populated with acceptable types and delegates to write objects to binary or set the key's value in the static constructor:

        /// <summary>
        /// Defines a type switch to set user data.
        /// </summary>
        private static readonly ActionTypeSwitch<string, Dictionary<string, object>, object> _SetTypeSwitch = new ActionTypeSwitch<string, Dictionary<string, object>, object>();
        /// <summary>
        /// Defines a type switch to write data.
        /// </summary>
        private static readonly ActionTypeSwitch<BinaryWriter, object> _WriteObjectSwitch = new ActionTypeSwitch<BinaryWriter, object>();

        /// <summary>
        /// Initializes static type switches.
        /// </summary>
        static FormsAuthenticationCookie()
        {
            #region Writing Object Data
            //case DataType: 
            _WriteObjectSwitch.Add(typeof(DataType), (type, writer, value) =>
            {
                //write type of data:
                writer.Write((byte)value);
            });

            //case bool:
            _WriteObjectSwitch.Add(typeof(bool), (type, writer, value) =>
            {
                //write DataType:
                _WriteObjectSwitch.Switch(typeof(DataType), writer, DataType.Bool);
                //write Boolean:
                writer.Write((bool)value);
            });

            //case Int32:
            _WriteObjectSwitch.Add(typeof(Int32), (type, writer, value) =>
            {
                //write DataType:
                _WriteObjectSwitch.Switch(typeof(DataType), writer, DataType.Int32);
                //write integer:
                writer.Write((Int32)value);
            });

            //case Int64:
            _WriteObjectSwitch.Add(typeof(Int64), (type, writer, value) =>
            {
                //write DataType:
                _WriteObjectSwitch.Switch(typeof(DataType), writer, DataType.Int64);
                //write long:
                writer.Write((Int64)value);
            });

            //case char:
            _WriteObjectSwitch.Add(typeof(char), (type, writer, value) =>
            {
                //write DataType:
                _WriteObjectSwitch.Switch(typeof(DataType), writer, DataType.Char);
                //write char:
                writer.Write((char)value);
            });

            //case DateTime:
            _WriteObjectSwitch.Add(typeof(DateTime), (type, writer, value) =>
            {
                //write DataType:
                _WriteObjectSwitch.Switch(typeof(DataType), writer, DataType.DateTime);
                //write serialized DateTime:
                writer.Write(((DateTime)value).ToBinary());
            });

            //case string:
            _WriteObjectSwitch.Add(typeof(string), (type, writer, value) =>
            {
                //write DataType:
                _WriteObjectSwitch.Switch(typeof(DataType), writer, DataType.String);
                //write string:
                writer.Write((string)value);
            });

            //default:
            _WriteObjectSwitch.Default = (type, builder, value) =>
            {
                throw new NotSupportedException("The type '" + type.FullName + "' is not supported.");
            };
            #endregion

            #region Set Dictionary Value
            //defines a method that sets a dictionary value
            Action<Type, string, Dictionary<string, object>, object> setValue = (Type type, string key, Dictionary<string, object> userData, object value) =>
            {
                userData[key] = value;
            };

            //add cases for each type of acceptable data:
            _SetTypeSwitch.Add(typeof(int), setValue);
            _SetTypeSwitch.Add(typeof(Int64), setValue);
            _SetTypeSwitch.Add(typeof(bool), setValue);
            _SetTypeSwitch.Add(typeof(char), setValue);
            _SetTypeSwitch.Add(typeof(DateTime), setValue);
            _SetTypeSwitch.Add(typeof(string), setValue);

            //default to handle invalid object types:
            _SetTypeSwitch.Default = (Type type, string key, Dictionary<string, object> userData, object value) =>
            {
                throw new NotSupportedException("The type '" + type.FullName + "' is not supported.");
            };
            #endregion
        }
Using

In the Login action of an Mvc 4 project after the WebSecurity.Login method is called to create the FormsAuthenticationTicket, the FormsAuthenticationCookie can be used to include user data in the ticket:

        [HttpPost]
        [AllowAnonymous]
        [ValidateAntiForgeryToken]
        public ActionResult Login(LoginModel model, string returnUrl)
        {
            if (ModelState.IsValid && WebSecurity.Login(model.UserName, model.Password, persistCookie: model.RememberMe))
            {
                //creates FormsAuthenticationCookie from an existing ticket:
                FormsAuthenticationCookie auth = FormsAuthenticationCookie.Current;
                
                //add user data:
                auth["Key1"] = "Value1";
                auth["Key2"] = 1000;
                auth["Key3"] = 1000L;
                auth["Key4"] = DateTime.Now;
                auth["Key5"] = true;
                auth["Key6"] = 'A';

                //saves cookie to response:
                auth.Save();

                return RedirectToLocal(returnUrl);
            }

            // If we got this far, something failed, redisplay form
            ModelState.AddModelError("", "The user name or password provided is incorrect.");
            return View(model);
        }

To access user data in an authorized action, use FormsAuthenticationCookie.Current and get the value through the indexer.

Remarks

You should limit the amount of data stored in the FormsAuthenticationCookie to ensure the final size of the FormsAuthenticationTicket.UserData property does not result in an invalid cookie.

Source Code

Download FormsAuthenticationCookie and Supporting Classes

Comments