On one of my test sites, I received a report that the "Remember my password" feature wasn't working. We're using forms authentication tickets to store some of the user data, so I double and triple checked all the settings. They all seemed correct.
In Logon.cs we were setting a persistent cookie
// Set a persistant cookie
FormsAuthentication.SetAuthCookie(CurrentUser.MemberID, true);
And in web.config, the timeout is about a month
<authentication mode="Forms">
<forms loginUrl="~/Account" timeout="30240" slidingExpiration="true"></forms>
</authentication>
Let's explore how forms authentication works and see if we can reason out why this isn't working.
Forms authentication is a fairly basic HttpModule that sets HttpContext.Current.User if a valid authentication ticket is supplied by the browser, usually through a cookie. The ASP.NET pipeline is a series of events similar to the page lifecycle that many .NET developers are
familiar with.
Internal Validation and URL mapping
Raise the BeginRequest() event.
Raise the AuthenticateRequest() event.
Raise the PostAuthenticateRequest() event.
Raise the AuthorizeRequest() event.
Raise the PostAuthorizeRequest() event.
Raise the ResolveRequestCache() event.
Raise the PostResolveRequestCache() event.
Raise the PostMapRequestHandler() event.
Raise the AcquireRequestState() event.
Raise the PostAcquireRequestState() event.
Raise the PreRequestHandlerExecute() event.
Execute the appropriate IHttpHandler based on file extension. In the case of .aspx files, this is usually the Page handler everyone is familiar with. The handler probably has its own pipeline. For example, the Page object is an HttpHandler.
http://msdn.microsoft.com/en-us/library/ms178472.aspx
Raise the PostRequestHandlerExecute() event.
Raise the ReleaseRequestState() event.
Raise the PostReleaseRequestState() event.
Perform response filtering if the Filter() property is defined.
Raise the UpdateRequestCache() event.
Raise the PostUpdateRequestCache() event.
Raise the EndRequest() event.
Raise the PreSendRequestHeaders() event.
Raise the PreSendRequestContent() event.
That's a lot of events! And that's not even counting the asynchronous ones. Can you guess where the forms authentication module sits? Yes, at AuthenticateRequest and the EndRequest. Why is it listening at EndRequest? We will see later on.
Internally, it's wired up like this.
public void Init(HttpApplication app)
{
[...]
FormsAuthentication.Initialize();
// Hook into the AuthenticateRequest and EndRequest events for the application
app.AuthenticateRequest += new EventHandler(this.OnEnter);
app.EndRequest += new EventHandler(this.OnLeave);
}
private void OnAuthenticate(FormsAuthenticationEventArgs e)
{
[...]
// Retrieve the ticket from the cookie, and set HttpContext.Current.User to a new IPrincipal
tOld = ExtractTicketFromCookie(e.Context, FormsAuthentication.FormsCookieName, out cookielessTicket);
e.Context.SetPrincipalNoDemand(new GenericPrincipal(new FormsIdentity(ticket), new string[0]));
}
With the user identity set, other modules in the chain can use this identity. One of the more prominent uses is the UrlAuthorizationModule. (Everyone loves this one).
<location path="Admin">
<system.web>
<authorization>
<allow users="Administrator guy">
<deny users="*">
</authorization>
</system.web>
</location>
UrlAuthorizationModule hooks into the AuthorizeRequest event and determines if the the Context.User set by Forms Authentication is authorized. If the user is not authorized, it sets the HTTP status code to 401 (Not authorized) and jumps the pipleine straight to EndRequest. So how
does simply ending the request achieve the "Redirect to Logon" that we're accustomed to seeing? The answer is pretty clever. Remember that the FormsAuthentication module also hooked into EndRequest, now here, it sees the 401 status code that's being returned, changes is to a 302
(Redirect) and shuttles the user to the login page.
So with just a few lines of code, we have secured our entire Admin folder with FormsAuthentication and UrlAuthorization, and we never have to think about it again. That's pretty dang useful!
So this is all well and good, and we have a reasonable mental model of how the whole system should work. Coming back to our issue, if we inspect our browser's cookies, we can see the authentication ticket is still around, and from all appearances, it should be good for a month.
But it's not! Often we're lucky if it lasts a day. So what gives? Don't stop me if you've already heard this one before. The answer lies in ASP.NET's <MachineKey> element.
The <MachineKey> is used by ASP.NET in numerous cryptographic situations:
. Tamper proofing of viewstate. Encryption of the viewstate, if enabled.
. Tamper proofing of forms authentication tickets and role cookies, and encryption if enabled.
. Encryption and tamper proofing of anonymous profile data
. Encryption of passwords in the built-in membership providers.
We're not using any of that stuff except for the forms ticket. The MachineKey is being used to tamperproof our authentication ticket. Using this random key, the ticket is one way hashed, then when ticket is deconstructed on the next request, the server compares the ticket, the
hashed ticket supplied by the browser, and the hashed ticket it creates internally. If it doesn't match up, the ticket is invalid. And since the browser doesn't know the MachineKey, they can't tamper with the data without making the hashed ticket invalid.
The default value for the <MachineKey> is to automatically generate a new one each time the website loads.
<machineKey validationKey="AutoGenerate,IsolateApps" decryptionKey="AutoGenerate,IsolateApps" validation="SHA1" decryption="Auto" />
Most web application pools are scheduled to restart every day or so, but some memory-restricted servers might have an application domain recycling every 30 minutes or less, or they shut down automatically during periods of inactivity. Your web also reloads if you modify the /bin
folder, your web.config, or a few other triggers.
So this MachineKey thing is used in a lot of core .NET systems. In fact, if you've ever tried to use the built in SqlMembershipProvider and enabled encrypted passwords, you've already been prompted to hard code this value.. But in general, this automatic setting works fine. About
once a day or more, your application will recycle and a new randomly generated key will be provided. You get a whole new cryptographic key without doing anything. That's like, a bonus or something, right?
Well, unless your needs are pretty basic, you'll bump your head eventually. If you're using WebForms, every now and then you'll get an error about an invalid viewstate if one of your users holds on to a form across AppPool recycles. Or maybe your users will need to re-login in
the middle of a session because their forms tickets are suddenly invalid. It's not a huge issue, but certainly far from ideal. So in our case, while the authentication tickets were being created with the purpose of being long-lasting, as soon as the application pool recycles, any
existing tickets are invalidated as their contents no longer match with the fresh MachineKey. There's no way a ticket will last a month.
The quick and dirty answer is to specify a <MachineKey> in the website's web.config. Let's use an online machine key generator and see what we get.
<machineKey
validationKey="C4E27B8F01F78BA426F61E80[...]"
decryptionKey="63ADAEE17C51D445197D9C[...]"
validation="SHA1" decryption="AES" />
And if we were looking for a simple solution, we'd just drop that line into our web.config and nasty mess would go away. The same key is preserved across AppPool restarts. And sure, I've used this technique plenty of times. But in a project that will be used by many users, I
certainly don't want these values
known and hard coded. While MegaBBS only uses authentication tickets for the most basic of uses (so forging a ticket won't get you anywhere), but the idea is you probably have other applications on your site, and I don't want to
be responsible for making them vulnerable.
{Sigh} At this point, it seems like we either need to find out a way to generate MachineKeys randomly during installation and update the user's web.config with these values, or ditch the whole notion of tickets entirely, which is becoming more and more attractive.
Given the first option, can we insert new keys directly into web.config. Here's what such code would look like in a perfect world.
Configuration config = WebConfigurationManager.OpenWebConfiguration(configPath);
MachineKeySection configSection = (MachineKeySection)config.GetSection("system.web/machineKey");
configSection.ValidationKey = GenerateRandomKey();
configSection.Save()
If only it were that easy! Under medium trust, you cannot edit the web.config, or even read its values directly. I understand the reasoning behind this, but man is it frustrating. We could instead treat the web.config as an XML or plain text file and insert these values manually.
But considering that direction gives me nervous butterflies in my stomach and I've learned to trust those butterflies. The web.config doubles as a catch-all application configuration file, with profound and far reaching effects. What if we clobber some setting, or what if you
already have a MachineKey entry and we accidentally overwrite it and nuke all your preciously encrypted passwords? I'm sure this approach has merits and can be done, perhaps even reliably, but I sure as heck don't want the responsibility of figuring this out.
At this point it seems like *nothing* is going to work like I want without a lot of heartache. So let's back out a bit. FormsAuthentication is nice, but I don't want to place the idealisim of using built-in components over using something that works exactly how we need. What
are our options now?
Design our own replacement for FormsAuthentication
The "old style" of doing authentication is to store the Member ID and hashed password in two cookies, reading it in each page request, and performing checks. That's not too bad of a solution, the password is hashed and the cookies are locked down with HttpOnly. But what if we want to use this cookie to store something sensitive? We can encrypt and tamper-proof things ourselves. Is there a strong performance penalty to doing so?
.NET comes with a number of different symmetric encryption providers. In order from strongest to weakest, we have
. Rijndael (AES) - Strong
. Triple DES - Strong
. DES - Moderate
. RC2 - Weak
I don't know how important the encryption here really is, so let's compare the performance of the different algorithims to help us decide. I wrote a test that builds 10,000 types of each, encodes, and decodes a simple string. The results were interesting.
RC2 DES 3DES AES
-------------------------------------------
Instantiate 2843ms 2841ms 2800ms 53ms
Encode 43ms 38ms 52ms 20ms
Decode 49ms 46ms 55ms 25ms
============================================
Total 2935ms 2925ms 2907ms 98ms
Aside from the constructor differences between the first three (Which I have not investigated), all four algorithims performed pretty well. This is over 10,000 iterations, we're talking about .0003 seconds worst-case to build the encryption transformer and decode and encode a
string. But AES is arguably one of the strongest algorithims, and in transformations it was about twice as fast. So the decision on which algorithim to choose seems obvious. We go with AES/Rijndael
AES requires a key and initialization vector, which is very similar to <MachineKey>. Since we have complete control over our own solution, we're not going to bother trying to write this to the web.config. Let's create a new file, /config/machinekey.config and use it for storing
these persistant values. We'll initialize them when our appplication starts.
/*Global.asax*/
public class MvcApplication : System.Web.HttpApplication
{
public static object instanceLock;
protected void Application_Start()
{
lock (instanceLock)
{
InitializeEncryption();
}
/* Do other stuff */
}
}
We use a lock since there could be multiple application instances spinning up at once, and we don't want them competing for read/write access to this global machinekey.config. InitializeEncryption will attempt to read this file. If it doesn't exist or if the keys are corrupt, it
generates new ones.
If it's unable to save the keys to the file, then you end up with new keys for each time the application starts (Back to square one). We could throw an exception here, and maybe that's an idea for a future update.
/*Global.asax*/
private void InitializeEncryption()
{
ApplicationGlobals.keyStorageAbsolutePath = Server.MapPath(ApplicationGlobals.keyStorageRelativePath);
string Result = null;
try
{
using (System.IO.StreamReader reader = new System.IO.StreamReader(ApplicationGlobals.keyStorageAbsolutePath))
{
Result = reader.ReadToEnd();
}
}
catch {}
if (String.IsNullOrEmpty(Result))
{
// Generate temporary keys
GenerateNewKeys();
}
else
{
string[] keys = Result.Split(new string[] {System.Environment.NewLine}, StringSplitOptions.RemoveEmptyEntries);
if (keys.Length != 2)
{
GenerateNewKeys();
}
else
{
ApplicationGlobals.EncryptionKey = Convert.FromBase64String(keys[0]);
ApplicationGlobals.EncryptionIv = Convert.FromBase64String(keys[1]);
if (ApplicationGlobals.EncryptionKey.Length != 16 || ApplicationGlobals.EncryptionIv.Length != 16)
{ GenerateNewKeys(); }
}
}
}
}
private void GenerateNewKeys()
{
// Generate two 16 byte values for internal encryption routines
// If the are unable to be persisted to the filesystem, they will be randomly generated each time the application starts
// Therefore, do not use these keys for encrypting anything that requires retrieval much later, like passwords in a database,
// as they may be unrecoverable after an application restart.
// Create two random values
System.Security.Cryptography.RandomNumberGenerator randomGenerator = System.Security.Cryptography.RandomNumberGenerator.Create();
byte[] randomKey = new byte[16];
byte[] randomIV = new byte[16];
randomGenerator.GetBytes(randomKey);
randomGenerator.GetBytes(randomIV);
ApplicationGlobals.EncryptionKey = randomKey.Take(16).ToArray();
ApplicationGlobals.EncryptionIv = randomIV.Take(16).ToArray();
// Convert to ASCII characters for persisting to the filesystem
string saveEncryptionKey = Convert.ToBase64String(ApplicationGlobals.EncryptionKey);
string saveEncryptionIV = Convert.ToBase64String(ApplicationGlobals.EncryptionIv);
// Attempt to store the two keys in a text file
System.IO.TextWriter tw = new System.IO.StreamWriter(ApplicationGlobals.keyStorageAbsolutePath, false, Encoding.ASCII);
tw.WriteLine(saveEncryptionKey);
tw.WriteLine(saveEncryptionIV);
tw.Close();
tw.Dispose();
}
Now we can store encrypted data and be reasonably certain sure that we've got a unique persistent set of keys for each website installation. Let's hook this code into the pipeline and see what it looks like. For this example, I'm not going to create a full HttpModule, but we
will hook the AuthenticateRequest event in our global.asax
/* Global.asax*/
protected void Application_AuthenticateRequest(object sender, EventArgs e)
{
if (HttpContext.Current.User == null)
{
BBSAuthenticationTicket ticket = BBSAuthenticationService.ReadAuthenticationTicket();
if (ticket.MemberId > 0)
{
// Custom IPrincipal / IIdentities cause errors under cassini, so we'll cache the password in the role
GenericPrincipal principal = new GenericPrincipal(new GenericIdentity(ticket.MemberId.ToString()), new string[] { "PWD:" + ticket.Password });
HttpContext.Current.User = principal;
}
}
}
/* Service factory */
public static EncryptionTransformer CreateEncryptionTransformer()
{
return new EncryptionTransformer(EncryptionAlgorithm.Rijndael, ApplicationGlobals.EncryptionKey, ApplicationGlobals.EncryptionIv);
}
/* Login page */
if (loginSuccessful)
{
// Set the authentication ticket and redirect the user to a ReturnURL
BBSAuthenticationService.IssueAuthenticationTicket(memberId, loginAccount.Password, "", rememberMe);
}
/* Pickup in base controller */
if (System.Web.HttpContext.Current.User != null)
{
System.Security.Principal.IPrincipal identity = System.Web.HttpContext.Current.User;
CurrentUser = membership.GetUserById(Pd9.Helpers.ValidateInt(identity.Identity.Name), true);
// Custom IPrincipal / IIdentities cause errors under cassini, so we'll cache the password in the role
if (CurrentUser != null && identity.IsInRole("PWD:" + CurrentUser.Password))
{
CurrentUserPermissions = securityService.GetMemberPermissions(CurrentUser);
CurrentUserSiteUserlevel = CurrentUserPermissions.GetUserlevelInTarget(SiteModules.SiteCore, SiteModules.DefaultTargetForSiteModules);
}
}
And finally, the complete code to the BBSAuthenticationService, our replacement for FormsAuthentication.
using System;
using System.Web;
using Pd9;
using BBS.Services;
using System.Text;
using System.Security.Principal;
using System.Runtime.Serialization;
namespace BBS.Classes
{
public class BBSAuthenticationTicket
{
/// <summary>
/// The member Id if validated, 0 otherwise
/// </summary>
public int MemberId { get; private set; }
/// <summary>
/// The hashed password
/// </summary>
public string Password { get; private set; }
/// <summary>
/// Optional userdata
/// </summary>
public string Userdata { get; set; }
/// <summary>
/// The encrypted ticket
/// </summary>
public string Ticket
{
get
{
return getTicket();
}
}
private EncryptionTransformer encryptor;
/// <summary>
/// Initialize with an encrypted ticket
/// </summary>
/// <param name="ticket">The encrypted ticket</param>
public BBSAuthenticationTicket(string ticket)
{
encryptor = ServiceFactory.CreateEncryptionTransformer();
decodeTicket(ticket);
}
/// <summary>
/// Initialize with user supplied plaintext data
/// </summary>
/// <param name="memberId"></param>
/// <param name="password">Hashed password</param>
/// <param name="userdata">Optional userdata</param>
public BBSAuthenticationTicket(int memberId, string password, string userdata)
{
encryptor = ServiceFactory.CreateEncryptionTransformer();
this.MemberId = memberId;
this.Password = password ?? "";
this.Userdata = userdata ?? "";
}
/// <summary>
/// Returns the encrypted ticket
/// </summary>
/// <returns></returns>
private string getTicket()
{
string ticketToEncrypt = MemberId.ToString() + ";" + Password + ";" + Convert.ToBase64String(Encoding.UTF8.GetBytes(Userdata)) + ";" + MemberId.ToString();
return encryptor.Encrypt(ticketToEncrypt);
}
/// <summary>
/// Given an encoded ticket, try to parse the memberId and hashed password
/// </summary>
/// <param name="ticket"></param>
private void decodeTicket(string ticket)
{
if (ticket == null)
{
setFailed();
return;
}
string decodedTicket = encryptor.Decrypt(ticket);
string[] decodedTicketParameters = decodedTicket.Split(';');
if (decodedTicketParameters.Length != 4)
{
setFailed();
return;
}
else
{
int m0, m2;
m0 = Pd9.Helpers.ValidateInt(decodedTicketParameters[0]);
m2 = Pd9.Helpers.ValidateInt(decodedTicketParameters[3]);
if (m0 != m2)
{
setFailed();
return;
}
MemberId = m0;
Password = Pd9.Helpers.ValidateString(decodedTicketParameters[1]);
Userdata = Pd9.Helpers.ValidateString(decodedTicketParameters[2]);
}
}
/// <summary>
/// Decoding a ticket has failed
/// </summary>
private void setFailed()
{
MemberId = 0;
Password = "";
Userdata = "";
}
}
public static class BBSAuthenticationService
{
private static string cookieName = ".BBSAUTH";
public static void IssueAuthenticationTicket(int memberId, string password, string userdata, bool persistent)
{
password = password ?? "";
userdata = userdata ?? "";
BBSAuthenticationTicket ticket = new BBSAuthenticationTicket(memberId, password, userdata);
HttpCookie ticketCookie = new HttpCookie(cookieName);
ticketCookie.HttpOnly = true;
if (persistent)
{
ticketCookie.Expires = DateTime.Now.AddMonths(3);
}
ticketCookie.Value = ticket.Ticket;
System.Web.HttpContext.Current.Response.Cookies.Add(ticketCookie);
}
public static BBSAuthenticationTicket ReadAuthenticationTicket()
{
HttpCookie encryptedTicketCookie = System.Web.HttpContext.Current.Request.Cookies[cookieName];
if (encryptedTicketCookie == null)
{
return new BBSAuthenticationTicket(0, "", "");
}
else
{
return new BBSAuthenticationTicket(encryptedTicketCookie.Value ?? "");
}
}
}
}
Attached: Some related material including my benchmark. Bonus: It includes the EncryptionTransformer class that easily wraps the four encryption providers.
EncryptionTest.zip (104.04 kb)