HMAC authentication in ASP.NET Web API

HMAC authentication in ASP.NET Web API

28 FEBRUARY 2013 on  delegating handlersASP.NET Web APIHTTPHMAC authenticationhttp authenticationmd5SecurityHMAC

In this article I will explain the concepts behind HMAC authentication and will show how to write an example implementation for ASP.NET Web API using message handlers. The project will include both server and client side (using Web API's HttpClient) bits.

HMAC based authentication

HMAC (hash-based message authentication code) provides a relatively simple way to authenticate HTTP messages using a secret that is known to both client and server. Unlike basic authentication it does not require transport level encryption (HTTPS), which makes its an appealing choice in certain scenarios. Moreover, it guarantees message integrity (prevents malicious third parties from modifying contents of the message).

On the other hand proper HMAC authentication implementation requires slightly more work than basic HTTP authentication and not all client platforms support it out of the box (most of them support cryptographic algorithms required to implement it though). My suggestion would be to use it only if HTTPS + basic authentication does not suit your requirements.

One prominent example of HMAC usage is Amazon S3 service.

The basic idea behind HMAC authentication in HTTP can be described as follows:

  • both client and server have access to a secret that will be used to generate HMAC - it can be a password (or preferably password hash) created by the user at the time of registration,
  • using the secret client generates a message signature using HMAC algorithm (the algorithm is provided by .NET 'for free'),
  • signature is attached to the message (eg. as a header) and the message is sent,
  • the server receives the message and calculates its own version of the signature using the secret (both client and server use the same HMAC algorithm),
  • if the signature computed by the server matches the on the message it means that the message is authorized.

As you can see the secret key (eg. password hash) is only shared between client and server once (eg. during user registration). Noone will be able to produce a valid signature without the access to the secret also any modification of the message (eg. appending content) will result in server calculating a different signature and refusing authorization.

Broadly speaking to create a HMAC authenticated client/server pair using ASP.NET Web API we need:

  • method that will return a string representing given http request,
  • method that based on secret string and message representation calculates HMAC signature,
  • client side - message handler that uses these methods to calculate the signature and attaches it to the request (as HTTP header),
  • server side - message handler that calculates signature of incoming request and compares it with the one contained in the header.

Web API client

Ok, so let's start by writing the first piece.

public interface IBuildMessageRepresentation  
{
    string BuildRequestRepresentation(HttpRequestMessage requestMessage);
}
public class CanonicalRepresentationBuilder : IBuildMessageRepresentation  
{
    /// <summary>
    /// Builds message representation as follows:
    /// HTTP METHOD\n +
    /// Content-MD5\n +  
    /// Timestamp\n +
    /// Username\n +
    /// Request URI
    /// </summary>
    /// <returns></returns>
    public string BuildRequestRepresentation(HttpRequestMessage requestMessage)
    {
        bool valid = IsRequestValid(requestMessage);
        if (!valid)
        {
            return null;
        }

        if (!requestMessage.Headers.Date.HasValue)
        {
            return null;
        }
        DateTime date = requestMessage.Headers.Date.Value.UtcDateTime;

        string md5 = requestMessage.Content == null ||
            requestMessage.Content.Headers.ContentMD5 == null ?  "" 
            : Convert.ToBase64String(requestMessage.Content.Headers.ContentMD5);

        string httpMethod = requestMessage.Method.Method;
        //string contentType = requestMessage.Content.Headers.ContentType.MediaType;
        if (!requestMessage.Headers.Contains(Configuration.UsernameHeader))
        {
            return null;
        }
        string username = requestMessage.Headers
            .GetValues(Configuration.UsernameHeader).First();
        string uri = requestMessage.RequestUri.AbsolutePath.ToLower();
        // you may need to add more headers if thats required for security reasons
        string representation = String.Join("\n", httpMethod,
            md5, date.ToString(CultureInfo.InvariantCulture),
            username, uri);

        return representation;
    }

    private bool IsRequestValid(HttpRequestMessage requestMessage)
    {
        //for simplicity I am omitting headers check (all required headers should be present)

        return true;
    }
}

A couple of points worth mentioning:

  • we construct message representation by concatenating 'important' headers, http method and uri,
  • instead of using incorporating the content we use its md5 hash (base64 encoded),
  • all parts of the message (eg. headers) that can affect its meaning and have side effects on the server side should be included in the representation (otherwise an attacker would be able to modify them without changing the signature).

Now lets look at that component that will calculate authentication code (signature).

public interface ICalculteSignature  
{
    string Signature(string secret, string value);
}
public class HmacSignatureCalculator : ICalculteSignature  
{
    public string Signature(string secret, string value)
    {
        var secretBytes = Encoding.UTF8.GetBytes(secret);
        var valueBytes = Encoding.UTF8.GetBytes(value);
        string signature;

        using (var hmac = new HMACSHA256(secretBytes))
        {
            var hash = hmac.ComputeHash(valueBytes);
            signature = Convert.ToBase64String(hash);
        }
        return signature;
    }
}

The signature will be encoded using base64 so that we can pass it easily in a header. What header you may ask? Well, unfortunately there is no standard way of  including message authentication codes into the message (as there is no standard way of constructing message representation). We will use Authorization HTTP header for that purpose providing a custom schema (ApiAuth).

Authorization: ApiAuth HMAC_SIGNATURE

The HMAC will be calculated and attached to the request in a custom message handler.

public class HmacSigningHandler : HttpClientHandler  
{
    private readonly ISecretRepository _secretRepository;
    private readonly IBuildMessageRepresentation _representationBuilder;
    private readonly ICalculteSignature _signatureCalculator;

    public string Username { get; set; }

    public HmacSigningHandler(ISecretRepository secretRepository,
                          IBuildMessageRepresentation representationBuilder,
                          ICalculteSignature signatureCalculator)
    {
        _secretRepository = secretRepository;
        _representationBuilder = representationBuilder;
        _signatureCalculator = signatureCalculator;
    }

    protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
                                 System.Threading.CancellationToken cancellationToken)
    {
        if (!request.Headers.Contains(Configuration.UsernameHeader))
        {
            request.Headers.Add(Configuration.UsernameHeader, Username);
        }
        request.Headers.Date = new DateTimeOffset(DateTime.Now,DateTime.Now-DateTime.UtcNow);
        var representation = _representationBuilder.BuildRequestRepresentation(request);
        var secret = _secretRepository.GetSecretForUser(Username);
        string signature = _signatureCalculator.Signature(secret,
            representation);

        var header = new AuthenticationHeaderValue(Configuration.AuthenticationScheme, signature);

        request.Headers.Authorization = header;
        return base.SendAsync(request, cancellationToken);
    }
}
public class Configuration  
{
    public const string UsernameHeader = "X-ApiAuth-Username";
    public const string AuthenticationScheme = "ApiAuth";
}
public class DummySecretRepository : ISecretRepository  
{
    private readonly IDictionary<string, string> _userPasswords
        = new Dictionary<string, string>()
              {
                  {"username","password"}
              };

    public string GetSecretForUser(string username)
    {
        if (!_userPasswords.ContainsKey(username))
        {
            return null;
        }

        var userPassword = _userPasswords[username];
        var hashed = ComputeHash(userPassword, new SHA1CryptoServiceProvider());
        return hashed;
    }

    private string ComputeHash(string inputData, HashAlgorithm algorithm)
    {
        byte[] inputBytes = Encoding.UTF8.GetBytes(inputData);
        byte[] hashed = algorithm.ComputeHash(inputBytes);
        return Convert.ToBase64String(hashed);
    }
}

public interface ISecretRepository  
{
    string GetSecretForUser(string username);
}

In a real life scenario you could retrieve the hashed password from the a persistent store (a database). If you remember how we constructed our message representation you will notice that we also need to set content MD5 header. We could do it in HmacSigningHandler, but to have separation of concerns and because Web API allows us to combine handlers in a neat way I moved it to a separate (dedicated) handler.

public class RequestContentMd5Handler : DelegatingHandler  
{
    protected async override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
                                       System.Threading.CancellationToken cancellationToken)
    {
        if (request.Content == null)
        {
            return await base.SendAsync(request, cancellationToken);
        }

        byte[] content = await request.Content.ReadAsByteArrayAsync();
        MD5 md5 = MD5.Create();
        byte[] hash = md5.ComputeHash(content);
        request.Content.Headers.ContentMD5 = hash;
        var response = await base.SendAsync(request, cancellationToken);
        return response;
    }
}

For simplicity the HMAC handler derives directly from HttpClientHandler. Here is how we would make a request:

static void Main(string[] args)  
{
    var signingHandler = new HmacSigningHandler(new DummySecretRepository(),
                                            new CanonicalRepresentationBuilder(),
                                            new HmacSignatureCalculator());
    signingHandler.Username = "username";

    var client = new HttpClient(new RequestContentMd5Handler()
    {
        InnerHandler = signingHandler
    });
    client.PostAsJsonAsync("http://localhost:48564/api/values","some content").Wait();
}

And that's basically it as far as http client is concerned. Let's have a look at server part.

Web API service

The general logic will be that we will want to authenticate every incoming request (we can us per route handlers to secure only one route for example). Each request's authentication code will be calculated using the very same IBuildMessageRepresentation and ICalculateSignature implementations. If the signature does not match (or the content md5 hash is different from the value in the header) we will immediately return a 401 response.

public class HmacAuthenticationHandler : DelegatingHandler  
{
    private const string UnauthorizedMessage = "Unauthorized request";

    private readonly ISecretRepository _secretRepository;
    private readonly IBuildMessageRepresentation _representationBuilder;
    private readonly ICalculteSignature _signatureCalculator;

    public HmacAuthenticationHandler(ISecretRepository secretRepository,
        IBuildMessageRepresentation representationBuilder,
        ICalculteSignature signatureCalculator)
    {
        _secretRepository = secretRepository;
        _representationBuilder = representationBuilder;
        _signatureCalculator = signatureCalculator;
    }

    protected async Task<bool> IsAuthenticated(HttpRequestMessage requestMessage)
    {
        if (!requestMessage.Headers.Contains(Configuration.UsernameHeader))
        {
            return false;
        }

        if (requestMessage.Headers.Authorization == null 
            || requestMessage.Headers.Authorization.Scheme 
                    != Configuration.AuthenticationScheme)
        {
            return false;
        }

        string username = requestMessage.Headers.GetValues(Configuration.UsernameHeader)
                                .First();
        var secret = _secretRepository.GetSecretForUser(username);
        if (secret == null)
        {
            return false;
        }

        var representation = _representationBuilder.BuildRequestRepresentation(requestMessage);
        if (representation == null)
        {
            return false;
        }

        if (requestMessage.Content.Headers.ContentMD5 != null 
            && !await IsMd5Valid(requestMessage))
        {
            return false;
        }

        var signature = _signatureCalculator.Signature(secret, representation);        

        var result = requestMessage.Headers.Authorization.Parameter == signature;

        return result;
    }

    protected async override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
           System.Threading.CancellationToken cancellationToken)
    {
        var isAuthenticated = await IsAuthenticated(request);

        if (!isAuthenticated)
        {
            var response = request
                .CreateErrorResponse(HttpStatusCode.Unauthorized, UnauthorizedMessage);
            response.Headers.WwwAuthenticate.Add(new AuthenticationHeaderValue(
                Configuration.AuthenticationScheme));
            return response;
        }
        return await base.SendAsync(request, cancellationToken);
    }
}

The bulk of work is done by IsAuthenticated() method. Also please note that we do not sign the response, meaning the client will not be able verify the authenticity of the response (although response signing would be easy to do given components that we already have). I have omitted IsMd5Valid()method for brevity, it basically compares content hash with MD5 header value (just remember not to compare byte[] arrays using == operator).

Configuration part is simple and can look like that (per route handler):

config.Routes.MapHttpRoute(  
                name: "DefaultApi",
                routeTemplate: "api/{controller}/{id}",
                constraints: null,
                handler: new HmacAuthenticationHandler(new DummySecretRepository(),
                    new CanonicalRepresentationBuilder(), new HmacSignatureCalculator())
                    {
                        InnerHandler = new HttpControllerDispatcher(config)
                    },
                defaults: new { id = RouteParameter.Optional }
            );

Replay attack prevention

There is one very important flaw in the current approach. Imagine a malicious third party intercepts a valid (properly authenticated) HTTP request coming from a legitimate client (eg. using a sniffer). Such a message can be stored and resent to our server at any time enabling attacker to repeat operations performed previously by authenticated users. Please note that new messages still cannot be created as the attacker does not know the secret nor has a way of retrieving it from intercepted data.

To help us fix this issue lets make following three observations/assumptions about dates of  requests in our system:

  • requests with different Date header values will have different signatures, thus attacker will not be able to modify the timestamp,
  • we assume identical, consecutive messages coming from a user will always have different timestamps - in other words that no client will want to send two or more identical messages at a given point in time,
  • we introduce a requirement that no http request can be older than X (eg. 5) minutes - if for any reason the message is delayed for more than that it will have to be resent with a refreshed timestamp.

Once we know the above we can introduce following changes into IsAuthenticated() method:

protected async Task<bool> IsAuthenticated(HttpRequestMessage requestMessage)  
{
    //(...)
    var isDateValid = IsDateValid(requestMessage);
    if (!isDateValid)
    {
        return false;
    }
    //(...)

    //disallow duplicate messages being sent within validity window (5 mins)
    if(MemoryCache.Default.Contains(signature))
    {
        return false;
    }

    var result = requestMessage.Headers.Authorization.Parameter == signature;
    if (result == true)
    {
        MemoryCache.Default.Add(signature, username,
                DateTimeOffset.UtcNow.AddMinutes(Configuration.ValidityPeriodInMinutes));
    }
    return result;
}

private bool IsDateValid(HttpRequestMessage requestMessage)  
{
    var utcNow = DateTime.UtcNow;
    var date = requestMessage.Headers.Date.Value.UtcDateTime;
    if (date >= utcNow.AddMinutes(Configuration.ValidityPeriodInMinutes)
        || date <= utcNow.AddMinutes(-Configuration.ValidityPeriodInMinutes))
    {
        return false;
    }
    return true;
}

For simplicity I didn't test the example for sever and client residing in different timezones (although as long as we normalize the dates to UTC we should be save here).

The code is available as usually on bitbucket.

Hope this article helps some of you!


https://www.piotrwalat.net/hmac-authentication-in-asp-net-web-api/

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值