Articles Secure External APIs with API Keys - and Keep the Villains at Bay by Lee P. Richardson

emailx45

Местный
Регистрация
5 Май 2008
Сообщения
3,571
Реакции
2,438
Credits
573
Secure External APIs with API Keys - and Keep the Villains at Bay
Lee P. Richardson - 26/Jun/2020
[SHOWTOGROUPS=4,20]
How to secure external web API's?

This blog post is about how to secure external web API's, as shown with diagrams, detailed videos, and a whole working Pull Request in a sample GitHub repo. 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.


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 Для просмотра ссылки Войди или Зарегистрируйся 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 Для просмотра ссылки Войди или Зарегистрируйся'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 Для просмотра ссылки Войди или Зарегистрируйся in a sample GitHub repo.

One caveat: this post is fairly specific to the security model in the Для просмотра ссылки Войди или Зарегистрируйся 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.

1593374178744.png

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.

Image 3


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.

Image 4


Crudy Keys
Last month this rando also published an Для просмотра ссылки Войди или Зарегистрируйся to help build out CRUD for new entities in ASP.NET Boilerplate apps. Following that guide for creating API Keys would be a great place to start. I'll include the original step and any customizations below. Also, if you want to follow along visually this dude also posted a video of the process including explaining topics like Table-Per-Hierarchy inheritance and published it as an episode to something called Для просмотра ссылки Войди или Зарегистрируйся (to which you should totally subscribe):


[/SHOWTOGROUPS]
 

emailx45

Местный
Регистрация
5 Май 2008
Сообщения
3,571
Реакции
2,438
Credits
573
[SHOWTOGROUPS=4,20]
A1. Add Entity
The ApiKey contains zero additional columns:
Код:
public class ApiKey : 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 Для просмотра ссылки Войди или Зарегистрируйся 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:

Код:
public class ApiKeyDto : EntityDto<long>
{
    [Required]
    [StringLength(AbpUserBase.MaxUserNameLength)]
    public string ApiKey { get; set; }
}
And a mapping profile like this:

Hide   Copy Code
public class ApiKeyDtoProfile : Profile
{
    public ApiKeyDtoProfile()
    {
        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))]
public class CreateApiKeyDto : ApiKeyDto
{
    [Required]
    [StringLength(AbpUserBase.MaxPlainPasswordLength)]
    public string Secret { get; set; }
}

[AbpAuthorize(PermissionNames.Pages_ApiKeys)]
public class ApiKeysAppService : AsyncCrudAppService<Key, 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:

Hide   Copy Code
public CreateApiKeyDto MakeApiKey()
{
    return new CreateApiKeyDto
    {
        ApiKey = User.CreateRandomPassword(),
        Secret = User.CreateRandomPassword()
    };
}
[code]
[/spoiler]

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>.
[spoiler="Code"]
[code]
public override async Task<KeyDto>ateAsync(CreateApiKeyDto input)
{
    var fakeUniqueEmail = input.ApiKey + "@noreply.com";

    var apiKey = new ApiKey
    {
        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();

    return new ApiKeyDto
    {
        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>
public async 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 = new Role
    {
        TenantId = CurrentUnitOfWork.GetTenantId(),
        Name = RoleNames.Api,
        DisplayName = "API"
    };
    await _roleManager.CreateAsync(apiRole);
    await _roleManager.SetGrantedPermissionsAsync(apiRole, permissions);
    return apiRole;
}
[/SHOWTOGROUPS]
 

emailx45

Местный
Регистрация
5 Май 2008
Сообщения
3,571
Реакции
2,438
Credits
573
[SHOWTOGROUPS=4,20]
A8. Run App, See Swagger Update, Rejoice
Great, the API is finished:

Image 5


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:

Код:
public ngOnInit() {
    this.apiKey = new CreateApiKeyDto();
    this.apiKeyServiceProxy
        .generateApiKey()
        .first()
        .subscribe(apiKey => (this.apiKey = apiKey));
}

And add a "Copy To Clipboard" button:

Код:
<mat-form-field class="col-sm-12">
    <input matInput type="text" name="Key" [(ngModel)]="apiKey.apiKey" required placeholder="Key" readonly #keyInput />
    <button mat-icon-button matSuffix type="button" (click)="copyInputToClipboard(keyInput)">
        <mat-icon matTooltip="Copy to clipboard">content_copy</mat-icon>
    </button>
</mat-form-field>
Hide   Copy Code
public copyInputToClipboard(inputElement: HTMLInputElement) {
    inputElement.select();
    document.execCommand('copy');
    inputElement.setSelectionRange(0, 0);
}

And now users can add API Keys:

Image 6


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.

Image 7


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 Для просмотра ссылки Войди или Зарегистрируйся is enabled) because the ApiKey is unique across tenants.

The full code for the ClientTokenAuthController is in Для просмотра ссылки Войди или Зарегистрируйся, 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)]
public async 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 :|
        return new UnauthorizedObjectResult(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 = await GetLoginResultAsync(
            model.ApiKey,
            model.Secret,
            GetTenancyNameOrNull()
        );

        return new OkObjectResult(CreateAccessToken(loginResult));
    }
}


And after placing that class in the Client.V1 namespace, the SwaggerFileMapperConvention (from Для просмотра ссылки Войди или Зарегистрируйся) will expose it in Swagger

Image 8




[/SHOWTOGROUPS]
 

emailx45

Местный
Регистрация
5 Май 2008
Сообщения
3,571
Реакции
2,438
Credits
573
[SHOWTOGROUPS=4,20]
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)]
public class ProductController : 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 = new ClientAuthenticateModel
{
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:
Код:
partial class ClientApiProxy
{
public string AccessToken { get; set; }

partial void PrepareRequest(System.Net.Http.HttpClient client, System.Net.Http.HttpRequestMessage request, string url)
{
if (AccessToken != null)
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", AccessToken);
}
}
}

Huzzah! Now check out that happy 200:

Image 9


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 Для просмотра ссылки Войди или Зарегистрируйся in my Для просмотра ссылки Войди или Зарегистрируйся. If you found this helpful or have a better approach please let me know Для просмотра ссылки Войди или Зарегистрируйся or in the comments. Happy coding!

License
This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)

[/SHOWTOGROUPS]