asp.netasp.net-web-apiforms-authenticationowinformsauthenticationticket

OWIN Self-Host CookieAuthentication & Legacy .NET 4.0 Application / FormsAuthenticationTicket


I have two bounded contexts:

  1. ASP.NET 4.0 MVC/WebForms Application
  2. OWIN Self-Hosted w/ ASP.NET Web API 2

The former is an existing well-established product, however, its lack of architecture (SmartUI) has led to an difficult-to-maintain codebase with concerns of extensibility and scalability now more glaringly visible.

We are iteratively addressing this issue by introducing a new backend application - exposable via OWIN/WebAPI services.

Currently we're only looking to leverage cookie authentication in the new application. Originally, I thought it would be a breeze to use existing cookie auth/validation based upon FormsAuthenticationTicket. Evidently this is not true.

In our WebForms application, we make use of MachineKey to designate our decryptionKey and validationKey to support our web farm. In .NET4, the default algorithm is AES if I'm not mistaken. I assumed it would be simple to leverage this information to build our own TicketDataFormat if the default wouldn't suffice.

First things learned:

Ideally, we're not looking to update our main application to .NET 4.5 to replace cookie encryption. Does anyone know of a way to integrate OWIN's CookieAuthentication with an existing FormsAuthenticationTicket?

We tried creating custom: IDataProtector, SecureDataFormat<AuthenticationTicket>, IDataSerializer<AuthenticationTicket> implementations. The IDataSerializer would be responsible for translation between FormsAuthenticationTicket and AuthenticationTicket.

Unfortunately, I can't find accurate information regarding Microsoft's ticket encrpytion. Here is our example idea for IDataProtector:

public byte[] Unprotect(byte[] protectedData)
{
    using (var crypto = new AesCryptoServiceProvider())
    {
        byte[] result = null;
        const Int32 blockSize = 16;
        crypto.KeySize = 192;
        crypto.Key = "<MachineKey>".ToBytesFromHexadecimal();
        crypto.IV = protectedData.Take(blockSize).ToArray();
        crypto.Padding = PaddingMode.None; // This prevents a padding exception thrown.

        using (var decryptor = crypto.CreateDecryptor(crypto.Key, crypto.IV))
        using (var msDecrypt = new MemoryStream(protectedData.Skip(blockSize).Take(protectedData.Length - blockSize).ToArray()))
        {
            using (var csDecrypt = new CryptoStream(msDecrypt, decryptor, CryptoStreamMode.Read))
            {
                result = new byte[protectedData.Length - blockSize];
                csDecrypt.Read(result, 0, result.Length);
            }
        }

        return result;
    }
}

This assumes Microsoft prepends the IV to the byte array. This also assumes the MachineKey is the AES key used. However, I have read that MS uses the MachineKey for a key derivation function - taking into account other settings like AppIsolation, AppVirtualLocation, AppId, etc. Basically, this was a shot in the dark and I need some light!

Our Current Approach

We're currently prototyping using a secondary cookie to establish identity for the new application context alongside the existing .ASPXAUTH. Unfortunately, this means keeping session sliding in sync in both AuthenticationTicket and FormsAuthenticationTicket.

Related Posts

Accepting ASP.NET Forms Authentication cookies in an OWIN-hosted SignalR implementation?


Solution

  • There was some initial confusion on whether I could use the <machineKey> element within app.config. Further prototyping has shown that I can successfully share a single FormsAuthenticationTicket between both bounded contexts with the following code.

    Ideally, we will implement a proper authorization server to enable OpenID Connect, Forms, WS-Fed, etc and have both applications operate off bearer tokens. However, this is working nicely in the short-term. Hope this helps!

    I have tested and verified successful encryption/decryption with both applications, sliding of formsauthticket timeout. You should be mindful of your web.config formsAuthentication setting for ticketCompatibilityMode.


    appBuilder.UseCookieAuthentication(new CookieAuthenticationOptions
            {
                CookieName = FormsAuthentication.FormsCookieName,
                CookieDomain = FormsAuthentication.CookieDomain,
                CookiePath = FormsAuthentication.FormsCookiePath,
                CookieSecure = CookieSecureOption.SameAsRequest,
                AuthenticationMode = AuthenticationMode.Active,
                ExpireTimeSpan = FormsAuthentication.Timeout,
                SlidingExpiration = true,
                AuthenticationType = "Forms",
                TicketDataFormat = new SecureDataFormat<AuthenticationTicket>(
                    new FormsAuthenticationTicketSerializer(), 
                    new FormsAuthenticationTicketDataProtector(), 
                    new HexEncoder())
            });
    

    <!-- app.config for OWIN Host - Only used for compatibility with existing auth ticket. -->
    <authentication mode="Forms">
      <forms domain=".hostname.com" protection="All" ... />
    </authentication>
    <machineKey validationKey="..." decryptionKey="..." validation="SHA1" />
    

    public class HexEncoder : ITextEncoder
    {
        public String Encode(Byte[] data)
        {
            return data.ToHexadecimal();
        }
    
        public Byte[] Decode(String text)
        {
            return text.ToBytesFromHexadecimal();
        }
    }
    

    public class FormsAuthenticationTicketDataProtector : IDataProtector
    {
        public Byte[] Protect(Byte[] userData)
        {
            FormsAuthenticationTicket ticket;
            using (var memoryStream = new MemoryStream(userData))
            {
                var binaryFormatter = new BinaryFormatter();
                ticket = binaryFormatter.Deserialize(memoryStream) as FormsAuthenticationTicket;
            }
    
            if (ticket == null)
            {
                return null;
            }
    
            try
            {
                var encryptedTicket = FormsAuthentication.Encrypt(ticket);
    
                return encryptedTicket.ToBytesFromHexadecimal();
            }
            catch
            {
                return null;
            }
        }
    
        public Byte[] Unprotect(Byte[] protectedData)
        {
            FormsAuthenticationTicket ticket;
            try
            {
                ticket = FormsAuthentication.Decrypt(protectedData.ToHexadecimal());
            }
            catch
            {
                return null;
            }
    
            if (ticket == null)
            {
                return null;
            }
    
            using (var memoryStream = new MemoryStream())
            {
                var binaryFormatter = new BinaryFormatter();
                binaryFormatter.Serialize(memoryStream, ticket);
    
                return memoryStream.ToArray();
            }
        }
    }
    

    public class FormsAuthenticationTicketSerializer : IDataSerializer<AuthenticationTicket>
    {
        public Byte[] Serialize(AuthenticationTicket model)
        {
            var userTicket = new FormsAuthenticationTicket(
                2,
                model.Identity.GetClaimValue<String>(CustomClaim.UserName),
                new DateTime(model.Properties.IssuedUtc.Value.UtcDateTime.Ticks, DateTimeKind.Utc),
                new DateTime(model.Properties.ExpiresUtc.Value.UtcDateTime.Ticks, DateTimeKind.Utc),
                model.Properties.IsPersistent,
                String.Format(
                    "AuthenticationType={0};SiteId={1};SiteKey={2};UserId={3}",
                    model.Identity.AuthenticationType,
                    model.Identity.GetClaimValue<String>(CustomClaim.SiteId),
                    model.Identity.GetClaimValue<String>(CustomClaim.SiteKey),
                    model.Identity.GetClaimValue<String>(CustomClaim.UserId)),
                FormsAuthentication.FormsCookiePath);
    
            using (var dataStream = new MemoryStream())
            {
                var binaryFormatter = new BinaryFormatter();
                binaryFormatter.Serialize(dataStream, userTicket);
    
                return dataStream.ToArray();
            }
        }
    
        public AuthenticationTicket Deserialize(Byte[] data)
        {
            using (var dataStream = new MemoryStream(data))
            {
                var binaryFormatter = new BinaryFormatter();
                var ticket = binaryFormatter.Deserialize(dataStream) as FormsAuthenticationTicket;
                if (ticket == null)
                {
                    return null;
                }
    
                var userData = ticket.UserData.ToNameValueCollection(';', '=');
                var authenticationType = userData["AuthenticationType"];
                var siteId = userData["SiteId"];
                var siteKey = userData["SiteKey"];
                var userId = userData["UserId"];
    
                var claims = new[]
                {
                    CreateClaim(CustomClaim.UserName, ticket.Name),
                    CreateClaim(CustomClaim.UserId, userId),
                    CreateClaim(CustomClaim.AuthenticationMethod, authenticationType),
                    CreateClaim(CustomClaim.SiteId, siteId),
                    CreateClaim(CustomClaim.SiteKey, siteKey)
                };
    
                var authTicket = new AuthenticationTicket(new UserIdentity(claims, authenticationType), new AuthenticationProperties());
                authTicket.Properties.IssuedUtc = new DateTimeOffset(ticket.IssueDate);
                authTicket.Properties.ExpiresUtc = new DateTimeOffset(ticket.Expiration);
                authTicket.Properties.IsPersistent = ticket.IsPersistent;
    
                return authTicket;
            }
        }
    
        private Claim CreateClaim(String type, String value)
        {
            return new Claim(type, value, ClaimValueTypes.String, CustomClaim.Issuer);
        }
    }