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
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 { ///
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; }
///
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);
} }
///
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 x) => { if (x.IsFaulted) throw x.Exception;
result = JsonConvert.DeserializeObject(x.Result); }); }
return result; }
///
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 x) => { if (x.IsFaulted) throw x.Exception;
result = JsonConvert.DeserializeObject<T[]>(x.Result); }); }
return result; }
///
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 x) => { if (x.IsFaulted) throw x.Exception;
result = JsonConvert.DeserializeObject(x.Result);
}); }
return result; }
///
var response = await client.PutAsync(apiUrl, putObject, new JsonMediaTypeFormatter).ConfigureAwait(false);
response.EnsureSuccessStatusCode; } }
///
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 { ///
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 { ///
///
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#
Accessing ASP.Net MVC Web APIs from Windows Application
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/
