Wednesday, December 6, 2017

Secure ASP.NET Core Web API using API Key Authentication

I am implementing the simple web service that grants access via usual login and api login with some token. I have googled a bit for good solution but found it for asp.net mvc 5 application only (original article - Secure ASP.NET Web API using API Key Authentication – HMAC Authentication).

So it is something that helps me to do the same stuff for asp.net core that I am using for my web services.

First of all, I went through great workshop related to the various new pieces in ASP.NET Core Authorization - AspNetAuthorizationWorkshop. Also, check out the great article about Filters.

But both these materials are focused on Authorization, it is nice to know but for our purpose, we have to use Authentication.

I implemented custom AuthenticationHandler and override there HandleAuthenticateAsync. I returned AuthenticateResult.Success when request met requirment and AuthenticateResult.Fail in otherwise.
internal class TokenHandler : AuthenticationHandler
    {
        private const ulong _REQUEST_MAX_AGE_IN_SECONDS = 300; //5 mins
        private const string _AUTHENTICATION_SCHEME = "amx";

        private static readonly DateTime _1970 = new DateTime(1970, 01, 01, 0, 0, 0, 0, DateTimeKind.Utc);
        private static readonly Dictionary _AllowedApps = new Dictionary();
        private readonly IMemoryCache _cache;

        public TokenHandler(IMemoryCache memoryCache, IOptionsMonitor options,
            ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock) : base(options, logger, encoder, clock)
        {
            _cache = memoryCache;

            if (_AllowedApps.Count == 0)
            {
                _AllowedApps.Add("4d53bce03ec34c0a911182d4c228ee6c", "A93reRTUJHsCuQSHR+L3GxqOJyDmQpCgps102ciuabc=");
            }
        }

        protected override async Task HandleAuthenticateAsync()
        {
            var headers = (FrameRequestHeaders)Context.Request.Headers;
            var authorizations = headers.HeaderAuthorization;

            if (authorizations.Any())
            {
                var authorization = authorizations.First();

                if (authorization.StartsWith(_AUTHENTICATION_SCHEME))
                {
                    var autherizationHeaderArray = GetAutherizationHeaderValues(authorization);

                    if (autherizationHeaderArray != null)
                    {
                        var appId = autherizationHeaderArray[0];
                        var incomingBase64Signature = autherizationHeaderArray[1];
                        var nonce = autherizationHeaderArray[2];
                        var requestTimeStamp = autherizationHeaderArray[3];

                        var request = Context.Request;
                        var isValid = IsValidRequest(request, appId, incomingBase64Signature, nonce, requestTimeStamp);

                        if (isValid)
                        {
                            var identity = new ClaimsIdentity("api");
                            var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), null, "api");
                            return AuthenticateResult.Success(ticket);
                        }

                        return AuthenticateResult.Fail("Incorrect'Authorization' header.");
                    }
                }
            }

            return AuthenticateResult.Fail("Missing or malformed 'Authorization' header.");
        }

        private bool IsValidRequest(HttpRequest request, string appId, string incomingBase64Signature, string nonce,
            string requestTimeStamp)
        {
            string requestContentBase64String = "";
            string requestUri =
                HttpUtility.UrlEncode(request.Scheme + "://" + request.Host +
                                      request.Path); //http://localhost:43326/api/values
            string requestHttpMethod = request.Method;

            if (!_AllowedApps.ContainsKey(appId)) return false;

            var sharedKey = _AllowedApps[appId];

            if (IsReplayRequest(nonce, requestTimeStamp)) return false;



            request.EnableRewind();
            byte[] hash = ComputeHash(request.Body);

            request.Body.Position = 0;            

             if (hash != null)
            {
                requestContentBase64String = Convert.ToBase64String(hash);
            }

            var data = $"{appId}{requestHttpMethod}{requestUri}{requestTimeStamp}{nonce}{requestContentBase64String}";

            var secretKeyBytes = Convert.FromBase64String(sharedKey);

            byte[] signature = Encoding.UTF8.GetBytes(data);

            using (var hmac = new HMACSHA256(secretKeyBytes))
            {
                byte[] signatureBytes = hmac.ComputeHash(signature);
                return incomingBase64Signature.Equals(Convert.ToBase64String(signatureBytes), StringComparison.Ordinal);
            }
        }

        private bool IsReplayRequest(string nonce, string requestTimeStamp)
        {
            if (_cache.TryGetValue(nonce, out object _)) return true;

            TimeSpan currentTs = DateTime.UtcNow - _1970;
            var serverTotalSeconds = Convert.ToUInt64(currentTs.TotalSeconds);
            var requestTotalSeconds = Convert.ToUInt64(requestTimeStamp);

            if (serverTotalSeconds - requestTotalSeconds > _REQUEST_MAX_AGE_IN_SECONDS) return true;

            _cache.Set(nonce, requestTimeStamp, DateTimeOffset.UtcNow.AddSeconds(_REQUEST_MAX_AGE_IN_SECONDS));

            return false;
        }

        private static string[] GetAutherizationHeaderValues(string rawAuthzHeader)
        {
            var credArray = rawAuthzHeader.Split(' ')[1].Split(':');
            return credArray.Length == 4 ? credArray : null;
        }

        private static byte[] ComputeHash(Stream body)
        {
            using (var md5 = MD5.Create())
            {
                var content = GetBytes(body);

                byte[] hash = content.Length != 0
                    ? md5.ComputeHash(content)
                    : null;
                return hash;
            }
        }

        private static byte[] GetBytes(Stream input)
        {
            byte[] buffer = new byte[16 * 1024];
            using (var ms = new MemoryStream())
            {
                int read;
                while ((read = input.Read(buffer, 0, buffer.Length)) > 0)
                {
                    ms.Write(buffer, 0, read);
                }
                return ms.ToArray();
            }
        }
    }


When handler was implemented I needed to configure service to use it. I wrote next code in Startup.ConfigureServices method:

services.AddAuthentication(o => o.AddScheme("api", a => a.HandlerType = typeof(TokenHandler)));

And marked controller/action that I needed to protect:

[Authorize(AuthenticationSchemes = "api")]

Vu'a la, it works now.

PS: I used AddMvcCore() rather AddMvc() for better performance. Also, I added AddJsonFormatters for exploring JSON in respoce and AddAuthorization to use security.

You could find source there on GitHub.

EDIT:
My implementation had the bug; it related to the way how to request.body works. If the request.body was read already before during request pipeline, then it is empty when you try to read it the second time. AuthenticationHandler reads request.body for computing hash, so model binder can't process correct binding from request.body to post or put parameters. But this bug is easy to fix I have to enable rewind for body stream (Allow rewind request.EnableRewind(); and rewind request.Body.Position = 0;). I marked this code bold in the code above.

I have already updated sources code on GitHub.

3 comments:

Unknown said...

Hi,

Thank you for this article.

I have used your code it is working fine , however it is throwing an exception for Post and Put methods while model binding and it is sending a "400 (Bad Request)". If i remove the filter it is working fine. Please let me know if there is anything missing.

Regards,
Chakri

John said...

same issue i'm facing

RredCat said...

Guys were right, the code had the bug with post and put methods.
But it has been already fixed.