Digest Authentication with ASP.NET Web API (Part 2)

This is continuation of my earlier post. Similar to basic authentication, we will use a delegating handler to implement digest authentication with ASP.NET Web API. When the handler returns a 401, it generates a server nonce and sends that back as part of the WWW-Authenticate header payload. Sequence is below.

Digest Authentication Sequence Diagram

AuthenticationHandler calls the static property UnauthorizedResponseHeader in Header class to create the WWW-Authenticate header for the 401 response. As part of this call, Nonce.Generate() gets called and a new nonce is generated. Once the client resubmits with a digest authorization header, handler creates an instance of the Header class parsing the authorization header string. It then calls Nonce.IsValid() to verify the digest and completes the authentication process.

401 Unauthorized response from ASP.NET Web API will be something like this. It has enough information (mainly the server nonce and QOP) for the client – Internet Explorer, in this case, to frame the authorization header correctly with sufficient information for the re-submission.

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Digest realm=”RealmOfBadri”, nonce=”5039c371d8eed05f0166d61e629e9e40″, qop=”auth”

Here is the Nonce class. It generates the nonce and also validates the nonce when it comes back in the authorization header. I’m using a static ConcurrentDictionary to store the generated nonces along with the time stamp, just for illustration only. This is not a scalable option given that it is single-threaded. Since dictionary is in the AppDomain, this will not work in case of a pure stateless web farm that does not implement sticky sessions.

public class Nonce
{
    private static ConcurrentDictionary<string, Tuple<int, DateTime>>
    nonces = new ConcurrentDictionary<string, Tuple<int, DateTime>>();

    public static string Generate()
    {
        byte[] bytes = new byte[16];
        RandomNumberGenerator rng = RandomNumberGenerator.Create();
        rng.GetBytes(bytes);

        string nonce = bytes.ToMD5Hash();

        nonces.TryAdd(nonce, new Tuple<int, DateTime>
                    (0, DateTime.Now.AddMinutes(10)));

        return nonce;
    }

    public static bool IsValid(string nonce, string nonceCount)
    {
        Tuple<int, DateTime> cachedNonce = null;
        nonces.TryGetValue(nonce, out cachedNonce);

        if (cachedNonce != null) // nonce is found
        {
            // nonce count is greater than the one in record
            if (Int32.Parse(nonceCount) > cachedNonce.Item1)
            {
                // nonce has not expired yet
                if (cachedNonce.Item2 > DateTime.Now)
                {
                    // update the dictionary to reflect the nonce
                    // count just received in this request
                    nonces[nonce] = new Tuple<int, DateTime>
                      (cachedNonce.Item1 + 1, cachedNonce.Item2);

                    // every thing seems to be fine
                    // server nonce is fresh and nonce count seems
                    // to be incremented. Does not look like replay.
                    return true;
                }
            }
        }

        return false;
    }
}

Client uses the generated nonce and creates the authorization header and sends that in the next request. Header has the scheme first, which is Digest. Then, there is a bunch of key value pairs.  Handler parses this header string through the Header class. Logic is pretty simple – remove the quotes, split by field delimiter (,) and then for each field, take what is to the left of the first encountered = as the key and the rest as the value and populate the corresponding property.

Authorization: Digest username=”aaa”, realm=”RealmOfBadri”, nonce=”5039c371d8eed05f0166d61e629e9e40″, uri=”/api/values”, cnonce=”d2e6b4e6df1c724669648b448d36bac2″, nc=00000001, response=”003f9f5e9cdd1ffd48ff6dcb58c8c5a6″, qop=”auth”

public class Header
{
    public Header() { }

    public Header(string header, string method)
    {
        string keyValuePairs = header.Replace("\"", String.Empty);

        foreach (string keyValuePair in keyValuePairs.Split(','))
        {
            int index = keyValuePair.IndexOf("=");
            string key = keyValuePair.Substring(0, index);
            string value = keyValuePair.Substring(index + 1);

            switch (key)
            {
                case "username": this.UserName = value; break;
                case "realm": this.Realm = value; break;
                case "nonce": this.Nonce = value; break;
                case "uri": this.Uri = value; break;
                case "nc": this.NounceCounter = value; break;
                case "cnonce": this.Cnonce = value; break;
                case "response": this.Response = value; break;
                case "method": this.Method = value; break;
            }
        }

        if (String.IsNullOrEmpty(this.Method))
            this.Method = method;
    }

    public string Cnonce { get; private set; }
    public string Nonce { get; private set; }
    public string Realm { get; private set; }
    public string UserName { get; private set; }
    public string Uri { get; private set; }
    public string Response { get; private set; }
    public string Method { get; private set; }
    public string NounceCounter { get; private set; }

    // This property is used by the handler to generate a
    // nonce and get it ready to be packaged in the
    // WWW-Authenticate header, as part of 401 response
    public static Header UnauthorizedResponseHeader
    {
        get
        {
            return new Header()
            {
                 Realm = "RealmOfBadri",
                 Nonce = DigestAuthN.Nonce.Generate()
            };
        }
    }

    public override string ToString()
    {
        StringBuilder header = new StringBuilder();
        header.AppendFormat("realm=\"{0}\"", Realm);
        header.AppendFormat(", nonce=\"{0}\"", Nonce);
        header.AppendFormat(", qop=\"{0}\"", "auth");
        return header.ToString();
    }
}

AuthenticationHandler, which is a DelegatingHandler brings together both the earlier classes. It validates the nonce by calling IsValid() in Nonce. It then proceeds with calculating the MD5 digest and compares the calculated value with the one sent by client in response field. If they match, authentication is deemed successful and a principal can be created and set to request.Properties[HttpPropertyKeys.UserPrincipalKey]. In this sample, I’m using the password same as user name, again for the purpose of illustration.

public class AuthenticationHandler : DelegatingHandler
{
    protected override Task<HttpResponseMessage> SendAsync(
                                 HttpRequestMessage request,
                                 CancellationToken cancellationToken)
    {
        try
        {
            var headers = request.Headers;
            if (headers.Authorization != null)
            {
                Header header = new Header(
                                  request.Headers.Authorization.Parameter,
                                     request.Method.Method);

                if (Nonce.IsValid(header.Nonce, header.NounceCounter))
                {
                    // Just assuming password is same as username
                    // for the purpose of illustration
                    string password = header.UserName;

                    string ha1 = String.Format("{0}:{1}:{2}",
                                        header.UserName,
                                        header.Realm,
                                        password).ToMD5Hash();

                    string ha2 = String.Format("{0}:{1}",
                                        header.Method,
                                        header.Uri).ToMD5Hash();

                    string computedResponse = String
                                  .Format("{0}:{1}:{2}:{3}:{4}:{5}",
                                        ha1,
                                        header.Nonce,
                                        header.NounceCounter,
                                        header.Cnonce,
                                        "auth",
                                        ha2).ToMD5Hash();

                    if (String.CompareOrdinal(header.Response,
                                                computedResponse) == 0)
                    {
                        // digest computed matches the value sent by
                        // client in the response field. Looks like an
                        // authentic client! Create a principal here.
                    }
                }
            }

            return base.SendAsync(request, cancellationToken)
                                     .ContinueWith((task) =>
            {
                var response = task.Result;

                if (response.StatusCode == HttpStatusCode.Unauthorized)
                {
                    response.Headers.WwwAuthenticate.Add(
                        new AuthenticationHeaderValue("Digest",
                          Header.UnauthorizedResponseHeader.ToString()));
                }

                return response;
            });
        }
        catch (Exception)
        {
            return Task<HttpResponseMessage>.Factory.StartNew(() =>
            {
                var response = new HttpResponseMessage
                                      (HttpStatusCode.Unauthorized);
                response.Headers.WwwAuthenticate.Add(
                         new AuthenticationHeaderValue("Digest",
                         Header.UnauthorizedResponseHeader.ToString()));

                return response;
            });
        }
    }
}

Finally, an extension to convert string and byte[] to their corresponding MD5 hashes.

public static class HashExtension
{
    public static string ToMD5Hash(this byte[] bytes)
    {
        StringBuilder hash = new StringBuilder();
        MD5 md5 = MD5.Create();

        md5.ComputeHash(bytes)
              .ToList()
              .ForEach(b => hash.AppendFormat("{0:x2}", b));

        return hash.ToString();
    }

    public static string ToMD5Hash(this string inputString)
    {
        return Encoding.UTF8.GetBytes(inputString).ToMD5Hash();
    }
}

So, with this, we are done with implementing digest authentication in ASP.NET Web API. I plan to blog about playing around with the digest authentication trying to break it, in a future post.

Advertisements

5 thoughts on “Digest Authentication with ASP.NET Web API (Part 2)

  1. You no longer need to use request.Properties[HttpPropertyKeys.UserPrincipalKey]. You can just set it to Thread.CurrentPrincipal. In ApiController, they have added a new Property User, which just returns Thread.CP.

    For the claims part, I’m using WIF 1.0 and .NET 4.0. I’m guessing Claim class you are referring to, is from Microsoft.IdentityModel.Claims, which is part of WIF 1.0. You have to install the run time first (from http://www.microsoft.com/en-us/download/details.aspx?id=17331) and then the SDK (from http://www.microsoft.com/en-us/download/details.aspx?id=4451).

    Please note MSFT has overhauled WIF and all these classes are part of the .NET 4.5 core framework itself. Just FYI only. HTH.

  2. Thanks, this is excellent article! However I have problem with HttpPropertyKeys.UserPrincipalKey, it doesn’t exist in System.Web.Http.Hosting (v. 4.0.0.0). Also Claim constructor has changed in System.IdentityModel.Claims. Are you using 3.5 versions? Do you know how this can be done in 4.0?

  3. WordPress does not allow me to upload a zip file but I have already given the source code of the individual class files in the blog post. You can create a new ASP.NET MVC 4.0 Web Api project and add these 4 class files – AuthenticationHandler.cs, HashExtension.cs, Header.cs and Nonce.cs and copy paste the code from the post. You would need to add the following line into Application_Start() method in Global.asax.cs.

    GlobalConfiguration.Configuration.MessageHandlers.Add(new AuthenticationHandler());

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s