Adding a Secured Geo-located Audit Trail
How I built a social sharing component for my own web site and added a secured geo-located audit trail. Step by step, let’s analyse technologies and source code for developing this component.
This is the second of two articles about adding social sharing capability to a web site, and auditing its usage. In this article I’ll focus on implementing a secured geo-located audit trail. If the first article, I introduced the sharing component.
In my previous article I presented the share buttons implemented as an MVC component made of a ShareButtons action and view. I have introduced a dedicated MVC controller for the purpose, to keep action isolated. The Social controller contains the actions for displaying the view and auditing the use of the share buttons.
[ChildActionOnly]
public ActionResult ShareButtons(int id)
{
using (var db = new DatabaseContext())
{
return PartialView(new
{
Article = db.Articles.Find(id),
SecurityToken = Request.Url.Host.Encrypt(EncryptionKey, EncryptionIV)
}.ToDynamic());
}
}
The ShareButtons action in the Social controller.
This action generates a security token to pass to the view for validating subsequent Ajax calls for auditing purpose.
Let’s expand on securing the auditing functionality…
How can we secure our auditing capability? We want to make sure that no-one else than the Share buttons can consume the Audit action. My implementation is based on encrypting the host name of the URL of the article page (where the Share buttons are displayed), and passing this encrypted string as a security token to the Share buttons. When the Ajax call is done from the Share buttons to the Audit action, as we will see later in detail, validation is done of the request URL against the token, and if they match, the audit request is accepted. This should prevent anything else outside of the current web page to invoke the Audit action.
The work flow is depicted in the following diagram.
Workflow to secure the Audit action.
Let’s expand on the Encrypt and Decrypt sub-processes. Both are implemented as String extensions. Starting with a string in clear (the Request URL host name, in our case), the Encrypt method:
1. Uses the Triple DES symmetric-key algorithm for encryption, with Key and Vector defined in the Web config file.
<appSettings>
<add key="EncryptionKey" value="1234567890ABCDEFGHJK!.$@"/>
<add key="EncryptionIV" value="01234567"/>
</appSettings>
Encryption Key and IV (Vector) for the DES3 algorithm.
2. Obtains a byte array from the clear message using the system’s default encoding.
3. Creates a crypto stream in which the message is encrypted to.
4. Copies the encrypted message into an in-memory message.
5. Converts the byte array of the in-memory message into Base 64 to make it HTTP friendly (i.e. textual format, as opposite to the encrypted stream, which is binary).
The source code is:
public static string Encrypt(this string clearMessage, string key, string vector)
{
byte[] message = Encoding.Default.GetBytes(clearMessage);
string cipher = string.Empty;
SymmetricAlgorithm des3 = SymmetricAlgorithm.Create("3DES");
des3.Key = Encoding.Default.GetBytes(key);
des3.IV = Encoding.Default.GetBytes(vector);
using (MemoryStream mstream = new MemoryStream())
{
using (CryptoStream cstream = new CryptoStream(mstream, des3.CreateEncryptor(), CryptoStreamMode.Write))
{
cstream.Write(message, 0, message.Length);
}
byte[] cryptostream = mstream.ToArray();
cipher = Convert.ToBase64String(cryptostream);
}
return cipher;
}
The Encrypt extension.
Likewise, the Decrypt method:
1. Converts the encoded message from Base 64 to an encrypted byte array.
2. Using a crypto stream in decrypting mode, decrypts the message into an in-memory stream.
3. From the in-memory stream, obtains a byte array and convert it into a string using the system’s default encoding.
4. As encrypted messages have length that is a multiple of the IV vector, trailing empty characters may result in the decrypted message (characters with ASCII code 0); these are trimmed out from the resulting string.
The source code is:
public static string Decrypt(this string encryptedMessage, string key, string vector)
{
byte[] cipher = Convert.FromBase64String(encryptedMessage);
string message = null;
SymmetricAlgorithm des3 = SymmetricAlgorithm.Create("3DES");
des3.Key = Encoding.Default.GetBytes(key);
des3.IV = Encoding.Default.GetBytes(vector);
using (MemoryStream mstream = new MemoryStream(cipher))
{
using (CryptoStream cstream = new CryptoStream(mstream, des3.CreateDecryptor(), CryptoStreamMode.Read))
{
byte[] clearstream = new byte[cipher.Length];
cstream.Read(clearstream, 0, cipher.Length);
message = Encoding.Default.GetString(clearstream).TrimEnd('\0');
}
}
return message;
}
The Decrypt extension.
Implementing auditing requires a full stack client – server – database, so with the same logic of putting the user experience first in our design, let’s start with the jQuery event handler of the “on click” event on the share buttons, which, I recall, are identified by the “social” CSS class.
After obtaining the value of social and src from the respective data- attributes as local JavaScript variables, the component makes an Ajax call to the Audit action on the Social controller. This call is secured by adding the security token to the header, and it passes modelId (the Id of the article) and socialName (the name of the social network) as data.
$(".social").on("click", function () {
var social = $(this).data("social");
var id = $(this).parent().parent().data("id");
$.ajax({
url: "@Url.Action("Audit", "Social")",
type: "POST",
headers: { securityToken: "@Model.SecurityToken"},
data: { modelId: id, socialName: social }
});
});
jQuery event handler for auditing, when clicking on a share button.
On the server site, the Audit action accepts modelId and socialName in input and returns an HTTP status code. More precisely, this action is marked as async so it can run asynchronously when accessing the database to add the information to the audit trail.
A couple of checks before the actual code for storing the audit data:
1. The action expects a header parameter with key “securityToken”; if it doesn’t exists, it returns the HTTP error code 400 “Bad Request”.
2. If it exists, the security token is decrypted and the clear phrase compared with the host name of the request, to make sure that the request is genuine; if there is no match, the HTTP error code 401 “Unauthorized” is returned.
Passing these validation controls, a new instance of the AuditTrailEntry model is created with several pieces of information collected from the request. This data is then added to the audit trail via Entity Framework, and saved asynchronously with the SaveChangeAsync method.
If all goes well at the end, the HTTP status code 200 “OK” is returned.
[HttpPost]
public async Task<HttpStatusCodeResult> Audit(int modelId, string socialName)
{
string securityToken = Request.Headers["securityToken"];
if (string.IsNullOrEmpty(securityToken))
{
return new HttpStatusCodeResult(HttpStatusCode.BadRequest);
}
string phrase = securityToken.Decrypt(EncryptionKey, EncryptionIV);
if (phrase != Request.Url.Host)
{
return new HttpStatusCodeResult(HttpStatusCode.Unauthorized);
}
var entry = new AuditTrailEntry
{
TimeStamp = DateTime.Now,
Browser = Request.Browser.Browser,
BrowserVersion = Request.Browser.Version,
BrowserMajorVersion = Request.Browser.MajorVersion,
BrowserIsMobileDevice = Request.Browser.IsMobileDevice,
BrowserPlatform = Request.Browser.Platform,
UrlReferrer = Request.UrlReferrer?.ToString(),
UserAgent = Request.UserAgent,
UserHostAddress = Request.UserHostAddress,
SocialName = socialName,
ModelId = modelId
};
using (var db = new DatabaseContext())
{
db.AuditTrail.Add(entry);
await db.SaveChangesAsync();
}
return new HttpStatusCodeResult(HttpStatusCode.OK);
}
The Audit action in the Social controller.
On the backend, the AuditTrailEntry model is a POCO class that exposes public properties for each piece of information to capture. I marked it with the Table attribute, which is a data annotation to force the name of the table at database label. If I didn’t specify the “AuditTrail” name for the table, EF would pluralise the model name into AuditTrailEntries. Which is fine, but I wanted my table to be called AuditTrail J
[Table("AuditTrail")]
public class AuditTrailEntry
Setting a table name with the Table attribute.
The schema of the AuditTrail class is the following. Feel free to improve it at your wish!
The AuditTrail table schema.
We are nearly at the end! We are now able to share our articles on different social network sites, and this action is audited. Important information is collected in our audit trail. A useful attribute is the UserHostAddress. This is the IP address of the client request. IP addresses can be geo-located, that is it is possible to know from which country they originated. This can be useful for statistical purposes, for example.
There are different services that offer geo-resolution of an IP address. I am using “IP Info.io”, available at http://ipinfo.io/. This service is free for small projects, up to 1000 daily requests, and has paid plans for larger numbers of daily requests. It also offers a very convenient JSON API.
So all I need is a way to consume a JSON API from my Audit action. The best way to do that in .NET is to use the Microsoft.AspNet.WebApi.Client library, which adds support for formatting and content negotiation to the System.Net.Http namespace. Using NuGet for adding this reference to the project, this will also install the Newtonsoft.Json package, which contains Json.NET, a popular high-performance JSON framework for .NET.
My implementation of the geolocation capability is based on an IGeolocator interface that exposes a Geolocate method, and an actual implementation of the IP Info.io client in the IpInfoGeolocator class. In this way, by using the interface as a contract, I would not need to change the code that implements the update of the country field in the audit trail entry, if I had to replace IP Info.io with another provider.
Geolocator class diagram.
The IGeolocator interface is straightforward. It exposes a Geolocate method that accepts an IP address in input and returns a GeolocationInfo model. Actually, a Task<GeolocationInfo>. This is so we can invoke Geolocate asynchronously.
public interface IGeolocator
{
Task<GeolocationInfo> Geolocate(string ipAddress);
}
The IGeolocator interface.
GeolocationInfo is a class that defines the geographical information that is possible to obtain as part of the geolocation resolution of an IP address. Accurate services may be able to track an IP address down to a specific location within a city. For our purposes, we limit our search at country level.
public class GeolocationInfo
{
public string Ip { get; set; }
public string Loc { get; set; }
public string City { get; set; }
public string Region { get; set; }
public string Country { get; set; }
}
The GeolocationInfo model.
The implementation of the IGeolocator interface is in the IpInfoGeolocator, which, as said, implements the geolocation services of the IP Info.io provider.
public class IpInfoGeolocator : IGeolocator
{
public IpInfoGeolocator(string geolocationApiUrl)
{
GeolocationApiUrl = geolocationApiUrl;
}
public string GeolocationApiUrl { get; }
public async Task<GeolocationInfo> Geolocate(string ipAddress)
{
using (var httpClient = new HttpClient { BaseAddress = new Uri(GeolocationApiUrl) })
{
httpClient.DefaultRequestHeaders.Accept.Clear();
httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
HttpResponseMessage response = await httpClient.GetAsync($"/{ipAddress}/geo");
if (!response.IsSuccessStatusCode)
{
return null;
}
return await response.Content.ReadAsAsync<GeolocationInfo>();
}
}
}
The IpInfoGeolocator class.
By using an HttpClient object, the Geolocate method invokes the “geo” endpoint by submitting the IP address, and obtains a GeolocationInfo result as a response. Everything is happening asynchronously to prevent hanging this call whilst the web service processes the request.
I also avoided hard-coding the base URL of the geolocation API, so that address is stored in the Web.config and passed in input to IpInfoGeolocator from the Audit action.
<appSettings>
<add key="GeolocationApiUrl" value="http://ipinfo.io"/>
</appSettings>
All the Audit action has to do is to trigger the geolocation resolution after saving the audit entry in the database. This is implemented as an extension on the AuditTrailEntry model, which, if you remember, is the class that defines all the attributes to audit.
await entry.GeolocateIpAddress(new IpInfoGeolocator(GeolocationApiUrl));
The extension is defined as follows:
public async static Task GeolocateIpAddress(this AuditTrailEntry entry, IGeolocator geolocator)
{
using (var db = new DatabaseContext())
{
var info = await geolocator.Geolocate(entry.UserHostAddress);
if (info != null)
{
entry.UserCountry = info.Country;
db.Entry(entry).State = EntityState.Modified;
}
await db.SaveChangesAsync();
}
}
Extension to geolocate the IP Address of an audit entry.
The GeolocateIpAddress accepts an IGeolocator interface in input to inject the dependency on which geolocation provider to use. Then it obtains the country of the IP address of the client request, and lastly it updates the audit entry. Again, all happens asynchronously to avoid hanging this process and instead returning control to the web page immediately.
Full source code of the solution is on CodePlex. Feel free to use it, it’s completely open source! Enjoy! J
Project Name: SocialSharing