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.
The full source including sample projects to test the code can be found here http://www.frez.co.uk/httpclientexample.zip
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);
// Force a wait to make a brute force attack harder
Thread.Sleep(2000);
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.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/