There’s very little guidance from Microsoft on writing your own custom authentication handlers for Core 2. If you look at the documentation you’ll find detailed guidance on the built-in Core Identity model that they want you to use. They also provide links to third-party open-source providers like Identity Server which is what I use in this example. There is an article on custom cookie authentication. But generally speaking because security is hard and it’s way too easy to screw up Microsoft would rather you did not roll your own. It’s best to stick to the prescriptive guidance Microsoft offers. Now that I’ve said that I’m going to ignore completely my own advice. Read on if you’re with me.
Basic authentication middleware is no longer available in Core 2 because the ASP.NET Team made the call that the risks outweigh the benefits. (And we all know about those risks: the password goes over the wire in base64 encoding rather than ciphertext, it sits there in the request header for the whole session, the user can cache it permanently in the browser, and anyone on the network can sniff it out before it gets to the web server.)
But sometimes in the real world our choices are limited. Sometimes a client is a legacy system that only supports basic authentication. Or you might have a client that insists on using HMAC. Or you prefer it yourself as I do. What if I need to support multiple types of authentication for multiple clients? That’s the subject of this post. I’m going to support three authentication schemes all within the same Core 2 Web API: (1) JWT; (2) Basic; and (3) HMAC.
I started with the same code base from an earlier microservices example and modified it to support the three authentication schemes. Go ahead and bring it down to your machine for reference:
git clone https://github.com/jamesstill/ApiMultAuthSchemes.git
The original JWT example had just one token authentication scheme. I’m going to add the other two so that in our WidgetApi Startup.cs class the configuration bootstrapper will look like this:
services .AddAuthentication("Bearer") .AddIdentityServerAuthentication(options => { options.Authority = authenticationUrl; options.RequireHttpsMetadata = true; options.ApiName = scope; }) .AddBasicAuthentication<BasicAuthenticationService>(o => { o.DiscoveryUrl = authenticationUrl; o.Scope = scope; }) .AddHMACAuthentication<HMACAuthenticationService>(o => { o.Key = key; });
Basic Authentication
The basic authentication code I’ll add was lifted and modified from code written by Joonas Westlin. But why write your own handler? Grab the Blazinga from NuGet and just configure it for your purposes. There’s one for HMAC too but I have not tested it and can’t say anything about it.
I assume you already know about extending the AuthenticationHandler<T> base class to create authentication middleware. There is a BasicAuthenticationOptions class to hold our discovery URL for the Identity Server and the scope of our API:
public class BasicAuthenticationOptions : AuthenticationSchemeOptions { public string DiscoveryUrl { get; set; } public string Scope { get; set; } }
The handler itself will expect it as an option along with an injected instance of an IBasicAuthenticationService:
public class BasicAuthenticationHandler : AuthenticationHandler<BasicAuthenticationOptions> { private readonly IBasicAuthenticationService _authenticationService; public BasicAuthenticationHandler( IOptionsMonitor<BasicAuthenticationOptions> options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock, IBasicAuthenticationService authenticationService) : base(options, logger, encoder, clock) { _authenticationService = authenticationService; } // left out for brevity }
As was shown above the service and options are configured in Startup.cs with two app settings that point to Identity Server and use the scope “widgetapi” as in the previous example:
.AddBasicAuthentication<BasicAuthenticationService>(o => { o.DiscoveryUrl = authenticationUrl; o.Scope = scope; })
After the handler has decoded the header values it calls the BasicAuthenticationService which is responsible for doing the work of verifying that the credentials are valid:
// get the credentials from the authorization header byte[] headerValueBytes = Convert.FromBase64String(headerValue.Parameter); string userAndPassword = Encoding.UTF8.GetString(headerValueBytes); string[] parts = userAndPassword.Split(':'); if (parts.Length != 2) { return AuthenticateResult.Fail("Invalid Basic authentication header"); } string username = parts[0]; string password = parts[1]; bool isValidUser = await _authenticationService.IsValidUserAsync(Options, username, password);
Just like with the previous microservices example project it does this by constructing a TokenClient with the username and password stored in the authentication header and calling Identity Server:
public class BasicAuthenticationService : IBasicAuthenticationService { public Task<bool> IsValidUserAsync(BasicAuthenticationOptions options, string username, string password) { var discoveryClient = new DiscoveryClient(options.DiscoveryUrl); var discoveryResponse = discoveryClient.GetAsync().Result; if (discoveryResponse.IsError) { return Task.FromResult(false); } var tokenClient = new TokenClient(discoveryResponse.TokenEndpoint, username, password); var tokenResponse = tokenClient.RequestClientCredentialsAsync(options.Scope).Result; if (tokenResponse.IsError) { return Task.FromResult(false); } return Task.FromResult(true); } }
If the service returns true then the handler will create a principal for the calling client and the pipeline will pass the request on into the controller operation. Otherwise an HTTP 401 is returned.
Before I get to HMAC authentication I want to show the WidgetController. At the point of authorization I’ve configured the API to use three authentication schemes:
[Authorize(AuthenticationSchemes = AllSchemes)] [Route("api/v1/[controller]")] [ApiController] public class WidgetController : ControllerBase { public const string AllSchemes = JwtBearerDefaults.AuthenticationScheme + "," + BasicAuthenticationDefaults.AuthenticationScheme + "," + HMACAuthenticationDefaults.AuthenticationScheme; [HttpGet] public async Task<IActionResult> Get() { await Task.Delay(100); // simulate latency var items = new List<Widget>() { new Widget { ID = 1, Name = "Cog", Shape = "Square" }, new Widget { ID = 2, Name = "Gear", Shape = "Round" }, new Widget { ID = 3, Name = "Sprocket", Shape = "Octagonal" }, new Widget { ID = 4, Name = "Pinion", Shape = "Triangular" } }; return Ok(items); } }
The constant string AllSchemes is a comma-delimited list of all three schemes. When a request comes into the pipeline the app will run all three schemes and each may create and append an identity principal to the request. Otherwise the request will generate an HTTP 401 and no widgets will be returned. In this way you can provide different authentication schemes for different kinds of callers.
HMAC Authentication
Let me explain the algorithm I used to implement HMAC authentication. Let’s say Bob wants to authenticate a call from Alice. At design time the two have exchanged a private key known only to them. Alice will:
- Create a timestamp for the current date and time UTC using the standard format specifier “o” (ISO 8601) corresponding to a custom format string yyyy-MM-ddTHH:mm:ss.fffffffZ.
- A hash algorithm is initialized using HMACSHA256 with the private key.
- HMACSHA256 computes a hash value of the timestamp.
- Both the cleartext timestamp and the computed hash value is sent in the request header.
Upon receiving the request Bob will pull out the timestamp and hashed value from the request header and repeat the process himself:
- Bob initializes HMACSHA256 with the private key.
- Bob uses the timestamp provided by Alice to compute a hash value.
- The computed hash value is compared to the hash value that Alice provided.
- If the two values match then Alice is authenticated.
Obviously HMAC has one big thing going for it over Basic, namely, the shared private key is never sent over the wire. Instead Alice and Bob agree that the key will be used to hash an agreed upon value (a timestamp) and the hash of that value is sent over the wire instead. Bob can repeat the process because he knows the key used by Alice. (They say that cryptography is “justified paranoia” so if you want to go down a deep rabbit hole you can think about the key exchange problem.)
Let’s look at the implementation. The HMACAuthenticationHandler expects the caller to provide the hash in a “Hash” header name and the timestamp in a “Timestamp” header name:
public class HMACAuthenticationHandler : AuthenticationHandler<HMACAuthenticationOptions> { private const string AuthenticationHeaderName = "Authentication"; private const string TimestampHeaderName = "Timestamp"; private readonly IHMACAuthenticationService _authenticationService;
If one or both of these are missing then authentication fails. In my implementation the authentication header is in the form {username:hash} so that I could look up the private key for the username from a database. In this example, for the sake of convenience I pass in the private key as a configuration option at Startup.cs. The authentication service does the job of validating the hash sent by the caller. Unlike the JWT and Basic handlers this does not call Identity Server but determines authentication within the service:
public interface IHMACAuthenticationService { Task<bool> IsValidUserAsync(HMACAuthenticationOptions options, string timestampValue, string username, string hash); } public class HMACAuthenticationService : IHMACAuthenticationService { private readonly double _replayAttackDelayInSeconds = 15; public Task<bool> IsValidUserAsync(HMACAuthenticationOptions options, string timestampValue, string username, string hash) { if (!IsValidTimestamp(timestampValue, out DateTime timestamp)) { return Task.FromResult(false); } if (!PassesThresholdCheck(timestamp)) { return Task.FromResult(false); } if (!ComputeHash(options.Key, timestamp, hash)) { return Task.FromResult(false); } return Task.FromResult(true); } private static bool IsValidTimestamp(string timestampValue, out DateTime timestamp) { // Parse a string representing UTC. E.g.: "2013-01-12T16:11:20.0904778Z"; // Client should create the timestamp like this: var timestampValue = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture); var ts = DateTime.TryParseExact(timestampValue, "o", CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out timestamp); return ts; } private bool PassesThresholdCheck(DateTime timestamp) { // make sure call is made within the allowed threshold var ts = DateTime.UtcNow.Subtract(timestamp); return ts.TotalSeconds <= _replayAttackDelayInSeconds; } private static bool ComputeHash(string privateKey, DateTime timestamp, string authenticationHash) { string hashString; var ticks = timestamp.Ticks.ToString(CultureInfo.InvariantCulture); var key = Encoding.UTF8.GetBytes(privateKey.ToUpper()); using (var hmac = new HMACSHA256(key)) { var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(ticks)); hashString = Convert.ToBase64String(hash); } return hashString.Equals(authenticationHash); } }
Notice that I added a little extra spice. To guard against a replay attack the timestamp must pass a simple 15-second threshold check. This is admittedly pretty weak in the face of a determined attacker but it’s better than accepting without question a hash from yesterday or a year ago.
Client Testing
In the code I provide a TestClient console app which exercises all three schemes to request widgets:
static void Main(string[] args) { Console.WriteLine("Test client for Ocelot, IdentityServer4, and an internal ASP.NET Core 2.1 API"); Console.WriteLine(Environment.NewLine); TokenAuthenticationAsync().GetAwaiter().GetResult(); BasicAuthenticationAsync().GetAwaiter().GetResult(); HMACAuthenticationAsync().GetAwaiter().GetResult(); Console.WriteLine("Press ENTER to quit."); Console.ReadLine(); }
If you want to run it on your machine make sure the solution is configured for multiple startup projects:
Fire it up and run the client (right-click > Debug > Start New Instance) to see the expected output: