Skip to content

Commit

Permalink
Support multiple valid header and query keys (#14)
Browse files Browse the repository at this point in the history
* Added support for multiple header and query keys in options and handler

* Bumped major version
  • Loading branch information
dnmh-psc committed Jul 12, 2023
1 parent e170760 commit 140b83a
Show file tree
Hide file tree
Showing 12 changed files with 188 additions and 78 deletions.
2 changes: 1 addition & 1 deletion Dnmh.Security.ApiKeyAuthentication.sln
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ VisualStudioVersion = 17.5.33502.453
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dnmh.Security.ApiKeyAuthentication", "Source\Dnmh.Security.ApiKeyAuthentication.csproj", "{D351F140-C932-4E94-93BA-223EDD531E30}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dnmh.Security.ApiKeyAuthentication.Test", "Tests\Dnmh.Security.ApiKeyAuthentication.Test.csproj", "{DFEF603C-60C8-41B7-A3D5-0B470800A4C5}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dnmh.Security.ApiKeyAuthentication.Tests", "Tests\Dnmh.Security.ApiKeyAuthentication.Tests.csproj", "{DFEF603C-60C8-41B7-A3D5-0B470800A4C5}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dnmh.Security.ApiKeyAuthentication.Sample", "Samples\Dnmh.Security.ApiKeyAuthentication.Sample.csproj", "{4D31192C-0339-43C1-B174-DC9B1E4535A0}"
EndProject
Expand Down
55 changes: 34 additions & 21 deletions Source/AuthenticationHandler/ApiKeyAuthenticationHandler.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
using Dnmh.Security.ApiKeyAuthentication.AuthenticationHandler.Context;
using Dnmh.Security.ApiKeyAuthentication.AuthenticationHandler.Internal;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers;
using System.Diagnostics.CodeAnalysis;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text.Encodings.Web;

namespace Dnmh.Security.ApiKeyAuthentication.AuthenticationHandler;
Expand Down Expand Up @@ -33,6 +35,7 @@ public class ApiKeyAuthenticationHandler : AuthenticationHandler<ApiKeyAuthentic
protected override Task InitializeHandlerAsync()
{
Options.Validate();
Options.InitializeDefaultValues();
return base.InitializeHandlerAsync();
}

Expand All @@ -47,34 +50,42 @@ protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
try
{
string? apiKey = null;
if (Options.AllowApiKeyInRequestHeader && TryExtractFromHeader(Request, out apiKey) && apiKey is null)
IEnumerable<string>? apiKeys = null;
if (Options.AllowApiKeyInRequestHeader && TryExtractFromHeader(Request, out apiKeys) && apiKeys is null)
{
return await FailAuthentication(new FailedAuthenticationException("Missing api key"));
}

if (apiKey is null && Options.AllowApiKeyInQuery && TryExtractFromQuery(Request, out apiKey) && apiKey is null)
if (apiKeys is null && Options.AllowApiKeyInQuery && TryExtractFromQuery(Request, out apiKeys) && apiKeys is null)
{
return await FailAuthentication(new FailedAuthenticationException("Missing api key"));
}

if (apiKey is null)
if (apiKeys is null)
{
return AuthenticateResult.NoResult();
}

var result = await _authenticationService.ValidateAsync(new ValidationContext(Context, Scheme, Options, apiKey!));
ClaimsPrincipal? validPrinciple = null;
foreach (var apiKey in apiKeys)
{
validPrinciple = await _authenticationService.ValidateAsync(new ValidationContext(Context, Scheme, Options, apiKey!));
if (validPrinciple is not null)
{
break;
}
}

if (result is null)
if (validPrinciple is null)
{
return await FailAuthentication(new FailedAuthenticationException("Invalid api key"));
}

if (Events is not null)
{
await Events.OnAuthenticationSuccess(new AuthenticationSuccessContext(Context, Scheme, Options, result));
await Events.OnAuthenticationSuccess(new AuthenticationSuccessContext(Context, Scheme, Options, validPrinciple));
}
var ticket = new AuthenticationTicket(result, Scheme.Name);
var ticket = new AuthenticationTicket(validPrinciple, Scheme.Name);
return AuthenticateResult.Success(ticket);
}
catch (Exception ex)
Expand All @@ -90,27 +101,27 @@ protected override async Task HandleChallengeAsync(AuthenticationProperties prop
await base.HandleChallengeAsync(properties);
}

private bool TryExtractFromHeader(HttpRequest request, [MaybeNullWhen(false)] out string headerValue)
private bool TryExtractFromHeader(HttpRequest request, [MaybeNullWhen(false)] out IEnumerable<string> headerValues)
{
if (Options.UseAuthorizationHeaderKey)
{
if (!request.Headers.ContainsKey(HeaderNames.Authorization))
{
headerValue = default;
headerValues = default;
// Authorization header not in request
return false;
}

if (!AuthenticationHeaderValue.TryParse(request.Headers[HeaderNames.Authorization], out var authenticationHeaderValue))
{
headerValue = default;
headerValues = default;
//Invalid Authorization header
return false;
}

if (authenticationHeaderValue.Parameter is null)
{
headerValue = default;
headerValues = default;
// Invalid Authorization header
return false;
}
Expand All @@ -119,36 +130,38 @@ private bool TryExtractFromHeader(HttpRequest request, [MaybeNullWhen(false)] ou
if (!schemeName.Equals(authenticationHeaderValue.Scheme, StringComparison.OrdinalIgnoreCase))
{
// Not correct scheme authentication header
headerValue = default;
headerValues = default;
return false;
}

headerValue = authenticationHeaderValue.Parameter;
headerValues = new List<string> { authenticationHeaderValue.Parameter };
}
else
{
if (!request.Headers.ContainsKey(Options.HeaderKey))
var validKeys = Options.HeaderKeys.Intersect(request.Headers.Keys);
if (validKeys == null || !validKeys.Any())
{
headerValue = default;
headerValues = default;
// Authorization header not in request
return false;
}

headerValue = request.Headers[Options.HeaderKey]!;
headerValues = request.Headers.Where(x => validKeys.Contains(x.Key)).SelectMany(x => x.Value).Where(x => x != null).Select(x => x!);
}

return true;
}

private bool TryExtractFromQuery(HttpRequest request, [MaybeNullWhen(false)] out string queryValue)
private bool TryExtractFromQuery(HttpRequest request, [MaybeNullWhen(false)] out IEnumerable<string> queryValues)
{
if (request.Query.ContainsKey(Options.QueryKey))
var validKeys = Options.QueryKeys.Intersect(request.Query.Keys);
if (validKeys != null && validKeys.Any())
{
queryValue = request.Query[Options.QueryKey]!;
queryValues = request.Query.Where(x => validKeys.Contains(x.Key)).SelectMany(x => x.Value).Where(x => x != null).Select(x => x!);
return true;
}

queryValue = null;
queryValues = null;
return false;
}

Expand Down
36 changes: 26 additions & 10 deletions Source/AuthenticationHandler/ApiKeyAuthenticationOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ namespace Dnmh.Security.ApiKeyAuthentication.AuthenticationHandler;
/// </summary>
public class ApiKeyAuthenticationOptions : AuthenticationSchemeOptions
{
/// <summary>
/// The default value for <see cref="QueryKeys"/> if none are added.
/// </summary>
public const string DefaultQueryKey = "apikey";

/// <summary>
/// The default value for <see cref="HeaderKeys"/> if none are added.
/// </summary>
public const string DefaultHeaderKey = "X-API-KEY";

/// <inheritdoc/>
public new ApiKeyAuthenticationEvents? Events
{
Expand All @@ -29,29 +39,35 @@ public class ApiKeyAuthenticationOptions : AuthenticationSchemeOptions
public bool AllowApiKeyInRequestHeader { get; set; } = true;

/// <summary>
/// Get or set the key used for the api key, when provided as a query parameter.
/// Default is <c>apikey</c>
/// Get or set the allowed keys used for the api key, when provided as a query parameter.
/// If no keys are added and <see cref="AllowApiKeyInQuery"/> is <c>true</c>, then the value of <see cref="DefaultQueryKey"/> is added automatically to the set.
/// </summary>
/// <remarks>
/// If more than one key in the query matches keys in the allowed set of <see cref="QueryKeys"/>, then all of the matched keys in the query are used for validation.
/// </remarks>
/// <example>
/// Example of header:
/// <code>&lt;HeaderKey&gt;: abcdef12345</code>
/// Example of query:
/// <code>&lt;QueryKey&gt;=abcdef12345</code>
/// </example>
public string QueryKey { get; set; } = "apikey";
public ISet<string> QueryKeys { get; set; } = new HashSet<string>();

/// <summary>
/// Get or set the key used for the api key, when provided as a request header parameter.
/// Default is <c>X-API-KEY</c>
/// Get or set the allowed keys used for the api key, when provided as a request header parameter.
/// If no keys are added and <see cref="AllowApiKeyInRequestHeader"/> is <c>true</c>, then the value of <see cref="DefaultHeaderKey"/> is added automatically to the set.
/// </summary>
/// <remarks>
/// If more than one key in the headers matches keys in the allowed set of <see cref="HeaderKeys"/>, then all of the matched keys in the headers are used for validation.
/// </remarks>
/// <example>
/// Example of header, if the <see cref="HeaderKey"/> is <c>X-API-KEY</c>:
/// Example of header, if the <see cref="HeaderKeys"/> contains <c>X-API-KEY</c>:
/// <code>X-API-KEY: abcdef12345</code>
/// </example>
public string HeaderKey { get; set; } = "X-API-KEY";
public ISet<string> HeaderKeys { get; set; } = new HashSet<string>();

/// <summary>
/// If <c>true</c>, then the standard <c>Authorization</c> header key is used in combination with <see cref="AuthorizationSchemeInHeader"/>.
/// Requires that <see cref="AllowApiKeyInRequestHeader"/> is <c>true</c>.
/// If this is set to <c>true</c>, then the value in <see cref="HeaderKey"/> is ignored.
/// If this is set to <c>true</c>, then the values in <see cref="HeaderKeys"/> is ignored.
/// Default is <c>false</c>
/// </summary>
/// <example>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
namespace Dnmh.Security.ApiKeyAuthentication.AuthenticationHandler.Internal;

/// <summary>
/// Extensions methods for <see cref="ApiKeyAuthenticationOptions"/>
/// </summary>
internal static class ApiKeyAuthenticationOptionsExtensions
{
/// <summary>
/// Initializes default values for <see cref="ApiKeyAuthenticationOptions.HeaderKeys"/> and <see cref="ApiKeyAuthenticationOptions.QueryKeys"/>.
/// </summary>
public static void InitializeDefaultValues(this ApiKeyAuthenticationOptions options)
{
if (options.AllowApiKeyInRequestHeader && options.HeaderKeys != null && !options.HeaderKeys.Any())
{
options.HeaderKeys.Add(ApiKeyAuthenticationOptions.DefaultHeaderKey);
}
if (options.AllowApiKeyInQuery && options.QueryKeys != null && !options.QueryKeys.Any())
{
options.QueryKeys.Add(ApiKeyAuthenticationOptions.DefaultQueryKey);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ public ApiKeyAuthenticationOptionsValidator()
.WithMessage($"Setting {nameof(ApiKeyAuthenticationOptions.UseAuthorizationHeaderKey)} to true requires {nameof(ApiKeyAuthenticationOptions.AllowApiKeyInRequestHeader)} to also be true");
RuleFor(x => x.AllowApiKeyInRequestHeader).Must(x => x).DependentRules(() => RuleFor(x => x.UseAuthorizationHeaderKey).Must(x => x)).When(x => x.UseSchemeNameInAuthorizationHeader)
.WithMessage($"Setting {nameof(ApiKeyAuthenticationOptions.UseSchemeNameInAuthorizationHeader)} to true requires {nameof(ApiKeyAuthenticationOptions.AllowApiKeyInRequestHeader)} and {nameof(ApiKeyAuthenticationOptions.UseAuthorizationHeaderKey)} to also be true");
RuleFor(x => x.HeaderKey).NotNull().NotEmpty().When(x => x.AllowApiKeyInRequestHeader)
.WithMessage($"{nameof(ApiKeyAuthenticationOptions.HeaderKey)} must be non-empty when {nameof(ApiKeyAuthenticationOptions.AllowApiKeyInRequestHeader)} is true");
RuleFor(x => x.QueryKey).NotNull().NotEmpty().When(x => x.AllowApiKeyInQuery)
.WithMessage($"{nameof(ApiKeyAuthenticationOptions.QueryKey)} must be non-empty when {nameof(ApiKeyAuthenticationOptions.AllowApiKeyInQuery)} is true");
RuleFor(x => x.HeaderKeys).NotNull().When(x => x.AllowApiKeyInRequestHeader)
.WithMessage($"{nameof(ApiKeyAuthenticationOptions.HeaderKeys)} must be non-empty when {nameof(ApiKeyAuthenticationOptions.AllowApiKeyInRequestHeader)} is true");
RuleFor(x => x.QueryKeys).NotNull().When(x => x.AllowApiKeyInQuery)
.WithMessage($"{nameof(ApiKeyAuthenticationOptions.QueryKeys)} must be non-empty when {nameof(ApiKeyAuthenticationOptions.AllowApiKeyInQuery)} is true");
RuleFor(x => x.AuthorizationSchemeInHeader).NotNull().NotEmpty().When(x => x.UseAuthorizationHeaderKey && !x.UseSchemeNameInAuthorizationHeader)
.WithMessage($"{nameof(ApiKeyAuthenticationOptions.AuthorizationSchemeInHeader)} must be non-empty when {nameof(ApiKeyAuthenticationOptions.UseAuthorizationHeaderKey)} is true and {nameof(ApiKeyAuthenticationOptions.UseSchemeNameInAuthorizationHeader)} is false");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public PostConfigureSwaggerAuthorization(string authenticationScheme, IOptionsMo
public void PostConfigure(string? name, SwaggerGenOptions options)
{
var parameterLocation = _authenticationOptions.AllowApiKeyInQuery ? ParameterLocation.Query : ParameterLocation.Header;
var keyName = parameterLocation == ParameterLocation.Query ? _authenticationOptions.QueryKey : _authenticationOptions.HeaderKey;
var keyName = parameterLocation == ParameterLocation.Query ? _authenticationOptions.QueryKeys.First() : _authenticationOptions.HeaderKeys.First();
var scheme = _authenticationOptions.UseSchemeNameInAuthorizationHeader ? _swaggerSchemeOptions.AuthenticationScheme : _authenticationOptions.AuthorizationSchemeInHeader;
// Setup the security definition
options.AddSecurityDefinition(keyName, new OpenApiSecurityScheme
Expand Down
7 changes: 6 additions & 1 deletion Source/Dnmh.Security.ApiKeyAuthentication.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageProjectUrl>https://github.com/DetNordjyskeMediehus/Dnmh.Security.ApiKeyAuthentication</PackageProjectUrl>
<Title>.Net ApiKey Authentication</Title>
<Version>2.0.1</Version>
<Version>3.0.0</Version>
<PackageReadmeFile>README.md</PackageReadmeFile>
<Description>A .NET Core library that provides API key authentication for your web applications. With this library, you can require API keys to access your API endpoints and secure your application against unauthorized access. The library can also be integrated with Swagger UI to provide a seamless authentication experience.</Description>
<PackageTags>authentication dotnet .Net dotnetcore .NetCore apikey apikey-authentication swagger swagger-ui</PackageTags>
Expand All @@ -29,4 +29,9 @@
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
</ItemGroup>

<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>$(AssemblyName).Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
</Project>
Loading

0 comments on commit 140b83a

Please sign in to comment.