Generic wrapper for calling ASP.NET WEB API REST service using HttpClient with optional HMAC authentication

Wanting to implement my business rules in a separate tier running on a different server than the presentation tier I decided that I wanted the business tier to expose its functionality via REST methods using the web api. I then wanted a standard reusable generic way of calling the different controllers so I started on a proof of concept.

Whilst developing the proof of concept I also explored ways of securing the web api calls so that the controllers could not be used indiscriminately. I initially tried using a shared secret in the request headers and then extended this to use HMAC.

In addition to the wrapper for the HttpClient calls to the web api I also needed an ActionFilter to use with the web api controllers to check the shared secret or HMAC code.

This is the source for the client wrapper:

using System;
using System.Configuration;
using System.Globalization;
using System.Net.Http;
using System.Net.Http.Formatting;
using System.Net.Http.Headers;
using System.Threading.Tasks;
using System.Web.Script.Serialization;
using Newtonsoft.Json;

namespace WebApiAuthentication
{
    /// <summary>
    /// A wrapper for a web api REST service that optionally allows different levels
    /// of authentication to be added to the header of the request that will then be
    /// checked using the SecretAuthenticationFilter in the web api controller methods.
    /// 
    /// Example Usage:
    ///   No authentication...
    ///     var productsClient = new RestClient<Product>("http://localhost/ServiceTier/api/");
    ///   Simple authentication...
    ///     var productsClient = new RestClient<Product>("http://localhost/ServiceTier/api/","productscontrollersecret");
    ///   HMAC authentication...
    ///     var productsClient = new RestClient<Product>("http://localhost/ServiceTier/api/","productscontrollersecret", true);
    /// 
    /// Example method calls:
    ///   var getManyResult = productsClient.GetMultipleItemsRequest("products?page=1").Result;
    ///   var getSingleResult = productsClient.GetSingleItemRequest("products/1").Result;
    ///   var postResult = productsClient.PostRequest("products", new Product { Id = 3, ProductName = "Dynamite", ProductDescription = "Acme bomb" }).Result;
    ///   productsClient.PutRequest("products/3", new Product { Id = 3, ProductName = "Dynamite", ProductDescription = "Acme bomb" }).Wait();
    ///   productsClient.DeleteRequest("products/3").Wait();
    /// </summary>
    /// <typeparam name="T">The class being manipulated by the REST api</typeparam>
    public class RestClient<T> where T : class
    {
        private readonly string _baseAddress;
        private readonly string _sharedSecretName;
        private readonly bool _hmacSecret;

        public RestClient(string baseAddress) : this(baseAddress, null, false) { }
        public RestClient(string baseAddress, string sharedSecretName) : this(baseAddress, sharedSecretName, false) { }
        public RestClient(string baseAddress, string sharedSecretName, bool hmacSecret)
        {
            // e.g. http://localhost/ServiceTier/api/
            _baseAddress = baseAddress;
            _sharedSecretName = sharedSecretName;
            _hmacSecret = hmacSecret;
        }

        /// <summary>
        /// Used to setup the base address, that we want json, and authentication headers for the request
        /// </summary>
        /// <param name="client">The HttpClient we are configuring</param>
        /// <param name="methodName">GET, POST, PUT or DELETE. Aim to prevent hacker changing the 
        /// method from say GET to DELETE</param>
        /// <param name="apiUrl">The end bit of the url we use to call the web api method</param>
        /// <param name="content">For posts and puts the object we are including</param>
        private void SetupClient(HttpClient client, string methodName, string apiUrl, T content = null)
        {
            // Three versions in one.
            // Just specify a base address and no secret token will be added
            // Specify a sharedSecretName and we will include the contents of it found in the web.config as a SecretToken in the header
            // Ask for HMAC and a HMAC will be generated and added to the request header
            const string secretTokenName = "SecretToken";

            client.BaseAddress = new Uri(_baseAddress);
            client.DefaultRequestHeaders.Accept.Clear();
            client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

            if (_hmacSecret)
            {
                // hmac using shared secret a representation of the message, as we are
                // including the time in the representation we also need it in the header
                // to check at the other end.
                // You might want to extend this to also include a username if, for instance,
                // the secret key varies by username
                client.DefaultRequestHeaders.Date = DateTime.UtcNow;
                var datePart = client.DefaultRequestHeaders.Date.Value.UtcDateTime.ToString(CultureInfo.InvariantCulture);

                var fullUri = _baseAddress + apiUrl;

                var contentMD5 = "";
                if (content != null)
                {
                    var json = new JavaScriptSerializer().Serialize(content);
                    contentMD5 = Hashing.GetHashMD5OfString(json);
                }

                var messageRepresentation = 
                    methodName + "\n" + 
                    contentMD5 + "\n" +
                    datePart + "\n" + 
                    fullUri;

                var sharedSecretValue = ConfigurationManager.AppSettings[_sharedSecretName];

                var hmac = Hashing.GetHashHMACSHA256OfString(messageRepresentation, sharedSecretValue);
                client.DefaultRequestHeaders.Add(secretTokenName, hmac);
            }
            else if (!string.IsNullOrWhiteSpace(_sharedSecretName))
            {
                var sharedSecretValue = ConfigurationManager.AppSettings[_sharedSecretName];
                client.DefaultRequestHeaders.Add(secretTokenName, sharedSecretValue);
                
            }
        }

        /// <summary>
        /// For getting a single item from a web api uaing GET
        /// </summary>
        /// <param name="apiUrl">Added to the base address to make the full url of the 
        /// api get method, e.g. "products/1" to get a product with an id of 1</param>
        /// <returns>The item requested</returns>
        public async Task<T> GetSingleItemRequest(string apiUrl)
        {
            T result = null;

            using (var client = new HttpClient())
            {
                SetupClient(client, "GET", apiUrl);

                var response = await client.GetAsync(apiUrl).ConfigureAwait(false);

                response.EnsureSuccessStatusCode();

                await response.Content.ReadAsStringAsync().ContinueWith((Task<string> x) =>
                {
                    if (x.IsFaulted)
                        throw x.Exception;

                    result = JsonConvert.DeserializeObject<T>(x.Result);
                });
            }

            return result;
        }

        /// <summary>
        /// For getting multiple (or all) items from a web api using GET
        /// </summary>
        /// <param name="apiUrl">Added to the base address to make the full url of the 
        /// api get method, e.g. "products?page=1" to get page 1 of the products</param>
        /// <returns>The items requested</returns>
        public async Task<T[]> GetMultipleItemsRequest(string apiUrl)
        {
            T[] result = null;

            using (var client = new HttpClient())
            {
                SetupClient(client, "GET", apiUrl);

                var response = await client.GetAsync(apiUrl).ConfigureAwait(false);

                response.EnsureSuccessStatusCode();

                await response.Content.ReadAsStringAsync().ContinueWith((Task<string> x) =>
                {
                    if (x.IsFaulted)
                        throw x.Exception;

                    result = JsonConvert.DeserializeObject<T[]>(x.Result);
                });
            }

            return result;
        }

        /// <summary>
        /// For creating a new item over a web api using POST
        /// </summary>
        /// <param name="apiUrl">Added to the base address to make the full url of the 
        /// api post method, e.g. "products" to add products</param>
        /// <param name="postObject">The object to be created</param>
        /// <returns>The item created</returns>
        public async Task<T> PostRequest(string apiUrl, T postObject)
        {
            T result = null;

            using (var client = new HttpClient())
            {
                SetupClient(client, "POST", apiUrl, postObject);

                var response = await client.PostAsync(apiUrl, postObject, new JsonMediaTypeFormatter()).ConfigureAwait(false);

                response.EnsureSuccessStatusCode();

                await response.Content.ReadAsStringAsync().ContinueWith((Task<string> x) =>
                {
                    if (x.IsFaulted)
                        throw x.Exception;

                    result = JsonConvert.DeserializeObject<T>(x.Result);

                });
            }

            return result;
        }

        /// <summary>
        /// For updating an existing item over a web api using PUT
        /// </summary>
        /// <param name="apiUrl">Added to the base address to make the full url of the 
        /// api put method, e.g. "products/3" to update product with id of 3</param>
        /// <param name="putObject">The object to be edited</param>
        public async Task PutRequest(string apiUrl, T putObject)
        {
            using (var client = new HttpClient())
            {
                SetupClient(client, "PUT", apiUrl, putObject);

                var response = await client.PutAsync(apiUrl, putObject, new JsonMediaTypeFormatter()).ConfigureAwait(false);

                response.EnsureSuccessStatusCode();
            }
        }

        /// <summary>
        /// For deleting an existing item over a web api using DELETE
        /// </summary>
        /// <param name="apiUrl">Added to the base address to make the full url of the 
        /// api delete method, e.g. "products/3" to delete product with id of 3</param>
        public async Task DeleteRequest(string apiUrl)
        {
            using (var client = new HttpClient())
            {
                SetupClient(client, "DELETE", apiUrl);

                var response = await client.DeleteAsync(apiUrl).ConfigureAwait(false);

                response.EnsureSuccessStatusCode();
            }
        }
    }
}

This is the source for the ActionFilter:

using System;
using System.Configuration;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Web.Http.Filters;

namespace WebApiAuthentication
{
    /// <summary>
    /// Can be used to decorate a web api controller or controller method. 
    /// 
    /// If HmacSecret is false or not specified it will simply check if the header contains 
    /// a SecretToken value that is the  same as what is held in the item with the name 
    /// contained in SharedSecretName in the web.config appsettings
    /// 
    /// If HmacSecret is true it takes things further by checking the header of the
    /// message contains a SecretToken value that is a HMAC of the message generated
    /// using the value in the SharedSecretName in the web.config appsettings as the key.
    /// </summary>
    public class SecretAuthenticationFilter : ActionFilterAttribute
    {
        // The name of the web.config item where the shared secret is stored
        public string SharedSecretName { get; set; }
        public bool HmacSecret { get; set; }

        public override void OnActionExecuting(System.Web.Http.Controllers.HttpActionContext actionContext)
        {
            // We can only validate if the action filter has had this passed in
            if (!string.IsNullOrWhiteSpace((SharedSecretName)))
            {
                // Name of meta data to appear in header of each request
                const string secretTokenName = "SecretToken";

                var goodRequest = false;

                // The request should have the secretTokenName in the header containing the shared secret
                if (actionContext.Request.Headers.Contains(secretTokenName))
                {
                    var messageSecretValue = actionContext.Request.Headers.GetValues(secretTokenName).First();
                    var sharedSecretValue = ConfigurationManager.AppSettings[SharedSecretName];

                    if (HmacSecret)
                    {
                        Stream reqStream = actionContext.Request.Content.ReadAsStreamAsync().Result;
                        if (reqStream.CanSeek)
                        {
                            reqStream.Position = 0;
                        }

                        //now try to read the content as string
                        string content = actionContext.Request.Content.ReadAsStringAsync().Result;
                        var contentMD5 = content == "" ? "" : Hashing.GetHashMD5OfString(content);
                        var datePart = "";
                        var requestDate = DateTime.Now.AddDays(-2);
                        if (actionContext.Request.Headers.Date != null)
                        {
                            requestDate = actionContext.Request.Headers.Date.Value.UtcDateTime;
                            datePart = requestDate.ToString(CultureInfo.InvariantCulture);
                        }
                        var methodName = actionContext.Request.Method.Method;
                        var fullUri = actionContext.Request.RequestUri.ToString();

                        var messageRepresentation =
                            methodName + "\n" +
                            contentMD5 + "\n" +
                            datePart + "\n" +
                            fullUri;

                        var expectedValue = Hashing.GetHashHMACSHA256OfString(messageRepresentation, sharedSecretValue);

                        // Are the hmacs the same, and have we received it within +/- 5 mins (sending and
                        // receiving servers may not have exactly the same time)
                        if (messageSecretValue == expectedValue
                            && requestDate > DateTime.UtcNow.AddMinutes(-5)
                            && requestDate < DateTime.UtcNow.AddMinutes(5))
                            goodRequest = true;
                    }
                    else
                    {
                        if (messageSecretValue == sharedSecretValue)
                            goodRequest = true;
                    }
                }

                if (!goodRequest)
                {
                    var request = actionContext.Request;
                    var actionName = actionContext.ActionDescriptor.ActionName;
                    var controllerName = actionContext.ActionDescriptor.ControllerDescriptor.ControllerName;
                    var moduleName = System.Reflection.Assembly.GetExecutingAssembly().GetName().Name;
                    
                    var errorMessage = string.Format(
                        "Error validating request to {0}:{1}:{2}",
                        moduleName, controllerName, actionName);

                    var errorResponse = request.CreateErrorResponse(HttpStatusCode.Forbidden, errorMessage);

                    actionContext.Response = errorResponse;
                }
            }

            base.OnActionExecuting(actionContext);
        }
    }
}

This is the source for the utility hashing functions:

using System;
using System.Security.Cryptography;
using System.Text;

namespace WebApiAuthentication
{
    public static class Hashing
    {
        /// <summary>
        /// Utility function to generate a MD5 of a string
        /// </summary>
        /// <param name="value">The item to have a MD5 generated for it</param>
        /// <returns>The MD5 digest</returns>
        public static string GetHashMD5OfString(string value)
        {
            using (var cryptoProvider = new MD5CryptoServiceProvider())
            {
                var hash = cryptoProvider.ComputeHash(Encoding.UTF8.GetBytes(value));
                return Convert.ToBase64String(hash);
            }
        }

        /// <summary>
        /// Utility to generate a HMAC of a string
        /// </summary>
        /// <param name="value">The item to have a HMAC generated for it</param>
        /// <param name="key">The 'shared' key to use for the HMAC</param>
        /// <returns>The HMAC for the value using the key</returns>
        public static string GetHashHMACSHA256OfString(string value, string key)
        {
            using (var cryptoProvider = new HMACSHA256(Encoding.UTF8.GetBytes(key)))
            {
                var hash = cryptoProvider.ComputeHash(Encoding.UTF8.GetBytes(value));
                return Convert.ToBase64String(hash);
            }
        }
    }
}

References

My research on the web to help me with this implementation made use of the following articles:

Compute any hash for any object in C#
http://alexmg.com/compute-any-hash-for-any-object-in-c/

Accessing ASP premarin 1.25.Net MVC Web APIs from Windows Application
http://developerpost.blogspot.co.uk/2014/04/accessing-aspnet-mvc-web-apis-from.html

Performing CRUD Operations using ASP.NET WEB API in Windows Store App using C# and XAML
http://www.dotnetcurry.com/showarticle.aspx?ID=917

Using HttpClient to Consume ASP.NET Web API REST Services
http://johnnycode.com/2012/02/23/consuming-your-own-asp-net-web-api-rest-service/

This entry was posted in Web API and tagged , , . Bookmark the permalink.

Leave a Reply

Your email address will not be published. Required fields are marked *