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/