feat(security): opt-in authentication for /metrics (SEC-14)
New MxGateway:Metrics:RequireAuthentication (default false). When true, /metrics requires an API key with the admin scope (verified through the cached verifier and the shared API-key failure limiter) or a dashboard login with a real dashboard role; anonymous localhost and Authentication:Mode=Disabled do not qualify. An anonymous scrape gets a plain 401 Bearer challenge, a throttled peer 429. /health stays anonymous because the off-repo Overview dashboard polls /health/active. MapZbMetrics returns IEndpointConventionBuilder, so the policy is attached with RequireAuthorization.
This commit is contained in:
@@ -52,4 +52,7 @@ public sealed class GatewayOptions
|
||||
/// login / API-key rate limiting).
|
||||
/// </summary>
|
||||
public SecurityOptions Security { get; init; } = new();
|
||||
|
||||
/// <summary>Gets Prometheus <c>/metrics</c> endpoint options.</summary>
|
||||
public MetricsOptions Metrics { get; init; } = new();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
|
||||
/// <summary>Options for the Prometheus <c>/metrics</c> scrape endpoint (<c>MxGateway:Metrics</c>).</summary>
|
||||
public sealed class MetricsOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether <c>/metrics</c> requires an authenticated caller. When
|
||||
/// <see langword="true"/>, a scrape must present either an API key carrying the <c>admin</c>
|
||||
/// scope (<c>Authorization: Bearer mxgw_…</c>) or a dashboard login cookie holding a real
|
||||
/// dashboard role; anonymous localhost access does not qualify. Default <see langword="false"/>
|
||||
/// keeps the endpoint anonymous for existing scrapers. The <c>/health</c> endpoints stay
|
||||
/// anonymous either way.
|
||||
/// </summary>
|
||||
public bool RequireAuthentication { get; init; }
|
||||
}
|
||||
@@ -113,6 +113,7 @@ public static class GatewayApplication
|
||||
builder.Services.AddZbSecrets(builder.Configuration, "Secrets");
|
||||
builder.Services.AddSqliteAuthStore(builder.Configuration);
|
||||
builder.Services.AddGatewayGrpcAuthorization();
|
||||
builder.Services.AddGatewayMetricsAuthorization();
|
||||
AddLoginRateLimiter(builder);
|
||||
builder.Services.AddHealthChecks()
|
||||
.AddTypeActivatedCheck<AuthStoreHealthCheck>(
|
||||
@@ -350,8 +351,19 @@ public static class GatewayApplication
|
||||
{
|
||||
endpoints.MapStaticAssets(ResolveStaticAssetsManifestPath());
|
||||
|
||||
// /health stays anonymous unconditionally: an off-repo Overview dashboard polls
|
||||
// /health/active without credentials.
|
||||
endpoints.MapZbHealth();
|
||||
endpoints.MapZbMetrics();
|
||||
IEndpointConventionBuilder metricsEndpoint = endpoints.MapZbMetrics();
|
||||
// Read from configuration rather than IOptions<GatewayOptions>: resolving the options here
|
||||
// would run whole-options validation during Build instead of at StartAsync.
|
||||
if (endpoints.ServiceProvider
|
||||
.GetRequiredService<IConfiguration>()
|
||||
.GetValue<bool>("MxGateway:Metrics:RequireAuthentication"))
|
||||
{
|
||||
// Opt-in (SEC-14): an admin-scoped API key or a dashboard login with a real role.
|
||||
metricsEndpoint.RequireAuthorization(MetricsAuthorizationDefaults.Policy);
|
||||
}
|
||||
|
||||
endpoints.MapGrpcService<MxAccessGatewayService>();
|
||||
endpoints.MapZbGalaxyRepository();
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
using ZB.MOM.WW.MxGateway.Server.Metrics;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
||||
|
||||
/// <summary>Names shared by the optional <c>/metrics</c> authentication (<c>MxGateway:Metrics:RequireAuthentication</c>).</summary>
|
||||
public static class MetricsAuthorizationDefaults
|
||||
{
|
||||
/// <summary>Authentication scheme that authenticates a metrics scrape.</summary>
|
||||
public const string AuthenticationScheme = "MxGateway.Metrics";
|
||||
|
||||
/// <summary>Authorization policy attached to <c>/metrics</c> when authentication is required.</summary>
|
||||
public const string Policy = "MxGateway.Metrics";
|
||||
|
||||
/// <summary>Claim type carrying one API-key scope on a principal the metrics scheme authenticated.</summary>
|
||||
public const string ScopeClaimType = "mxgateway:scope";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates a <c>/metrics</c> scrape from either credential the gateway already issues: an API
|
||||
/// key presented as <c>Authorization: Bearer mxgw_…</c> (verified through the same cached
|
||||
/// <see cref="IApiKeyVerifier"/> and per-peer failure limiter as gRPC calls), or, when no
|
||||
/// authorization header is sent, the dashboard login cookie.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// One scheme fronting both credentials keeps the challenge a plain <c>401</c> with
|
||||
/// <c>WWW-Authenticate: Bearer</c>, which is what a Prometheus scraper understands; listing the
|
||||
/// cookie scheme on the policy directly would turn an anonymous scrape into a 302 redirect to the
|
||||
/// login page. A throttled key partition answers <c>429</c>, mirroring the gRPC
|
||||
/// <c>ResourceExhausted</c>. Failure reasons are never echoed to the caller and the presented
|
||||
/// secret is never logged.
|
||||
/// </remarks>
|
||||
public sealed class MetricsAuthenticationHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder,
|
||||
IApiKeyVerifier apiKeyVerifier,
|
||||
ApiKeyFailureLimiter failureLimiter,
|
||||
IAuthenticationSchemeProvider schemeProvider,
|
||||
GatewayMetrics metrics)
|
||||
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
||||
{
|
||||
private bool _throttled;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
string? authorizationHeader = Request.Headers.Authorization;
|
||||
if (string.IsNullOrWhiteSpace(authorizationHeader))
|
||||
{
|
||||
return await AuthenticateDashboardCookieAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Same throttle as the gRPC interceptor: checked before the store read so online guessing
|
||||
// against /metrics cannot spend a verification per attempt or bypass the gRPC limiter.
|
||||
ApiKeyThrottlePartition partition = new(
|
||||
Context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
||||
GatewayGrpcAuthorizationInterceptor.TryResolveKeyId(authorizationHeader));
|
||||
ApiKeyThrottleDecision decision = failureLimiter.Check(
|
||||
partition,
|
||||
out ApiKeyFailureLimiter.PartitionResolution resolution);
|
||||
if (decision is ApiKeyThrottleDecision.ThrottledByPeer or ApiKeyThrottleDecision.ThrottledByAggregate)
|
||||
{
|
||||
_throttled = true;
|
||||
metrics.RecordAuthThrottled(decision == ApiKeyThrottleDecision.ThrottledByPeer ? "peer" : "aggregate");
|
||||
return AuthenticateResult.Fail("Too many authentication attempts.");
|
||||
}
|
||||
|
||||
ApiKeyVerification verification = await apiKeyVerifier
|
||||
.VerifyAsync(authorizationHeader, Context.RequestAborted)
|
||||
.ConfigureAwait(false);
|
||||
if (!verification.Succeeded || verification.Identity is null)
|
||||
{
|
||||
failureLimiter.RecordFailure(partition);
|
||||
return AuthenticateResult.Fail("Missing or invalid API key.");
|
||||
}
|
||||
|
||||
failureLimiter.Reset(partition, resolution);
|
||||
|
||||
List<Claim> claims = [new Claim(ClaimTypes.Name, verification.Identity.KeyId)];
|
||||
foreach (string scope in verification.Identity.Scopes)
|
||||
{
|
||||
claims.Add(new Claim(MetricsAuthorizationDefaults.ScopeClaimType, scope));
|
||||
}
|
||||
|
||||
ClaimsPrincipal principal = new(new ClaimsIdentity(claims, Scheme.Name));
|
||||
return AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
|
||||
{
|
||||
if (_throttled)
|
||||
{
|
||||
Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
Response.Headers.WWWAuthenticate = "Bearer";
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// No authorization header: fall back to the dashboard login cookie, so an operator already
|
||||
// signed in to the dashboard can open /metrics in the same browser.
|
||||
private async Task<AuthenticateResult> AuthenticateDashboardCookieAsync()
|
||||
{
|
||||
if (await schemeProvider.GetSchemeAsync(DashboardAuthenticationDefaults.AuthenticationScheme).ConfigureAwait(false)
|
||||
is null)
|
||||
{
|
||||
return AuthenticateResult.NoResult();
|
||||
}
|
||||
|
||||
AuthenticateResult cookie = await Context
|
||||
.AuthenticateAsync(DashboardAuthenticationDefaults.AuthenticationScheme)
|
||||
.ConfigureAwait(false);
|
||||
return cookie.Succeeded && cookie.Principal is not null
|
||||
? AuthenticateResult.Success(new AuthenticationTicket(cookie.Principal, Scheme.Name))
|
||||
: AuthenticateResult.NoResult();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Requirement for <c>/metrics</c> access; see <see cref="MetricsAccessHandler"/>.</summary>
|
||||
public sealed class MetricsAccessRequirement : IAuthorizationRequirement
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Grants <c>/metrics</c> to an API key carrying the <c>admin</c> scope or to a dashboard login
|
||||
/// holding a real dashboard role (Administrator or Viewer). It reads no options, so neither
|
||||
/// anonymous localhost nor <c>Authentication:Mode=Disabled</c> can satisfy it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A Viewer login qualifies because every dashboard role already sees the same aggregate counters
|
||||
/// on the dashboard itself; the metrics carry no per-session or per-tag data. An API key needs
|
||||
/// <c>admin</c> because metrics are an operator surface, not one of the data-plane scopes a client
|
||||
/// key is normally issued with.
|
||||
/// </remarks>
|
||||
public sealed class MetricsAccessHandler : AuthorizationHandler<MetricsAccessRequirement>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override Task HandleRequirementAsync(
|
||||
AuthorizationHandlerContext context,
|
||||
MetricsAccessRequirement requirement)
|
||||
{
|
||||
foreach (ClaimsIdentity identity in context.User.Identities)
|
||||
{
|
||||
if (!identity.IsAuthenticated)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Scope claims count only on an identity the metrics scheme minted from a verified key.
|
||||
bool isApiKeyIdentity = string.Equals(
|
||||
identity.AuthenticationType,
|
||||
MetricsAuthorizationDefaults.AuthenticationScheme,
|
||||
StringComparison.Ordinal);
|
||||
if (isApiKeyIdentity
|
||||
&& identity.HasClaim(MetricsAuthorizationDefaults.ScopeClaimType, GatewayScopes.Admin))
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
if (!isApiKeyIdentity
|
||||
&& (identity.HasClaim(identity.RoleClaimType, DashboardRoles.Admin)
|
||||
|| identity.HasClaim(identity.RoleClaimType, DashboardRoles.Viewer)))
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Registration for the optional <c>/metrics</c> authentication.</summary>
|
||||
public static class MetricsAuthorizationServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers the metrics authentication scheme, the <see cref="MetricsAuthorizationDefaults.Policy"/>
|
||||
/// policy and its handler. Registration is unconditional and inert: the policy only takes effect
|
||||
/// when <c>MxGateway:Metrics:RequireAuthentication</c> attaches it to the endpoint.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection to register into.</param>
|
||||
/// <returns>The same service collection, for chaining.</returns>
|
||||
public static IServiceCollection AddGatewayMetricsAuthorization(this IServiceCollection services)
|
||||
{
|
||||
services.AddAuthentication()
|
||||
.AddScheme<AuthenticationSchemeOptions, MetricsAuthenticationHandler>(
|
||||
MetricsAuthorizationDefaults.AuthenticationScheme,
|
||||
_ => { });
|
||||
services.AddAuthorizationBuilder()
|
||||
.AddPolicy(
|
||||
MetricsAuthorizationDefaults.Policy,
|
||||
policy => policy
|
||||
.AddAuthenticationSchemes(MetricsAuthorizationDefaults.AuthenticationScheme)
|
||||
.AddRequirements(new MetricsAccessRequirement()));
|
||||
services.AddSingleton<IAuthorizationHandler, MetricsAccessHandler>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,70 @@ public sealed class GatewayApplicationTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// With <c>MxGateway:Metrics:RequireAuthentication=true</c> an anonymous scrape is refused with a
|
||||
/// plain 401 Bearer challenge (not a login redirect), while <c>/health/active</c> — polled by the
|
||||
/// off-repo Overview dashboard without credentials — stays anonymous.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Build_WhenMetricsRequireAuthentication_RefusesAnonymousScrapeButNotHealth()
|
||||
{
|
||||
await using WebApplication app = GatewayApplication.Build(
|
||||
["--urls=http://127.0.0.1:0", "--MxGateway:Metrics:RequireAuthentication=true"]);
|
||||
await app.StartAsync();
|
||||
try
|
||||
{
|
||||
using HttpClient client = new() { BaseAddress = new Uri(app.Urls.First()) };
|
||||
|
||||
using HttpResponseMessage metrics = await client.GetAsync("/metrics");
|
||||
using HttpRequestMessage badKeyRequest = new(HttpMethod.Get, "/metrics");
|
||||
badKeyRequest.Headers.TryAddWithoutValidation("Authorization", "Bearer mxgw_nobody_not-a-real-secret");
|
||||
using HttpResponseMessage badKey = await client.SendAsync(badKeyRequest);
|
||||
using HttpResponseMessage health = await client.GetAsync("/health/active");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, metrics.StatusCode);
|
||||
Assert.Contains(metrics.Headers.WwwAuthenticate, value => value.Scheme == "Bearer");
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, badKey.StatusCode);
|
||||
Assert.NotEqual(HttpStatusCode.Unauthorized, health.StatusCode);
|
||||
Assert.NotEqual(HttpStatusCode.Forbidden, health.StatusCode);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await app.StopAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The dashboard-login path: with dev-only <c>Dashboard:DisableLogin</c> every request carries
|
||||
/// the auto-login Administrator principal under the dashboard cookie scheme, which the metrics
|
||||
/// scheme accepts when no authorization header is sent.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Build_WhenMetricsRequireAuthentication_AcceptsDashboardLogin()
|
||||
{
|
||||
await using WebApplication app = GatewayApplication.Build(
|
||||
[
|
||||
"--urls=http://127.0.0.1:0",
|
||||
"--MxGateway:Metrics:RequireAuthentication=true",
|
||||
"--MxGateway:Dashboard:DisableLogin=true",
|
||||
]);
|
||||
await app.StartAsync();
|
||||
try
|
||||
{
|
||||
using HttpClient client = new() { BaseAddress = new Uri(app.Urls.First()) };
|
||||
|
||||
using HttpResponseMessage response = await client.GetAsync("/metrics");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await app.StopAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Build maps dashboard and authentication endpoints when the dashboard is enabled.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
|
||||
-1
@@ -1078,7 +1078,6 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// <summary>An <see cref="IAsyncStreamReader{T}"/> that yields no messages.</summary>
|
||||
/// <typeparam name="T">The streamed message type.</typeparam>
|
||||
private sealed class EmptyStreamReader<T> : IAsyncStreamReader<T>
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
using ZB.MOM.WW.MxGateway.Server.Metrics;
|
||||
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
||||
|
||||
using LibApiKeyIdentity = ZB.MOM.WW.Auth.Abstractions.ApiKeys.ApiKeyIdentity;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Security.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// The optional <c>/metrics</c> authentication (<c>MxGateway:Metrics:RequireAuthentication</c>,
|
||||
/// SEC-14): the scheme that authenticates a scrape and the requirement that authorizes it.
|
||||
/// </summary>
|
||||
public sealed class MetricsAuthorizationTests
|
||||
{
|
||||
private const string AdminHeader = "Bearer mxgw_scraper_secret";
|
||||
|
||||
/// <summary>A verified API key authenticates and carries its scopes as claims.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Authenticate_WithValidApiKey_SucceedsWithScopeClaims()
|
||||
{
|
||||
CountingVerifier verifier = new(Success(GatewayScopes.Admin));
|
||||
DefaultHttpContext httpContext = ContextWithAuthorization(AdminHeader);
|
||||
|
||||
AuthenticateResult result = await AuthenticateAsync(verifier, httpContext);
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.Equal("scraper", result.Principal!.Identity!.Name);
|
||||
Assert.True(result.Principal.HasClaim(MetricsAuthorizationDefaults.ScopeClaimType, GatewayScopes.Admin));
|
||||
Assert.Equal(AdminHeader, verifier.LastHeader);
|
||||
}
|
||||
|
||||
/// <summary>An invalid key fails authentication with an opaque reason.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Authenticate_WithInvalidApiKey_Fails()
|
||||
{
|
||||
CountingVerifier verifier = new(new ApiKeyVerification(false, null, ApiKeyFailure.SecretMismatch));
|
||||
|
||||
AuthenticateResult result = await AuthenticateAsync(verifier, ContextWithAuthorization(AdminHeader));
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.NotNull(result.Failure);
|
||||
Assert.DoesNotContain("secret", result.Failure!.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Repeated bad keys from one peer trip the shared failure limiter, after which the verifier is
|
||||
/// no longer reached and the challenge answers 429 — /metrics is not a bypass of the gRPC throttle.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Authenticate_AfterRepeatedFailures_IsThrottledBeforeVerification()
|
||||
{
|
||||
CountingVerifier verifier = new(new ApiKeyVerification(false, null, ApiKeyFailure.SecretMismatch));
|
||||
ApiKeyFailureLimiter limiter = new(
|
||||
limit: 2,
|
||||
TimeSpan.FromMinutes(1),
|
||||
maxPartitions: 16,
|
||||
aggregateLimit: 0,
|
||||
probeInterval: TimeSpan.Zero,
|
||||
TimeProvider.System);
|
||||
|
||||
for (int attempt = 0; attempt < 2; attempt++)
|
||||
{
|
||||
await AuthenticateAsync(verifier, ContextWithAuthorization(AdminHeader), limiter);
|
||||
}
|
||||
|
||||
DefaultHttpContext throttled = ContextWithAuthorization(AdminHeader);
|
||||
MetricsAuthenticationHandler handler = await CreateHandlerAsync(verifier, throttled, limiter);
|
||||
AuthenticateResult result = await handler.AuthenticateAsync();
|
||||
await handler.ChallengeAsync(new AuthenticationProperties());
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Equal(2, verifier.CallCount);
|
||||
Assert.Equal(StatusCodes.Status429TooManyRequests, throttled.Response.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>An anonymous scrape is challenged with a plain 401 Bearer, not a login redirect.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Challenge_WithoutCredentials_Returns401Bearer()
|
||||
{
|
||||
DefaultHttpContext httpContext = new();
|
||||
MetricsAuthenticationHandler handler = await CreateHandlerAsync(
|
||||
new CountingVerifier(Success(GatewayScopes.Admin)),
|
||||
httpContext);
|
||||
|
||||
AuthenticateResult result = await handler.AuthenticateAsync();
|
||||
await handler.ChallengeAsync(new AuthenticationProperties());
|
||||
|
||||
Assert.True(result.None);
|
||||
Assert.Equal(StatusCodes.Status401Unauthorized, httpContext.Response.StatusCode);
|
||||
Assert.Equal("Bearer", httpContext.Response.Headers.WWWAuthenticate.ToString());
|
||||
}
|
||||
|
||||
/// <summary>An API key holding the admin scope satisfies the metrics requirement.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Requirement_ApiKeyWithAdminScope_Succeeds()
|
||||
{
|
||||
Assert.True(await EvaluateAsync(ApiKeyPrincipal(GatewayScopes.Admin)));
|
||||
}
|
||||
|
||||
/// <summary>An API key without the admin scope does not satisfy the requirement.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Requirement_ApiKeyWithoutAdminScope_Fails()
|
||||
{
|
||||
Assert.False(await EvaluateAsync(ApiKeyPrincipal(GatewayScopes.EventsRead, GatewayScopes.InvokeRead)));
|
||||
}
|
||||
|
||||
/// <summary>A scope claim counts only on an identity the metrics scheme minted from a verified key.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Requirement_ScopeClaimOnForeignIdentity_Fails()
|
||||
{
|
||||
ClaimsPrincipal principal = new(new ClaimsIdentity(
|
||||
[new Claim(MetricsAuthorizationDefaults.ScopeClaimType, GatewayScopes.Admin)],
|
||||
DashboardAuthenticationDefaults.AuthenticationScheme));
|
||||
|
||||
Assert.False(await EvaluateAsync(principal));
|
||||
}
|
||||
|
||||
/// <summary>A dashboard login holding a real dashboard role satisfies the requirement.</summary>
|
||||
/// <param name="role">The dashboard role.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[InlineData(DashboardRoles.Admin)]
|
||||
[InlineData(DashboardRoles.Viewer)]
|
||||
public async Task Requirement_DashboardLoginWithRole_Succeeds(string role)
|
||||
{
|
||||
ClaimsPrincipal principal = new(new ClaimsIdentity(
|
||||
[new Claim(ClaimTypes.Name, "operator"), new Claim(ClaimTypes.Role, role)],
|
||||
DashboardAuthenticationDefaults.AuthenticationScheme));
|
||||
|
||||
Assert.True(await EvaluateAsync(principal));
|
||||
}
|
||||
|
||||
/// <summary>An unauthenticated principal (anonymous localhost included) never satisfies it.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Requirement_Unauthenticated_Fails()
|
||||
{
|
||||
ClaimsPrincipal principal = new(new ClaimsIdentity([new Claim(ClaimTypes.Role, DashboardRoles.Admin)]));
|
||||
|
||||
Assert.False(await EvaluateAsync(principal));
|
||||
}
|
||||
|
||||
private static async Task<bool> EvaluateAsync(ClaimsPrincipal principal)
|
||||
{
|
||||
MetricsAccessRequirement requirement = new();
|
||||
AuthorizationHandlerContext context = new([requirement], principal, resource: null);
|
||||
await new MetricsAccessHandler().HandleAsync(context);
|
||||
return context.HasSucceeded;
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal ApiKeyPrincipal(params string[] scopes)
|
||||
{
|
||||
List<Claim> claims = [new Claim(ClaimTypes.Name, "scraper")];
|
||||
claims.AddRange(scopes.Select(scope => new Claim(MetricsAuthorizationDefaults.ScopeClaimType, scope)));
|
||||
return new ClaimsPrincipal(new ClaimsIdentity(claims, MetricsAuthorizationDefaults.AuthenticationScheme));
|
||||
}
|
||||
|
||||
private static async Task<AuthenticateResult> AuthenticateAsync(
|
||||
IApiKeyVerifier verifier,
|
||||
HttpContext httpContext,
|
||||
ApiKeyFailureLimiter? limiter = null)
|
||||
{
|
||||
MetricsAuthenticationHandler handler = await CreateHandlerAsync(verifier, httpContext, limiter);
|
||||
return await handler.AuthenticateAsync();
|
||||
}
|
||||
|
||||
private static async Task<MetricsAuthenticationHandler> CreateHandlerAsync(
|
||||
IApiKeyVerifier verifier,
|
||||
HttpContext httpContext,
|
||||
ApiKeyFailureLimiter? limiter = null)
|
||||
{
|
||||
MetricsAuthenticationHandler handler = new(
|
||||
new StaticOptionsMonitor(),
|
||||
NullLoggerFactory.Instance,
|
||||
UrlEncoder.Default,
|
||||
verifier,
|
||||
limiter ?? new ApiKeyFailureLimiter(1000, TimeSpan.FromMinutes(1), 1024, 0, TimeSpan.Zero, TimeProvider.System),
|
||||
new AuthenticationSchemeProvider(Options.Create(new AuthenticationOptions())),
|
||||
new GatewayMetrics());
|
||||
await handler.InitializeAsync(
|
||||
new AuthenticationScheme(
|
||||
MetricsAuthorizationDefaults.AuthenticationScheme,
|
||||
displayName: null,
|
||||
typeof(MetricsAuthenticationHandler)),
|
||||
httpContext);
|
||||
return handler;
|
||||
}
|
||||
|
||||
private static DefaultHttpContext ContextWithAuthorization(string header)
|
||||
{
|
||||
DefaultHttpContext context = new();
|
||||
context.Request.Headers.Authorization = header;
|
||||
context.Connection.RemoteIpAddress = System.Net.IPAddress.Parse("198.51.100.7");
|
||||
return context;
|
||||
}
|
||||
|
||||
private static ApiKeyVerification Success(params string[] scopes) => new(
|
||||
Succeeded: true,
|
||||
Identity: new LibApiKeyIdentity(
|
||||
KeyId: "scraper",
|
||||
DisplayName: "Scraper",
|
||||
Scopes: new HashSet<string>(scopes, StringComparer.Ordinal),
|
||||
Constraints: null),
|
||||
Failure: null);
|
||||
|
||||
private sealed class CountingVerifier(ApiKeyVerification result) : IApiKeyVerifier
|
||||
{
|
||||
/// <summary>Gets the number of verifications performed.</summary>
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
/// <summary>Gets the last authorization header presented.</summary>
|
||||
public string? LastHeader { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ApiKeyVerification> VerifyAsync(string authorizationHeader, CancellationToken ct)
|
||||
{
|
||||
CallCount++;
|
||||
LastHeader = authorizationHeader;
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StaticOptionsMonitor : IOptionsMonitor<AuthenticationSchemeOptions>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public AuthenticationSchemeOptions CurrentValue { get; } = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public AuthenticationSchemeOptions Get(string? name) => CurrentValue;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IDisposable? OnChange(Action<AuthenticationSchemeOptions, string?> listener) => null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user