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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
services.AddAuthentication(o => o.AddScheme("api", a => a.HandlerType = typeof(TokenHandler))); |
And marked controller/action that I needed to protect:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
[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 have already updated sources code on GitHub.
5 comments:
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
same issue i'm facing
Guys were right, the code had the bug with post and put methods.
But it has been already fixed.
Excellent approach, but it seems the Replay or ReplayAttack check is flawed, it needs to be thread safe and even MemoryCache has currently problems with multithreaded GetOrAdd
You are right, it is exposed to replay attack. You have to improve key/headers with a date.
MemoryCache is used for prototyping only, you could replace it with a more reliable keys store.
Post a Comment