I’m building the foundation for an ASP.Net Core 2 site with Cosmos DB as the back-end store and want to build in the idea of user-manageable API keys. In the past two posts, I’ve added interactive registration and login to the application using built-in Cookie and Twitter middleware on top of custom authorization logic and Cosmos DB. In this one, we’ll be adding endpoints that require API Keys that can be created and revoked by the user.
While I started out with credentials stored directly in the LoginUser
Document, in the prior post I decided to start treating authentication mechanisms as separate documents (my house keys are not a property of me).
In this post, that separation will start paying off, as it will allow us to add API key entries for the user and easily “revoke” them by switching their authentication type from “APIKey” to “RevokedAPIKey”, keeping the data available for audits but ensuring it’s no longer valid for API authentication.
- ASP.Net Core 2 w/ Cosmos DB: Getting Started
- Custom Authentication in ASP.Net Core 2 w/ Cosmos DB
- Adding Twitter Authentication to an ASP.Net Core 2 site w/ Cosmos DB
- Adding User-Managed API Keys to ASP.Net Core 2 w/ Cosmos DB (You Are Here!)
Breaking it down
In the last two posts, we laid the groundwork for authentication from the UI down to Cosmos DB. With this post, we’re going to build a minimal screen to let users generate API Keys, some protected API endpoints, and the logic to tie these into the LoginUserAuthentications
DocumentCollection in Cosmos DB.
Most of the work is building on the [previous post](), the big difference is a new way to authenticate and some middleware to do the work.
I worked on this in two pieces:
- User Interface: Create/Revoke API keys in the UI down to Cosmos DB
- Making API Calls: Protected API endpoints, Middleware, Membership business logic
The source code through this set of changes is available here: github: Sample_ASPNetCore2AndCosmosDB, Post #4 Branch
Task 1: User-managed API Keys
In this set of changes, we’re going to add some very basic screens to show the list of API Keys, add a new one, and revoke an existing one.
UI Screens
Let’s start with the screens and work down the stack. I’ve created a new (and poorly named) UserController
for the new screens.
SampleCosmosCore2App/Controllers/UserController.cs
[HttpGet("")]
public async Task<IActionResult> IndexAsync(string error)
{
var sessionId = _membership.GetSessionId(HttpContext.User);
var user = await _persistence.Users.GetUserBySessionIdAsync(sessionId);
var auths = await _persistence.Users.GetUserAuthenticationsAsync(user.Id);
// ... grouping logic to create model ...
return View("Index", model);
}
[HttpGet("addKey")]
public IActionResult AddKey()
{
var model = new NewKeyModel();
return View("AddKey", model);
}
[HttpPost("addKey")]
public async Task<IActionResult> PostAddKeyAsync(NewKeyModel model)
{
if (!ModelState.IsValid)
{
return View("AddKey", model);
}
var sessionId = _membership.GetSessionId(HttpContext.User);
var user = await _persistence.Users.GetUserBySessionIdAsync(sessionId);
var generatedKey = _membership.GenerateAPIKey(user.Id);
var result = await _membership.AddAuthenticationAsync(user.Id, "APIKey", generatedKey, model.Name);
var resultModel = // ... create the model ...
return View("ShowKey", resultModel);
}
[HttpGet("revoke")]
public async Task<IActionResult> Revoke(string id)
{
var sessionId = _membership.GetSessionId(HttpContext.User);
var user = await _persistence.Users.GetUserBySessionIdAsync(sessionId);
var result = await _membership.RevokeAuthenticationAsync(user.Id, id);
if (result.Failed)
{
return RedirectToAction("IndexAsync", new { error = result.Error });
}
else
{
return RedirectToAction("IndexAsync");
}
}
IndexAsync
uses ICustomMembership
to get the current logged in user’s information, then uses a new UserPersistence
method to get all available LoginUserAuthentications
from Cosmos DB (which will include Twitter, revoked API Tokens, and more as we go along).
AddKey
and PostAddKeyAsync
display a form to create a new API Token and receive the POST, respectively. When we receive the POST, we rely on a new method in ICustomMembership
to generate a token then use the existing method (built while adding Twitter) to store that token.
Revoke
uses a new ICustomMembership
method to revoke a given key (and I only just noticed I left off the conventional Async suffix, oops).
The AddKey view is a basic 1-field form (included more to show you I don’t have anything up my sleeves):
SampleCosmosCore2App/Views/User/AddKey.cshtml
@model SampleCosmosCore2App.Models.User.NewKeyModel
@{
ViewData["Title"] = "AddKey";
Layout = "~/Views/Shared/Layout.cshtml";
}
<div class="box">
<h2>
AddKey
</h2>
</div>
<div>
<a asp-action="IndexAsync">Back to List</a>
</div>
And once it’s created, we then show it to you with “ShowKey”:
SampleCosmosCore2App/Views/User/ShowKey.cshtml
@model SampleCosmosCore2App.Models.User.UserAuthenticationModel
@{
ViewData["Title"] = "ShowKey";
Layout = "~/Views/Shared/Layout.cshtml";
}
<div class="box">
<h2>
Your New API Key
</h2>
<p>
You will need the API Key Id and API Key Secret to make an API call. Save your API Key Secret now, we won't show it again!
</p>
<div>
Name: @Model.Name<br />
API Key Id: @Model.Id<br />
API Key Secret: @Model.Identity<br />
</div>
</div>
<div>
<a asp-action="IndexAsync">Back to List</a>
</div>
We’ll be requiring the pair of API Key Id and API Key Secret to authorize API calls later. The Id is the generated Cosmos DB id
and the Key is the ICustomMembership
generated value.
Displaying the index is a little more complex, as there are potentially several types of LoginUserAuthentications
records available, so we include areas for specific keys and ignore the ones we don’t currently recognize:
SampleCosmosCore2App/Views/User/Index.cshtml
@model SampleCosmosCore2App.Models.User.UserIndexModel
@{
ViewData["Title"] = "Index";
Layout = "~/Views/Shared/Layout.cshtml";
}
## Your Account
<div asp-validation-summary="ModelOnly" class="text-danger">
</div>
Username: @Model.User.Username
Registered: @Model.User.CreationTime
Twitter Status: @if (Model.UserAuthentications.ContainsKey("Twitter"))
{
var twitter = Model.UserAuthentications["Twitter"].Single();
<text>@twitter.Name at @twitter.CreationTime</text>
}
else
{
<text>Not Linked</text>
}
### API Keys
<table>
<tr>
<th>
Created
</th>
<th>
Name
</th>
<th>
API Key Id
</th>
<th>
API Key Secret
</th>
<th>
</th>
</tr>
@if (Model.UserAuthentications.ContainsKey("APIKey"))
{
var keys = Model.UserAuthentications["APIKey"];
foreach (var key in keys)
{
<tr>
<td>
@key.CreationTime
</td>
<td>
@key.Name
</td>
<td>
@key.Id
</td>
<td>
@key.GetMaskedIdentity()
</td>
<td>
<a asp-action="Revoke" asp-route-id="@key.Id">Revoke</a>
</td>
</tr>
}
}
<tr>
<td colspan="5">
<a asp-action="AddKey">Add a key</a>
</td>
</tr>
</table>
Again, this HTML won’t win any awards, it’s here to let us functionally build out what we need. Later in a real application we would replace this with a better form or front-end templates and JSON.
Membership + Persistence
The screens above need some new capabilities in the membership logic (CosmosDBMembership
) and queries to Cosmos DB (UserPersistence
), so before we test we need to add those in.
The new methods are:
- CosmosDBMembership.GenerateAPIKey
- CosmosDBMembership.AddAuthenticationAsync
- CosmosDBMembership.RevokeAuthenticationAsync
- UserPersistence.GetUserAuthenticationAsync
- UserPersistence.UpdateUserAuthenticationAsync
SampleCosmosCore2App/Membership/CosmosDBMembership.cs
public string GenerateAPIKey(string userId)
{
var key = new byte[32];
using (var generator = RandomNumberGenerator.Create())
{
generator.GetBytes(key);
}
return Convert.ToBase64String(key);
}
GenerateAPIKey
is starting with a simple random generator scheme to generate new keys.
public async Task<AuthenticationDetails> AddAuthenticationAsync(string userId, string scheme, string identity, string identityName)
{
var userAuth = new LoginUserAuthentication()
{
// ... warning below ...
};
userAuth = await _persistence.Users.CreateUserAuthenticationAsync(userAuth);
return new AuthenticationDetails()
{
// ... warning below ...
};
}
AddAuthenticationAsync
is just a CRUD method to write the new data and return the results.
public async Task<RevocationDetails> RevokeAuthenticationAsync(string userId, string identity)
{
var userAuth = await _persistence.Users.GetUserAuthenticationAsync(identity);
if (!userAuth.UserId.Equals(userId))
{
return RevocationDetails.GetFailed("Could not find specified API Key for your account");
}
if (userAuth.Scheme == Core.Users.AuthenticationScheme.RevokedAPIKey)
{
return RevocationDetails.GetFailed("APIKey has already been revoked");
}
if (userAuth.Scheme != Core.Users.AuthenticationScheme.APIKey)
{
return RevocationDetails.GetFailed("Could not find specified API Key for your account");
}
userAuth.Scheme = Core.Users.AuthenticationScheme.RevokedAPIKey;
await _persistence.Users.UpdateUserAuthenticationAsync(userAuth);
return RevocationDetails.GetSuccess();
}
RevokeAuthenticationAsync
is a little more involved due to validation that the key you’re trying to revoke exists, is yours, isn’t already revoked, and so on, but then it updates the key type from APIKey
to RevokedAPIKey
and updates it in Cosmos DB.
So, ask me about class initializers...
There are times that using class initializers is an anti-pattern, like the case above. If the properties are required for the object to be valid, put it in the constructor and don't use class initializers for it. Everything else is fair game.
I recognize that people really like them, but it misleads the next developer and makes it easy to introduce bugs when you're adding or changing "required" fields.
Now that we have the new ICustomMembership
behavior, we can add the UserPersistence
requirements.
public enum AuthenticationScheme
{
Twitter = 1,
APIKey = 2,
RevokedAPIKey = 3
}
First we add the two new Authentication types to the enum.
// ...
public async Task<LoginUserAuthentication> GetUserAuthenticationAsync(string id)
{
var query = _client.CreateDocumentQuery<LoginUserAuthentication>(GetAuthenticationsCollectionUri(), new SqlQuerySpec()
{
QueryText = "SELECT * FROM UserAuthentications UA WHERE UA.id = @id",
Parameters = new SqlParameterCollection()
{
new SqlParameter("@id", id)
}
});
var result = await query.AsDocumentQuery()
.ExecuteNextAsync<LoginUserAuthentication>();
return result.SingleOrDefault();
}
// ...
public async Task UpdateUserAuthenticationAsync(LoginUserAuthentication userAuth)
{
await _client.ReplaceDocumentAsync(UriFactory.CreateDocumentUri(_databaseId, AUTHS_DOCUMENT_COLLECTION_ID, userAuth.Id), userAuth, new RequestOptions() { });
}
// ...
Once again, the Cosmos DB logic is pretty straightforward. I’m still getting used to the Query result handling, which feels a little convoluted, but everything else has been absurdly smooth because we’re just doing usual Document store logic (please take this JSON serialized object and give it back later) instead of having to map relational concepts to objects.
Now we can test out this process.
First we’ll add a new Key:
And here it is:
And it shows up in our fancy API Key list like so:
And we can revoke one of the keys easily:
And here it is in Cosmos DB Data Explorer, with the poorly named “Scheme” property indicating “3”, which is “RevokedAPIKey”:
Now that our fictitious users can generate and revoke API keys and we’ve locked in those changes, it’s time to protect some API endpoints.
Task 2: Protected API Endpoints
What we’re going to be doing for this case is adding in a new AuthenticationHandler middleware.
Other resources
- Adding a custom AuthenticationHandler: Custom Authentication in ASP.NET Core 2.0
- More detailed: Creating an authentication scheme in ASP.NET Core 2.0
We’ll be adding a CustomMembershipAPIAuthHandler
and an options object to hold the name of the AuthenticationScheme
and Realm
configured from startup.
The very short version of AuthenticationHandlers is that when the server receives a request, it will go through the whole list of available AuthenticationHandlers and ask each one if the request is: Pass, Fail, or NoResult:
- NoResult: This request doesn’t have anything relevant to me, thank you drive through!
- Fail: This request has relevant info for me and it’s wrong!
- Success: This request has relevant info for me and it’s right, here’s a ClaimsPrincipal!
Add the AuthenticationHandler
For this case, we’re going to expect an Authorization
header on requests formatted as: Scheme Id:Secret
, similar to Basic and Bearer authentication methods. The Scheme
comes from the Options that will be set in Startup, the Id and Secret are the API Token values we created above.
SampleCosmosCore2App/Membership/CustomMembershipAPIAuthHandler.cs (HandleAuthenticateAsync)
public class CustomMembershipAPIAuthHandler : AuthenticationHandler<CustomMembershipAPIOptions>
{
// ...
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
// Is this relevant to us?
if (!Request.Headers.TryGetValue(HeaderNames.Authorization, out var authorization))
{
return AuthenticateResult.NoResult();
}
var actualAuthValue = authorization.FirstOrDefault(s => s.StartsWith(Options.Scheme, StringComparison.CurrentCultureIgnoreCase));
if (actualAuthValue == null)
{
return AuthenticateResult.NoResult();
}
// Is it a good pair?
var apiPair = actualAuthValue.Substring(Options.Scheme.Length + 1);
var apiValues = apiPair.Split(':', 2);
if (apiValues.Length != 2 || String.IsNullOrEmpty(apiValues[0]) || String.IsNullOrEmpty(apiValues[1]))
{
return AuthenticateResult.Fail($"Invalid authentication format, expected '{Options.Scheme} id:secret'");
}
var principal = await _membership.GetOneTimeLoginAsync("APIKey", apiValues[0], apiValues[1], Options.Scheme);
if (principal == null)
{
return AuthenticateResult.Fail("Invalid authentication provided, access denied.");
}
var ticket = new AuthenticationTicket(principal, Options.Scheme);
return AuthenticateResult.Success(ticket);
}
// ...
}
Notice we’ve added the concept of a “OneTimeLogin” to ICustomMembership
. This will lead to a membership refactor to make it clearer which methods are relevant to “Interactive” logins and which are relevant to “OneTimeLogin” types like API request authentication.
On top of having logic to look at handle Authenticate requests, we also want to add logic to handle Challenge and Forbidden requests. In this case, we are going to add a nice WWW-Authenticate
header before letting the base class send it back as a 401, which is consistent with how most APIs handle challenges. We’re also going to just let the base class handle the Forbidden cases as designed.
SampleCosmosCore2App/Membership/CustomMembershipAPIAuthHandler.cs (HandleAuthenticateAsync)
public class CustomMembershipAPIAuthHandler : AuthenticationHandler<CustomMembershipAPIOptions>
{
// ...
protected override async Task HandleChallengeAsync(AuthenticationProperties properties)
{
Response.Headers["WWW-Authenticate"] = $"Basic realm=\"{Options.Realm}\", charset=\"UTF-8\"";
await base.HandleChallengeAsync(properties);
}
protected override Task HandleForbiddenAsync(AuthenticationProperties properties)
{
return base.HandleForbiddenAsync(properties);
}
// ...
}
After an addition of an extension method, we can register this in Startup
.
SampleCosmosCore2App/Startup.cs
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc();
// ... add persistence, membership ...
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
/* Custom Membership API Provider */
.AddCustomMembershipAPIAuth("APIToken", "SampleCosmosCore2App")
/* External Auth Providers */
.AddCookie("ExternalCookie")
.AddTwitter("Twitter", options => /* ... */ )
/* 'Session' Cookie Provider */
.AddCookie((options) => /* ... */);
services.AddAuthorization(options => {
options.AddPolicy("APIAccessOnly", policy =>
{
policy.AddAuthenticationSchemes("APIToken");
policy.RequireAuthenticatedUser();
});
});
}
First we make a one line addition to the AddCustomMembershipAPIAuth
Extension method, passing in a Scheme
and Realm
. Next we add in a new “APIAccessOnly” policy that we will use to enforce API Access authentication only for API endpoints, which we can do next.
SampleCosmosCore2App/Controllers/ValuesController.cs
[Route("api/[controller]")]
[Authorize(Policy = "APIAccessOnly")]
public class ValuesController : Controller {
// GET api/values
[HttpGet]
public IEnumerable<string> Get() {
return new string[] { "value1", "value2" };
}
// ...
}
I’ve decorated the ValuesController with an Authorize(Policy = ...)
attribute, but I could also have used Authorize(AuthenticationScheme = ...)
and skipped the policy definition in the Startup file. However, I like the idea that all of my Authentication schemes are defined in Startup consistently, the policy for accessing API endpoints is defined in one place, and it looks more readable to me.
Membership Changes
To support the middleware above, we need a GetOneTimeLoginAsync
method in membership. This method will accept the authentication details, verify them, and return a ClaimsPrincipal (or null).
SampleCosmosCore2App/Membership/CosmosDBMembership.cs
public async Task<ClaimsPrincipal> GetOneTimeLoginAsync(string scheme, string userAuthId, string identity, string authenticationScheme)
{
var authScheme = StringToScheme(scheme);
var userAuth = await _persistence.Users.GetUserAuthenticationAsync(userAuthId);
// are the passed auth details valid?
if (userAuth == null)
{
return null;
}
if (userAuth.Scheme != authScheme || !userAuth.Identity.Equals(identity, StringComparison.CurrentCultureIgnoreCase))
{
return null;
}
// is the user allowed to log in? We don't have addtl checks yet
var user = await _persistence.Users.GetUserAsync(userAuth.UserId);
// create a claims principal
var claimsIdentity = new ClaimsIdentity(authenticationScheme);
claimsIdentity.AddClaim(new Claim("userId", userAuth.UserId));
claimsIdentity.AddClaim(new Claim("userAuthId", userAuth.Id));
return new ClaimsPrincipal(claimsIdentity);
}
Currently this method accepts an authentication scheme for the “OneTimeLogin”, later this will be refactored so that the membership class supports a separate “InteractiveAuthenticationScheme” and “OneTimeLoginScheme” that are configured in Startup and they will consistently set either userId
and userAuthId
claims or userId
and sessionid
claims.
That’s all we needed! Let’s try it out with postman.
Let’s start with the happy path, passing in good credentials:
Then we should try a number of failure situations, like no credentials, bad credentials, and invalidly formatted credentials:
Excellent! We now have working API Authentication!
Wrapping up, next steps
Moving right along. We now have an ASP.net Core 2 website that can perform really basic CRUD logic against Cosmos DB. We’ve then layered standard username/password authentication on it, with storage in Cosmos DB as well. Then we extended this to support a second interactive login method (Twitter), opening the door to adding as many of those as we need. Then in this post, we’ve added API authorization for per-request authorization. So far, so good.
Next up, we’re going to take a break from Cosmos DB for a bit to do some general refactoring (which won’t be a blog post) and add in error handling logic. From there, we’ll shift focus to front-end tooling to ensure we have a good developer and production experience with CSS and JavaScript. Once we’re done there, we’ll be back to add in API Rate Limiting with Cosmos DB as the backing store. See you soon!