Suppose you've written a web app and exposed an external REST API based on Swagger and Swashbuckle in an ASP.Net Core site. Perhaps you blindly followed some rando's recent blog post about it, published it to production, and your customers are super happy except: Horrors, you've just realized you forgot to secure it and now it's open to all the villains of the Internet! Worse, your customers don't want to use their existing credentials, they want something called API Keys.
Good news: you've just stumbled on said rando's follow-up blog post about how to secure those external web API's, and even better it's got diagrams, detailed videos, and a whole working Pull Request in a sample GitHub repo.
One caveat: this post is fairly specific to the security model in the ASP.Net Boilerplate framework, but even if you're not using that framework, this technique should be generally applicable.
What Are API Keys?
The idea of API Keys is fairly standard with systems that offer an external API. They offer customers a way to connect to an API with credentials that are separate from their own. More importantly API Keys offer limited access, a critical element of a good security model. API Keys can't, for instance, log in to the site and create new API Keys, or manage users. They can only perform the limited actions that the external Web API grants. If the credentials become compromised, users can reject or delete them without affecting their own credentials.
Data Structure
The first step in implementing API Keys is to design the data structure and build out the CRUD for the UI. Well, the CRD, anyway, updating API Keys doesn't make much sense.
Conveniently enough ASP.Net Boilerplate already provides a Users entity that ostensibly offers a superset of everything required. ASP.Net Boilerplate Users can have multiple Roles and each of those Roles can have multiple Permissions.
Developers can then restrict access to methods and classes via the AbpAuthorize("SomePermission")
attribute, which takes a Permission as a parameter. When an end user makes a request to an endpoint ABP determines the user from a bearer token in the request header, figures out their roles, and figures out which permissions belong to those roles. If one of those permissions matches the requirement in the AbpAuthorize()
attribute, the call is allowed through.
API Keys should be similar. As far as fields they'll have an "API Key" instead of "Username", and a "Secret" instead of a "Password". Unlike users they'll likely only need one permission for decorating the external API instead of many. Thus, they'll have just a single Role to help link the single permission to the API Keys.
Therefore, implementing this should be as simple as having ApiKey inherit from User and pretend that Username is ApiKey and Password is Secret.
Crudy Keys
A1. Add Entity
The ApiKey contains zero additional columns:
publicclassApiKey:User{}
A2. Add to DB Context
A3. Add Migration
The addition of inheritance to the User entity means Entity Framework will add a discriminator column because Entity Framework Core prefers the Table per Hierarchy inheritance strategy. It will take care of setting that discriminator column for all future data, but it won't for older data. To address that add either a second migration or add this to the first migration:
migrationBuilder.Sql("UPDATE AbpUsers SET Discriminator = 'User'");
A4. Update Database
A5. Add DTO
The only field we want to expose when viewing API Keys is the key (username). We want a custom mapping from Username to ApiKey when AutoMapper converts it. Therefore we want a DTO like this:
publicclassApiKeyDto:EntityDto<long>{[Required][StringLength(AbpUserBase.MaxUserNameLength)]publicstring ApiKey {get;set;}}
And a mapping profile like this:
publicclassApiKeyDtoProfile:Profile{publicApiKeyDtoProfile(){CreateMap<ApiKey,ApiKeyDto>().ForMember(i => i.ApiKey, opt => opt.MapFrom(i => i.UserName));}}
A6. Register a Permission
context.CreatePermission(PermissionNames.Pages_ApiKeys, L("ApiKeys"), multiTenancySides: MultiTenancySides.Host);
A7. AppService
The AppService has three things of note.
First, the Create DTO is different from the View DTO, because users send in a Secret but never get one back. We can solve that by making a CreateApiKeyDto that inherits from the view DTO and customizes the final generic parameter
[AutoMapFrom(typeof(ApiKey))]publicclassCreateApiKeyDto:ApiKeyDto{[Required][StringLength(AbpUserBase.MaxPlainPasswordLength)]publicstring Secret {get;set;}}[AbpAuthorize(PermissionNames.Pages_ApiKeys)]publicclassApiKeysAppService:AsyncCrudAppService<ApiKey, ApiKeyDto,long, PagedAndSortedResultRequestDto, CreateApiKeyDto>{...
Second, we need a custom method to retrieve unique API Keys and Secrets. A quick naive version would look like this:
publicCreateApiKeyDtoMakeApiKey(){returnnewCreateApiKeyDto{
ApiKey = User.CreateRandomPassword(),
Secret = User.CreateRandomPassword()};}
Finally, when we create ApiKeys we need to put in some fake value in the various required user fields, but most importantly we need to hash the secret with an IPasswordHasher<User>
.
publicoverrideasync Task<ApiKeyDto>CreateAsync(CreateApiKeyDto input){var fakeUniqueEmail = input.ApiKey +"@noreply.com";var apiKey =newApiKey{
UserName = input.ApiKey,
EmailAddress = fakeUniqueEmail,
Name ="API Key",
Surname ="API Key",
IsEmailConfirmed =true,
NormalizedEmailAddress = fakeUniqueEmail
};
apiKey.Password = _passwordHasher.HashPassword(apiKey, input.Secret);await _userManager.CreateAsync(apiKey);var apiRole =await _roleService.EnsureApiRole();await _userManager.SetRolesAsync(apiKey,new[]{ apiRole.Name });await CurrentUnitOfWork.SaveChangesAsync();returnnewApiKeyDto{
Id = apiKey.Id,
ApiKey = apiKey.UserName
};}
That call to _roleService.EnsureApiRole()
basically just creates a Role and a Permission that we can decorate our external API calls with.
/// <summary>/// ApiKeys should have the API permission. Users/ApiKeys must get permissions by association with a Role./// This code finds and returns a role called API or if it doesn't exist it creates and returns a role/// called API that has the API permission. This code is called when an API Role is created. Thus, the API/// role is created the 1st time a user creates an API Key for a tenant./// </summary>publicasync Task<Role>EnsureApiRole(){var apiRole =await _roleRepository.GetAll().FirstOrDefaultAsync(i => i.Name == RoleNames.Api);if(apiRole !=null)return apiRole;var permissions = _permissionManager.GetAllPermissions().Where(i => i.Name == PermissionNames.Api);
apiRole =newRole{
TenantId = CurrentUnitOfWork.GetTenantId(),
Name = RoleNames.Api,
DisplayName ="API"};await _roleManager.CreateAsync(apiRole);await _roleManager.SetGrantedPermissionsAsync(apiRole, permissions);return apiRole;}
A8. Run App, See Swagger Update, Rejoice
Great, the API is finished:
B1. Update nSwag
B2. Register Service Proxy
B3. Update Left-Hand Nav
B4. Duplicate Tenant Folder and Find/Replace "Tenant" with "[Entity]" and "tenant" with "[entity]"
B5. Update Route
B6. Register New Components in app.module.ts
B7. Fix Fields, Customize ... Rejoice?
With the back-end work finished, the front-end is fairly straightforward. Just delete the edit dialog and in the create dialog to set the DTO on init and pick up the random passwords:
publicngOnInit(){this.apiKey =newCreateApiKeyDto();this.apiKeyServiceProxy
.generateApiKey().first().subscribe(apiKey =>(this.apiKey = apiKey));}
And add a "Copy To Clipboard" button:
<mat-form-fieldclass="col-sm-12"><inputmatInputtype="text"name="Key"[(ngModel)]="apiKey.apiKey"requiredplaceholder="Key"readonly#keyInput/><buttonmat-icon-buttonmatSuffixtype="button"(click)="copyInputToClipboard(keyInput)"><mat-iconmatTooltip="Copy to clipboard">content_copy</mat-icon></button></mat-form-field>
publiccopyInputToClipboard(inputElement: HTMLInputElement){
inputElement.select();
document.execCommand('copy');
inputElement.setSelectionRange(0,0);}
And now users can add API Keys:
But API users still aren't authenticating with those API Keys.
ClientTokenAuthController
The existing SPA site authenticates a username, password and tenant by calling into the TokenAuthController class. If the credentials check out that class returns an access token that the client appends to subsequent requests.
The Web API should do the same thing. Mostly just copy and paste the existing TokenAuthController class into a ClientTokenAuthController and put it in the external API folder (\Client\V1 from my previous blog post). The main customization is that the ClientTokenAuthController should take an ApiKey and Secret, and it should not take a tenant (if multi-tenancy is enabled) because the ApiKey is unique across tenants.
The full code for the ClientTokenAuthController is in this commit, but the relevant part looks like this:
/// <summary>/// Authenticates an API Key and Secret. If successful AuthenticateResultModel will contain a token that/// should be passed to subsequent methods in the header as a Bearer Auth token./// </summary>/// <param name="model">Contains the API Key and Secret</param>/// <returns>The authentication results</returns>[HttpPost("api/client/v1/tokenauth", Name =nameof(Authenticate))][ProducesResponseType(typeof(AuthenticateResultModel),200)][DontWrapResult(WrapOnError =false, WrapOnSuccess =false, LogError =true)]publicasync Task<IActionResult>Authenticate([FromBody]ClientAuthenticateModel model){/*
* This 1st Authenticate() looks only in ApiKeys, which are assumed to be unique across Tenants (unlike Users),
* thus we can pull back a TenantId on success and set the session to use it
*/var apiKeyAuthenticationResult =await _apiKeyAuthenticationService.Authenticate(model.ApiKey, model.Secret);if(!apiKeyAuthenticationResult.Success){// this 401 is much cleaner than what the regular TokenAuthController returns. It does a HttpFriendlyException which results in 500 :|returnnewUnauthorizedObjectResult(null);}using(_session.Use(apiKeyAuthenticationResult.TenantId,null)){/*
* This 2nd Authenticate is almost entirely guaranteed to succeed except for a few edge cases like if the
* tenant is inactive. However, it's necessary in order to get a loginResult and create an access token.
*/
AbpLoginResult<Tenant, User> loginResult =awaitGetLoginResultAsync(
model.ApiKey,
model.Secret,GetTenancyNameOrNull());returnnewOkObjectResult(CreateAccessToken(loginResult));}}
And after placing that class in the Client.V1 namespace, the SwaggerFileMapperConvention (from Mastering External API's) will expose it in Swagger
Authenticating The External API
Finally, the last step is to lock down the existing method and ensure (ala TDD) that client's can't get in. If you're a visual learner this section along with details about password hashing and AutoMapper details is covered in Code Hour Episode 29:
With that shameful self-promotion out of the way, next register an API Permission. Unlike the earlier permission this one is for accessing API endpoints, not managing API Keys:
context.CreatePermission(PermissionNames.Api, L("Api"), multiTenancySides: MultiTenancySides.Tenant);
Now restrict the external API's controller with that permission:
[AbpAuthorize(PermissionNames.Api)]publicclassProductController:LeesStoreControllerBase
Now if when clients hit the /api/client/v1/product endpoint, they get an HTTP 401. Excellent!
To authenticate with an API Key and Password call into ClientTokenAuthController with an API Key and Secret and save the token onto the ClientApiController:
var authenticateModel =newClientAuthenticateModel{
ApiKey = apiKey,
Secret = secret
};var authenticateResultModel =await clientApiProxy.AuthenticateAsync(authenticateModel);
clientApiProxy.AccessToken = authenticateResultModel.AccessToken;var product =await clientApiProxy.GetProductAsync(productId);
What's that? You're following along and it didn't compile?! No AccessToken property on ClientApiProxy?! Oh, that's easy! Just use the fact that the NSwag generated proxy is partial and has a PrepareRequest partial method:
partialclassClientApiProxy{publicstring AccessToken {get;set;}partialvoidPrepareRequest(System.Net.Http.HttpClient client,System.Net.Http.HttpRequestMessage request,string url){if(AccessToken !=null){
request.Headers.Authorization =newAuthenticationHeaderValue("Bearer", AccessToken);}}}
Huzzah! Now check out that happy 200:
🎉 We Did It!!
If you made it to the end of this post I am deeply impressed. I hope it was useful. The code is at the Api Keys Pull Request in my ASP.Net Demo Site. If you found this helpful or have a better approach please let me know @twitter or in the comments. Happy coding!