82a856b61e
- The metrics scheme skips its dashboard-cookie fallback while Dashboard:DisableLogin is on, so the dev auto-login handler cannot satisfy RequireAuthentication=true. - Invoke logs client_correlation_id <-> worker envelope id once at Debug. - MetricsEndpointAuthenticationTests drives the real pipeline: anonymous loopback 401 (no /login redirect), Viewer cookie 200, admin key 200, non-admin key 403, DisableLogin 401, /health/active 200. - Docs: DisableLogin behaviour; the /metrics limiter partition is per IP vs gRPC per connection, so only the per-key aggregate is shared.
223 lines
9.9 KiB
C#
223 lines
9.9 KiB
C#
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.Configuration;
|
|
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. The limiter's per-peer partition here is the remote IP, whereas the gRPC
|
|
/// interceptor's is the gRPC peer string (address and port, i.e. per connection), so the two paths
|
|
/// share only the per-key-id aggregate layer.
|
|
/// </remarks>
|
|
public sealed class MetricsAuthenticationHandler(
|
|
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
|
ILoggerFactory logger,
|
|
UrlEncoder encoder,
|
|
IApiKeyVerifier apiKeyVerifier,
|
|
ApiKeyFailureLimiter failureLimiter,
|
|
IAuthenticationSchemeProvider schemeProvider,
|
|
IOptions<GatewayOptions> gatewayOptions,
|
|
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()
|
|
{
|
|
// Dashboard:DisableLogin registers an always-succeeding auto-login handler under the cookie
|
|
// scheme name. Falling back to it would let every anonymous request satisfy /metrics, so
|
|
// with DisableLogin on, RequireAuthentication=true accepts API keys only.
|
|
if (gatewayOptions.Value.Dashboard.DisableLogin)
|
|
{
|
|
return AuthenticateResult.NoResult();
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|