Utilizando AuthenticationHandler para controlar el API key de nuestras APIs

La semana pasada escribí un artículo sobre cómo añadir un API key a nuestras API utilizando un middleware. Tal y como comenté, hay varias maneras de hacer esta implementación, así que hoy os traigo otra, basándonos en lo que nos ofrece el framework para simplificarnos esta implementación.
Implementando AuthenticationSchemeOptions
El primer paso será la implementación de AuthenticationSchemeOptions. Podríamos no necesitar la implementación que he hecho, pero he añadido el campo HeaderName para tener la posibilidad de cambiar el nombre de la key que se buscará en la cabecera en la que debe estar el API key. Por defecto, el nombre de esa cabecera es Authorization (obtenido de HeaderNames.Authorizarion) pero, como veremos más adelante, lo vamos a cambiar.
public class ApiKeySchemeOptions : AuthenticationSchemeOptions
{
public const string Scheme = "ApiKeyScheme";
public string HeaderName { get; set; } = HeaderNames.Authorization;
}
Implementando AuthenticationHandler
Esta implementación es la que tendrá la lógica para validar nuestro API key. Yo lo valido contra un valor almacenado en las settings (obtenido desde IConfiguration) de mi proyectos, pero se podría validar contra una base de datos. Incluso tener varias keys con distintos claims asociados.
public class ApiKeySchemeHandler : AuthenticationHandler<ApiKeySchemeOptions>
{
private readonly IConfiguration _configuration;
public ApiKeySchemeHandler(IOptionsMonitor<ApiKeySchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock, IConfiguration configuration) : base(options, logger, encoder, clock)
{
_configuration = configuration;
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.ContainsKey(Options.HeaderName))
{
return Task.FromResult(AuthenticateResult.Fail("Header Not Found."));
}
var headerValue = Request.Headers[Options.HeaderName];
if (string.IsNullOrWhiteSpace(headerValue))
return Task.FromResult(AuthenticateResult.Fail("Wrong Api Key"));
var apiKey = _configuration.GetValue<string>(Options.HeaderName);
if (apiKey is null)
return Task.FromResult(AuthenticateResult.Fail("Wrong Api Key."));
if (!apiKey.Equals(headerValue))
return Task.FromResult(AuthenticateResult.Fail("Wrong Api Key."));
var identity = new ClaimsIdentity(nameof(ApiKeySchemeHandler));
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
Si la llamada es autorizada, se crea un ticket de autenticación con una identidad que está disponible en todo el scope de la llamada. Si quiésieramos añadir claims a esa identidad, este sería el punto en el que hacerlo.
Añadiendo la autenticación a nuestra aplicación
El último paso es añadir la autenticación que hemos implementado a nuestra aplicación. Para ello, nuestro program, debe contener algo parecido a lo siguiente, donde se registra el esquema de autenticación que hemos implementado, indicándole el handler (también implementado en pasos anteriores) y algunas opciones (aquí es donde definimos el nombre de la cabecera que debe contener el API key)
builder.Services.AddAuthorization();
builder.Services.AddAuthentication(ApiKeySchemeOptions.Scheme)
.AddScheme<ApiKeySchemeOptions, ApiKeySchemeHandler>(
ApiKeySchemeOptions.Scheme, options =>
{
options.HeaderName = "XApiKey";
});
var app = builder.Build();
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.AddMiddlewares();
app.MapControllers();
app.BuildContext().Run();
Por último, debemos indicar los controladores que queremos que requieran un API key valido. Para ello tenemos varias alternativas. A continuación muestro algunas de ellas.
Utilizar el decorador [Authorize]. Se puede usar tanto a nivel de endpoint como de controlador. Nótese que para el ejemplo estamos trabajando con un único esquema, pero podríamos tener varios, por lo que este decorador seleccionaría el esquema por defecto.
Si queremos utilizar un esquema en concreto, podemos hacerlo con el mismo decorador que antes, pero dandole algo más de información. Por ejemplo, [Authorize(AuthenticationSchemes = “{Your-schema}”)]
Siguiendo la metodología anterior, si tenemos algún endpoint o controlador anónimo, bastaría con no añadir el decorador.
Con todo lo anterior, puede resultar muy tedioso tener que añadir los decoradores en todos los controladores, si queremos que todos, o la mayoría, requieran del API key. En ese caso, podemos añadirlo a nivel global desde el Program, quedando de la siguiente manera:
builder.Services.AddAuthorization(options =>
{
options.DefaultPolicy = new AuthorizationPolicyBuilder()
.AddAuthenticationSchemes(ApiKeySchemeOptions.Scheme)
.RequireAuthenticatedUser()
.Build();
});
builder.Services.AddAuthentication(ApiKeySchemeOptions.Scheme)
.AddScheme<ApiKeySchemeOptions, ApiKeySchemeHandler>(
ApiKeySchemeOptions.Scheme, options =>
{
options.HeaderName = "XApiKey";
});
var app = builder.Build();
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.AddMiddlewares();
app.MapControllers().RequireAuthorization();
app.BuildContext().Run();
Siguiendo esta línea, ahora si queremos un controlador o endpoint anónimo, tendríamos que indicarlo. Podemos hacerlo añadiéndoles el decorador [AllowAnonymous]
[Route("api/test")]
[ApiController]
[AllowAnonymous]
public class TestController : ControllerBase
{
...
}
Añadiendo algunos tests
Dado que en este post hemos creado una implementación diferente de lo que se hizo en el artículo sobre cómo añadir un API key a nuestras API utilizando un middleware, tendría sentido mantener los tests para garantizar que todo sigue funcionando, a pesar de haber cambiado la implementación. A continuación muestro esos tests, en los que se comprueba que la ejecución no es autorizada si no envío la clave, o envío una incorrecta. Y sí es permitida cuando la calve es correcta.
private static readonly string url = "/api/task";
private HttpClient _client;
[TestInitialize]
public async Task InitDbContext()
{
//Creates a temporary database and inserts some data to make tests
WebApplicationFactory<Program> factory = await BuildWebApplicationFactory(Guid.NewGuid().ToString());
_client = factory.CreateClient();
}
[TestCleanup]
public async Task RemoveDbContext()
{
await DeleteDatabase();
_client.Dispose();
}
[TestMethod]
public async Task get_all_without_api_key_unauthorized()
{
StringContent content = new StringContent(string.Empty, Encoding.UTF8, "application/json");
HttpResponseMessage response = await _client.PostAsync($"{url}/getAll", content);
Assert.AreEqual(System.Net.HttpStatusCode.Unauthorized, response.StatusCode);
}
[TestMethod]
public async Task get_all_wrong_api_key_unauthorized()
{
_client.DefaultRequestHeaders.Add("X-API-Key", "wrong-key");
StringContent content = new StringContent(string.Empty, Encoding.UTF8, "application/json");
HttpResponseMessage response = await _client.PostAsync($"{url}/getAll", content);
Assert.AreEqual(System.Net.HttpStatusCode.Unauthorized, response.StatusCode);
}
[TestMethod]
public async Task get_all_ok()
{
_client.DefaultRequestHeaders.Add("X-API-Key", "correct-key");
TaskPaginationRequest pagination = new TaskPaginationRequest { TaskListId = 1, PageSize = 2, PageNumber = 1 };
StringContent content = new StringContent(JsonConvert.SerializeObject(pagination), Encoding.UTF8, "application/json");
HttpResponseMessage response = await _client.PostAsync($"{url}/getAll", content);
Assert.AreEqual(System.Net.HttpStatusCode.OK, response.StatusCode);
}
Con todo lo anterior, tendríamos la implementación realizada, respaldada con los tests anteriores.
Foto de Towfiqu barbhuiya en Unsplash