fix(security): validate the API-key pepper at start, finish JWT bearer, circuit-aware audit actor
- ApiKeyOptionsValidator (ValidateOnStart on any driver/admin node): a blank or placeholder Security:ApiKeys:Pepper fails host start instead of rejecting every key after a green boot (R03:2.3). - JWT is finished and opt-in by key (R03:2.4): with Security:Jwt:SigningKey set, a Bearer scheme (JwtBearerAuthenticationHandler over JwtTokenService.TryValidate / BuildValidationParameters, fail closed) accepts the tokens /auth/token mints; the default scheme is a cookie-or-bearer policy scheme and the fallback policy names it, so /api/* and every RequireAuthorization endpoint take a browser session OR a machine token. JwtOptions are validated at start (JwtOptionsValidator). Without a key, /auth/token is not mapped (404, not the former constructor 500) and no bearer scheme exists. A direct AddJwtBearer needs Microsoft.AspNetCore.Authentication.JwtBearer in Directory.Packages.props (not this lane's file) — the handler is a drop-in to swap. - AuthenticationStateAuditActorAccessor is the registered IAuditActorAccessor: the Blazor circuit's principal first, HttpContext as the fallback (R03:2.5). HttpAuditActorAccessor shares its resolver. - CookieAuthenticationStateProvider (unregistered, fire-and-forget ping loop) is deleted (R03:1.5/2.5). - /auth/login logs the LDAP exception at Warning before answering 503 (R03:2.7). - Admin operations reply with a fixed message + correlation id instead of ex.Message (R03:4.1). Refs #733 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -89,6 +89,18 @@ public sealed class AdminOperationsActor : ReceiveActor
|
||||
Receive<ShelveAlarmCommand>(HandleShelveAlarm);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The reply text for an unexpected server-side failure (R03:4.1): a fixed sentence plus the request's
|
||||
/// correlation id, never <c>ex.Message</c> — a SqlException or UriFormatException message carries
|
||||
/// connection-string fragments and host names, and the reply reaches any ConfigEditor browser session.
|
||||
/// The detail is in the central log under the same correlation id.
|
||||
/// </summary>
|
||||
/// <param name="operation">The admin operation that failed.</param>
|
||||
/// <param name="correlationId">The request's correlation token, as logged alongside the exception.</param>
|
||||
/// <returns>The operator-facing message.</returns>
|
||||
internal static string ServerFault(string operation, object correlationId) =>
|
||||
$"{operation} failed on the server; see the central node log for correlation {correlationId}.";
|
||||
|
||||
/// <summary>
|
||||
/// Hands a central→node command to the mesh transport router, or publishes it directly when
|
||||
/// no router is wired.
|
||||
@@ -136,8 +148,8 @@ public sealed class AdminOperationsActor : ReceiveActor
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Error(ex, "AdminOps: Acknowledge failed for alarm {AlarmId}", msg.AlarmId);
|
||||
replyTo.Tell(new AcknowledgeAlarmResult(false, ex.Message, msg.CorrelationId));
|
||||
_log.Error(ex, "AdminOps: Acknowledge failed for alarm {AlarmId} (correlation {CorrelationId})", msg.AlarmId, msg.CorrelationId);
|
||||
replyTo.Tell(new AcknowledgeAlarmResult(false, ServerFault("Acknowledge", msg.CorrelationId), msg.CorrelationId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,8 +195,8 @@ public sealed class AdminOperationsActor : ReceiveActor
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Error(ex, "AdminOps: Shelve ({Kind}) failed for alarm {AlarmId}", msg.Kind, msg.AlarmId);
|
||||
replyTo.Tell(new ShelveAlarmResult(false, ex.Message, msg.CorrelationId));
|
||||
_log.Error(ex, "AdminOps: Shelve ({Kind}) failed for alarm {AlarmId} (correlation {CorrelationId})", msg.Kind, msg.AlarmId, msg.CorrelationId);
|
||||
replyTo.Tell(new ShelveAlarmResult(false, ServerFault("Shelve", msg.CorrelationId), msg.CorrelationId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,12 +249,12 @@ public sealed class AdminOperationsActor : ReceiveActor
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Error(ex, "StartDeployment failed for {CreatedBy}", msg.CreatedBy);
|
||||
_log.Error(ex, "StartDeployment failed for {CreatedBy} (correlation {CorrelationId})", msg.CreatedBy, msg.CorrelationId);
|
||||
replyTo.Tell(new StartDeploymentResult(
|
||||
StartDeploymentOutcome.Rejected,
|
||||
DeploymentId: null,
|
||||
RevisionHash: null,
|
||||
Message: ex.Message,
|
||||
Message: ServerFault("StartDeployment", msg.CorrelationId),
|
||||
msg.CorrelationId));
|
||||
return;
|
||||
}
|
||||
@@ -269,12 +281,12 @@ public sealed class AdminOperationsActor : ReceiveActor
|
||||
private void HandleDeployComposeFailed(DeployComposeFailed msg)
|
||||
{
|
||||
_composeInFlight = null;
|
||||
_log.Error(msg.Error, "StartDeployment failed for {CreatedBy}", msg.Request.CreatedBy);
|
||||
_log.Error(msg.Error, "StartDeployment failed for {CreatedBy} (correlation {CorrelationId})", msg.Request.CreatedBy, msg.Request.CorrelationId);
|
||||
msg.ReplyTo.Tell(new StartDeploymentResult(
|
||||
StartDeploymentOutcome.Rejected,
|
||||
DeploymentId: null,
|
||||
RevisionHash: null,
|
||||
Message: msg.Error.Message,
|
||||
Message: ServerFault("StartDeployment", msg.Request.CorrelationId),
|
||||
msg.Request.CorrelationId));
|
||||
}
|
||||
|
||||
@@ -513,10 +525,10 @@ public sealed class AdminOperationsActor : ReceiveActor
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Error(ex, "Probe for {DriverType} threw", msg.DriverType);
|
||||
_log.Error(ex, "Probe for {DriverType} threw (correlation {CorrelationId})", msg.DriverType, msg.CorrelationId);
|
||||
return new TestDriverConnectResult(
|
||||
false,
|
||||
ex.Message,
|
||||
ServerFault("Test Connect", msg.CorrelationId),
|
||||
null,
|
||||
msg.CorrelationId);
|
||||
}
|
||||
@@ -548,8 +560,8 @@ public sealed class AdminOperationsActor : ReceiveActor
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Error(ex, "AdminOps: RestartDriver failed for {DriverInstanceId}", msg.DriverInstanceId);
|
||||
replyTo.Tell(new RestartDriverResult(false, ex.Message, msg.CorrelationId));
|
||||
_log.Error(ex, "AdminOps: RestartDriver failed for {DriverInstanceId} (correlation {CorrelationId})", msg.DriverInstanceId, msg.CorrelationId);
|
||||
replyTo.Tell(new RestartDriverResult(false, ServerFault("RestartDriver", msg.CorrelationId), msg.CorrelationId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -578,8 +590,8 @@ public sealed class AdminOperationsActor : ReceiveActor
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Error(ex, "AdminOps: ReconnectDriver failed for {DriverInstanceId}", msg.DriverInstanceId);
|
||||
replyTo.Tell(new ReconnectDriverResult(false, ex.Message, msg.CorrelationId));
|
||||
_log.Error(ex, "AdminOps: ReconnectDriver failed for {DriverInstanceId} (correlation {CorrelationId})", msg.DriverInstanceId, msg.CorrelationId);
|
||||
replyTo.Tell(new ReconnectDriverResult(false, ServerFault("ReconnectDriver", msg.CorrelationId), msg.CorrelationId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using ZB.MOM.WW.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Fail-fast startup validator for <see cref="ApiKeyOptions"/> (R03:2.3). The pepper used to be
|
||||
/// checked only when the first key was verified — <c>ApiKeySecret.Verify</c> throws on an empty one —
|
||||
/// so a site node deployed without <c>Security__ApiKeys__Pepper</c> booted green, passed health,
|
||||
/// applied deployments and denied every machine client with the same opaque "Invalid API key" until
|
||||
/// someone read the log; a central node with a wrong pepper minted keys that could verify nowhere.
|
||||
/// Every other security section already fails host start on a bad value; this one now does too.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Registered for <c>hasDriver || hasAdmin</c>: a driver node needs the pepper to VERIFY keys, an admin
|
||||
/// node to MINT them. The minimum length is a floor against a placeholder (<c>"x"</c>) passing as
|
||||
/// configured, not a strength claim — the pepper is a server-side secret, not a password.
|
||||
/// </remarks>
|
||||
public sealed class ApiKeyOptionsValidator : OptionsValidatorBase<ApiKeyOptions>
|
||||
{
|
||||
/// <summary>The shortest pepper accepted (characters, after trimming).</summary>
|
||||
public const int MinPepperLength = 16;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Validate(ValidationBuilder builder, ApiKeyOptions options)
|
||||
{
|
||||
var pepper = options.Pepper?.Trim() ?? string.Empty;
|
||||
builder.RequireThat(pepper.Length > 0,
|
||||
$"{ApiKeyOptions.SectionName}:Pepper is required on any node with the driver or admin role — " +
|
||||
"supply it via Security__ApiKeys__Pepper or a ${secret:} reference. An empty pepper would reject " +
|
||||
"every API key (driver) or mint keys that can never verify (admin).");
|
||||
builder.RequireThat(pepper.Length == 0 || pepper.Length >= MinPepperLength,
|
||||
$"{ApiKeyOptions.SectionName}:Pepper must be at least {MinPepperLength} characters.");
|
||||
builder.RequireThat(!string.IsNullOrWhiteSpace(options.Username),
|
||||
$"{ApiKeyOptions.SectionName}:Username must not be blank.");
|
||||
builder.RequireThat(options.StoreTimeoutMs >= 0,
|
||||
$"{ApiKeyOptions.SectionName}:StoreTimeoutMs must be >= 0 (0 disables the deadline).");
|
||||
builder.RequireThat(options.StoreCacheSeconds >= 0,
|
||||
$"{ApiKeyOptions.SectionName}:StoreCacheSeconds must be >= 0 (0 disables the cache).");
|
||||
}
|
||||
}
|
||||
@@ -162,10 +162,15 @@ builder.AddZbSerilog(o => o.ServiceName = "otopcua");
|
||||
// central over the fetch-and-cache path (ConfigSource:Mode=FetchAndCache). Registering it
|
||||
// unconditionally would throw on a driver-only node, since AddOtOpcUaConfigDb requires the string.
|
||||
// A fused admin+driver node keeps ConfigDb via its admin role — byte-for-byte unaffected.
|
||||
// bound unconditionally: a driver node needs the pepper to VERIFY keys, an admin node needs it
|
||||
// to MINT them, and getting this wrong on either side is a silent authentication failure rather than a
|
||||
// startup error.
|
||||
builder.Services.Configure<ApiKeyOptions>(builder.Configuration.GetSection(ApiKeyOptions.SectionName));
|
||||
// A driver node needs the pepper to VERIFY keys, an admin node needs it to MINT them, and getting this
|
||||
// wrong on either side used to be a silent authentication failure rather than a startup error (R03:2.3):
|
||||
// ApiKeyOptionsValidator now fails host start on a blank or placeholder pepper wherever either role is
|
||||
// present. A node with neither role (dev-only) has no key path and keeps the plain bind.
|
||||
if (hasDriver || hasAdmin)
|
||||
builder.Services.AddValidatedOptions<ApiKeyOptions, ApiKeyOptionsValidator>(
|
||||
builder.Configuration, ApiKeyOptions.SectionName);
|
||||
else
|
||||
builder.Services.Configure<ApiKeyOptions>(builder.Configuration.GetSection(ApiKeyOptions.SectionName));
|
||||
// Security:Acl:Enabled. Default false; see AclOptions for why the flip must be a config
|
||||
// change rather than a redeploy, and what it swaps rather than adds.
|
||||
builder.Services.Configure<AclOptions>(builder.Configuration.GetSection(AclOptions.SectionName));
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using ZB.MOM.WW.Auth.AspNetCore;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Security.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IAuditActorAccessor"/> that reads the Blazor circuit's principal first (R03:2.5).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="HttpAuditActorAccessor"/> reads <c>IHttpContextAccessor</c>, which is right for a
|
||||
/// minimal-API request but wrong inside a Blazor Server circuit: after the first render there is no
|
||||
/// current <c>HttpContext</c> on the circuit's threads, so every audit row written from an interactive
|
||||
/// page would have been stamped <c>"system"</c>. The circuit's identity lives in the scoped
|
||||
/// <see cref="AuthenticationStateProvider"/>, so that is consulted first and the HTTP context is the
|
||||
/// fallback for non-circuit scopes (where the provider is not registered at all).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>CurrentActor</c> is synchronous by contract; the server-side provider's
|
||||
/// <see cref="AuthenticationStateProvider.GetAuthenticationStateAsync"/> is already completed in a
|
||||
/// circuit, and an incomplete task is treated as "no circuit identity" rather than blocked on.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class AuthenticationStateAuditActorAccessor : IAuditActorAccessor
|
||||
{
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
private readonly AuthenticationStateProvider? _authenticationState;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="AuthenticationStateAuditActorAccessor"/> class.</summary>
|
||||
/// <param name="httpContextAccessor">The HTTP context accessor (minimal-API fallback).</param>
|
||||
/// <param name="authenticationState">The circuit's authentication state, when resolved inside a Blazor circuit.</param>
|
||||
public AuthenticationStateAuditActorAccessor(
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
AuthenticationStateProvider? authenticationState = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(httpContextAccessor);
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
_authenticationState = authenticationState;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? CurrentActor
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_authenticationState is not null)
|
||||
{
|
||||
var task = _authenticationState.GetAuthenticationStateAsync();
|
||||
if (task.IsCompletedSuccessfully)
|
||||
{
|
||||
var actor = ResolveActor(task.Result.User);
|
||||
if (actor is not null) return actor;
|
||||
}
|
||||
}
|
||||
return ResolveActor(_httpContextAccessor.HttpContext?.User);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The claim-resolution rule shared with <see cref="HttpAuditActorAccessor"/>.</summary>
|
||||
/// <param name="user">The principal, or null.</param>
|
||||
/// <returns>The actor string, or null when unauthenticated.</returns>
|
||||
internal static string? ResolveActor(ClaimsPrincipal? user)
|
||||
{
|
||||
if (user?.Identity?.IsAuthenticated != true)
|
||||
return null;
|
||||
|
||||
// Prefer the canonical login-name claim; fall back to the Name claim or
|
||||
// Identity.Name (both of which map to ClaimTypes.Name / ZbClaimTypes.Name).
|
||||
return user.FindFirst(ZbClaimTypes.Username)?.Value
|
||||
?? user.FindFirst(ZbClaimTypes.Name)?.Value
|
||||
?? user.Identity.Name;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using ZB.MOM.WW.Auth.AspNetCore;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Security.Audit;
|
||||
|
||||
@@ -17,10 +16,10 @@ namespace ZB.MOM.WW.OtOpcUa.Security.Audit;
|
||||
/// <see cref="System.Security.Principal.IIdentity.Name"/>, in that order.</item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Registered as <b>scoped</b> in <see cref="ZB.MOM.WW.OtOpcUa.Security.ServiceCollectionExtensions.AddOtOpcUaAuth"/>
|
||||
/// so that it correctly follows the request scope used by Blazor Server interactive components
|
||||
/// and minimal-API endpoints. <c>IHttpContextAccessor</c> is registered by
|
||||
/// <c>AddOtOpcUaAuth</c> via <c>services.AddHttpContextAccessor()</c>.
|
||||
/// No longer the registered implementation: <see cref="AuthenticationStateAuditActorAccessor"/> is, because
|
||||
/// a Blazor circuit has no <c>HttpContext</c> after its first render and this accessor would stamp every row
|
||||
/// written from an interactive page <c>"system"</c>. Kept as the explicit HTTP-only variant for non-circuit
|
||||
/// hosts and tests.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class HttpAuditActorAccessor : IAuditActorAccessor
|
||||
@@ -35,19 +34,6 @@ public sealed class HttpAuditActorAccessor : IAuditActorAccessor
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? CurrentActor
|
||||
{
|
||||
get
|
||||
{
|
||||
var user = _httpContextAccessor.HttpContext?.User;
|
||||
if (user?.Identity?.IsAuthenticated != true)
|
||||
return null;
|
||||
|
||||
// Prefer the canonical login-name claim; fall back to the Name claim or
|
||||
// Identity.Name (both of which map to ClaimTypes.Name / ZbClaimTypes.Name).
|
||||
return user.FindFirst(ZbClaimTypes.Username)?.Value
|
||||
?? user.FindFirst(ZbClaimTypes.Name)?.Value
|
||||
?? user.Identity.Name;
|
||||
}
|
||||
}
|
||||
public string? CurrentActor =>
|
||||
AuthenticationStateAuditActorAccessor.ResolveActor(_httpContextAccessor.HttpContext?.User);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
using System.Text.Encodings.Web;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Jwt;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Security.Auth;
|
||||
|
||||
/// <summary>The authentication scheme names OtOpcUa registers.</summary>
|
||||
public static class OtOpcUaAuthSchemes
|
||||
{
|
||||
/// <summary>
|
||||
/// The bearer scheme that validates the HS256 tokens <c>/auth/token</c> mints, through
|
||||
/// <see cref="JwtTokenService.BuildValidationParameters"/>. Registered only when
|
||||
/// <see cref="JwtOptions.IsConfigured"/>.
|
||||
/// </summary>
|
||||
public const string Bearer = "Bearer";
|
||||
|
||||
/// <summary>
|
||||
/// The default scheme: a policy scheme that forwards to <see cref="Bearer"/> when the request
|
||||
/// carries an <c>Authorization: Bearer</c> header and to the cookie scheme otherwise, so one
|
||||
/// <c>RequireAuthorization()</c> / policy accepts a browser session AND a machine token.
|
||||
/// </summary>
|
||||
public const string CookieOrBearer = "OtOpcUa.CookieOrBearer";
|
||||
|
||||
/// <summary>Whether the request presents a bearer token (the selector's whole rule).</summary>
|
||||
/// <param name="context">The request.</param>
|
||||
/// <returns><see langword="true"/> when an <c>Authorization: Bearer …</c> header is present.</returns>
|
||||
public static bool HasBearerToken(HttpContext context) =>
|
||||
TryGetBearerToken(context.Request.Headers.Authorization, out _);
|
||||
|
||||
internal static bool TryGetBearerToken(StringValues authorization, out string token)
|
||||
{
|
||||
token = string.Empty;
|
||||
var header = authorization.ToString();
|
||||
const string prefix = "Bearer ";
|
||||
if (!header.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return false;
|
||||
token = header[prefix.Length..].Trim();
|
||||
return token.Length > 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates an <c>Authorization: Bearer</c> token minted by <c>/auth/token</c> (R03:2.4). The JWT
|
||||
/// feature used to be a dead end: tokens were issued and nothing in the product accepted them. This
|
||||
/// handler is the consuming side, built on <see cref="JwtTokenService.TryValidate"/> so the validation
|
||||
/// parameters can never drift from the minting side (<see cref="JwtTokenService.BuildValidationParameters"/>
|
||||
/// sets <c>RoleClaimType</c>/<c>NameClaimType</c> to the minted keys, so <c>[Authorize(Roles)]</c> and
|
||||
/// <c>IsInRole</c> resolve).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Fail closed: no header ⇒ <c>NoResult</c> (the cookie scheme is not consulted by this handler — the
|
||||
/// policy scheme selects one or the other); a malformed, expired, tampered or wrong-issuer token ⇒
|
||||
/// <c>Fail</c>, never a fall-through to the cookie. A direct <c>AddJwtBearer</c> needs the
|
||||
/// <c>Microsoft.AspNetCore.Authentication.JwtBearer</c> package, which the central package list does
|
||||
/// not carry; this handler is ~40 lines over the already-referenced <c>System.IdentityModel.Tokens.Jwt</c>
|
||||
/// and is a drop-in to replace once that package is pinned.
|
||||
/// </remarks>
|
||||
public sealed class JwtBearerAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
private readonly JwtTokenService _jwt;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="JwtBearerAuthenticationHandler"/> class.</summary>
|
||||
/// <param name="options">The scheme options monitor.</param>
|
||||
/// <param name="logger">The logger factory.</param>
|
||||
/// <param name="encoder">The URL encoder.</param>
|
||||
/// <param name="jwt">The token service whose validation parameters mirror its minting side.</param>
|
||||
public JwtBearerAuthenticationHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder,
|
||||
JwtTokenService jwt)
|
||||
: base(options, logger, encoder)
|
||||
{
|
||||
_jwt = jwt;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
if (!OtOpcUaAuthSchemes.TryGetBearerToken(Request.Headers.Authorization, out var token))
|
||||
return Task.FromResult(AuthenticateResult.NoResult());
|
||||
|
||||
if (!_jwt.TryValidate(token, out var principal) || principal is null)
|
||||
return Task.FromResult(AuthenticateResult.Fail("Invalid bearer token."));
|
||||
|
||||
return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name)));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
|
||||
{
|
||||
Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
Response.Headers.WWWAuthenticate = OtOpcUaAuthSchemes.Bearer;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Security.Blazor;
|
||||
|
||||
/// <summary>
|
||||
/// Blazor Server <see cref="AuthenticationStateProvider"/> that snapshots the cookie-backed
|
||||
/// principal supplied at circuit boot and polls <c>/auth/ping</c> every 60 seconds to detect
|
||||
/// expiry. Mirrors ScadaLink's CentralUI implementation.
|
||||
/// </summary>
|
||||
public sealed class CookieAuthenticationStateProvider : AuthenticationStateProvider, IAsyncDisposable
|
||||
{
|
||||
private static readonly TimeSpan PingInterval = TimeSpan.FromSeconds(60);
|
||||
|
||||
private readonly HttpClient _http;
|
||||
private readonly ILogger<CookieAuthenticationStateProvider> _logger;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private ClaimsPrincipal _current;
|
||||
private Task? _pingLoop;
|
||||
|
||||
/// <summary>Initializes a new instance of the CookieAuthenticationStateProvider class.</summary>
|
||||
/// <param name="initial">The initial claims principal from circuit boot.</param>
|
||||
/// <param name="http">The HTTP client for authentication ping requests.</param>
|
||||
/// <param name="logger">The logger for diagnostic messages.</param>
|
||||
public CookieAuthenticationStateProvider(
|
||||
ClaimsPrincipal initial,
|
||||
HttpClient http,
|
||||
ILogger<CookieAuthenticationStateProvider> logger)
|
||||
{
|
||||
_current = initial;
|
||||
_http = http;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<AuthenticationState> GetAuthenticationStateAsync()
|
||||
{
|
||||
_pingLoop ??= Task.Run(() => PingLoopAsync(_cts.Token));
|
||||
return Task.FromResult(new AuthenticationState(_current));
|
||||
}
|
||||
|
||||
private async Task PingLoopAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(PingInterval, ct).ConfigureAwait(false);
|
||||
var resp = await _http.GetAsync("/auth/ping", ct).ConfigureAwait(false);
|
||||
if (!resp.IsSuccessStatusCode && _current.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
_logger.LogInformation("/auth/ping returned {Code}; notifying circuit", (int)resp.StatusCode);
|
||||
_current = new ClaimsPrincipal(new ClaimsIdentity());
|
||||
NotifyAuthenticationStateChanged(
|
||||
Task.FromResult(new AuthenticationState(_current)));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { /* expected on shutdown */ }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Auth ping loop terminated unexpectedly");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Cancels the background ping loop, awaits its completion, and disposes the cancellation token source.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_cts.Cancel();
|
||||
if (_pingLoop is not null)
|
||||
{
|
||||
try { await _pingLoop.ConfigureAwait(false); } catch { /* swallow shutdown errors */ }
|
||||
}
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Auth.AspNetCore;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Jwt;
|
||||
@@ -36,7 +37,14 @@ public static class AuthEndpoints
|
||||
// AddOtOpcUaAuth fallback policy that would otherwise 401 the request.
|
||||
app.MapPost("/auth/login", (Delegate)LoginAsync).AllowAnonymous().DisableAntiforgery();
|
||||
app.MapGet("/auth/ping", (Delegate)Ping).AllowAnonymous();
|
||||
app.MapPost("/auth/token", (Delegate)IssueToken).RequireAuthorization();
|
||||
// Mapped only when a signing key is configured (JwtOptions.IsConfigured): an unconfigured
|
||||
// endpoint used to answer HTTP 500 from the JwtTokenService constructor on every call.
|
||||
if (app.ServiceProvider.GetRequiredService<IOptions<JwtOptions>>().Value.IsConfigured)
|
||||
app.MapPost("/auth/token", (Delegate)IssueToken).RequireAuthorization();
|
||||
else
|
||||
app.ServiceProvider.GetService<ILoggerFactory>()?
|
||||
.CreateLogger("ZB.MOM.WW.OtOpcUa.Security.AuthEndpoints")
|
||||
.LogInformation("Security:Jwt:SigningKey is not configured — /auth/token is not mapped and bearer tokens are not accepted");
|
||||
app.MapPost("/auth/logout", (Delegate)LogoutAsync).RequireAuthorization();
|
||||
return app;
|
||||
}
|
||||
@@ -73,8 +81,13 @@ public static class AuthEndpoints
|
||||
{
|
||||
result = await ldap.AuthenticateAsync(username, password, ct);
|
||||
}
|
||||
catch (Exception)
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
// R03:2.7 — a directory outage used to leave NOTHING in the server log from this path; the
|
||||
// operator had to infer every 503 from the directory side.
|
||||
http.RequestServices.GetService<ILoggerFactory>()?
|
||||
.CreateLogger("ZB.MOM.WW.OtOpcUa.Security.AuthEndpoints")
|
||||
.LogWarning(ex, "LDAP authentication failed for {User}; answering 503", username);
|
||||
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,4 +15,26 @@ public sealed class JwtOptions
|
||||
|
||||
/// <summary>Default token expiry. Mirrors ScadaLink (15 min).</summary>
|
||||
public int ExpiryMinutes { get; set; } = 15;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the JWT feature is on: a non-blank <see cref="SigningKey"/>. When off, <c>/auth/token</c>
|
||||
/// is not mapped and no bearer scheme is registered, so an admin node that never configured a key
|
||||
/// keeps booting exactly as before; when on, the key is validated at host start
|
||||
/// (<c>ValidateOnStart</c>) instead of throwing HTTP 500 on the first token request.
|
||||
/// </summary>
|
||||
public bool IsConfigured => !string.IsNullOrWhiteSpace(SigningKey);
|
||||
|
||||
/// <summary>The validation rule shared by the startup validator and the tests: why the options are invalid, or null.</summary>
|
||||
/// <returns>A failure message, or <see langword="null"/> when valid.</returns>
|
||||
public string? Validate()
|
||||
{
|
||||
if (!IsConfigured) return null;
|
||||
var keyBytes = System.Text.Encoding.UTF8.GetByteCount(SigningKey);
|
||||
if (keyBytes < MinSigningKeyBytes)
|
||||
return $"{SectionName}:SigningKey must be at least {MinSigningKeyBytes} bytes (256 bits) UTF-8 for HMAC-SHA256; the configured key is {keyBytes} byte(s).";
|
||||
if (string.IsNullOrWhiteSpace(Issuer)) return $"{SectionName}:Issuer must not be blank.";
|
||||
if (string.IsNullOrWhiteSpace(Audience)) return $"{SectionName}:Audience must not be blank.";
|
||||
if (ExpiryMinutes <= 0) return $"{SectionName}:ExpiryMinutes must be > 0.";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Security.Jwt;
|
||||
|
||||
/// <summary>
|
||||
/// Startup validator for <see cref="JwtOptions"/> (R03:2.4): when a signing key is configured it must
|
||||
/// be usable, and the host must learn that at start (<c>ValidateOnStart</c>) rather than from an HTTP 500
|
||||
/// on the first <c>/auth/token</c>. An unconfigured section (no key) is valid — the feature is simply off.
|
||||
/// </summary>
|
||||
public sealed class JwtOptionsValidator : IValidateOptions<JwtOptions>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public ValidateOptionsResult Validate(string? name, JwtOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
var failure = options.Validate();
|
||||
return failure is null ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(failure);
|
||||
}
|
||||
}
|
||||
@@ -19,9 +19,12 @@ using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Security;
|
||||
|
||||
/// <summary>
|
||||
/// DI registration for OtOpcUa auth. Single Cookie scheme (the JWT lives inside the
|
||||
/// cookie as its credential payload); no JwtBearer parallel scheme. Matches ScadaBridge
|
||||
/// structurally — see <c>docs/plans/2026-05-29-auth-alignment-design.md</c>.
|
||||
/// DI registration for OtOpcUa auth. The cookie scheme carries the browser session; when
|
||||
/// <c>Security:Jwt:SigningKey</c> is configured a <see cref="OtOpcUaAuthSchemes.Bearer"/> scheme
|
||||
/// accepts the tokens <c>/auth/token</c> mints (R03:2.4), and the default scheme is a policy scheme
|
||||
/// that forwards to whichever the request presents — so <c>/api/*</c> and every
|
||||
/// <c>RequireAuthorization</c> endpoint take a browser session OR a machine token. Matches
|
||||
/// ScadaBridge structurally — see <c>docs/plans/2026-05-29-auth-alignment-design.md</c>.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
@@ -36,7 +39,14 @@ public static class ServiceCollectionExtensions
|
||||
/// <returns>The same service collection, for chaining.</returns>
|
||||
public static IServiceCollection AddOtOpcUaAuth(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddOptions<JwtOptions>().Bind(configuration.GetSection(JwtOptions.SectionName));
|
||||
// JWT is opt-in by key: with no SigningKey the feature is off (no /auth/token, no bearer scheme)
|
||||
// and an admin node boots as before; with one, the key is validated at host START (R03:2.4) —
|
||||
// the JwtTokenService constructor used to throw on the first /auth/token call (HTTP 500).
|
||||
services.AddOptions<JwtOptions>()
|
||||
.Bind(configuration.GetSection(JwtOptions.SectionName))
|
||||
.ValidateOnStart();
|
||||
services.AddSingleton<IValidateOptions<JwtOptions>, JwtOptionsValidator>();
|
||||
var jwtConfigured = configuration.GetSection(JwtOptions.SectionName).Get<JwtOptions>()?.IsConfigured ?? false;
|
||||
services.AddOptions<OtOpcUaCookieOptions>().Bind(configuration.GetSection(OtOpcUaCookieOptions.SectionName));
|
||||
services.AddOptions<LdapOptions>().Bind(configuration.GetSection(LdapOptions.SectionName));
|
||||
services.AddOptions<AuthDisableLoginOptions>().Bind(configuration.GetSection(AuthDisableLoginOptions.SectionName));
|
||||
@@ -53,7 +63,10 @@ public static class ServiceCollectionExtensions
|
||||
// as the Actor field when constructing a canonical ZB.MOM.WW.Audit.AuditEvent. Registered
|
||||
// Scoped so it correctly follows the request scope used by Blazor Server and minimal-API
|
||||
// endpoints.
|
||||
services.TryAddScoped<IAuditActorAccessor, HttpAuditActorAccessor>();
|
||||
// AuthenticationStateAuditActorAccessor reads the Blazor circuit's principal first (no HttpContext
|
||||
// exists on a circuit's threads after the first render) and falls back to the HTTP context for
|
||||
// minimal-API scopes, where AuthenticationStateProvider is not registered (R03:2.5).
|
||||
services.TryAddScoped<IAuditActorAccessor, AuthenticationStateAuditActorAccessor>();
|
||||
|
||||
// Singleton — OtOpcUaLdapAuthService is stateless (the shared-library directory client it
|
||||
// wraps opens/disposes an LdapConnection per call) and must be consumable by the Singleton
|
||||
@@ -78,7 +91,26 @@ public static class ServiceCollectionExtensions
|
||||
.GetSection(AuthDisableLoginOptions.SectionName)
|
||||
.GetValue<bool>(nameof(AuthDisableLoginOptions.DisableLogin));
|
||||
|
||||
var authBuilder = services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
// The default scheme is the cookie-or-bearer selector; sign-in/out stay on the cookie scheme, which is
|
||||
// the only one that can issue a session. With JWT off the selector always picks the cookie.
|
||||
var authBuilder = services.AddAuthentication(o =>
|
||||
{
|
||||
o.DefaultScheme = OtOpcUaAuthSchemes.CookieOrBearer;
|
||||
o.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
|
||||
o.DefaultSignOutScheme = CookieAuthenticationDefaults.AuthenticationScheme;
|
||||
});
|
||||
authBuilder.AddPolicyScheme(OtOpcUaAuthSchemes.CookieOrBearer, "Cookie or Bearer", o =>
|
||||
{
|
||||
o.ForwardDefaultSelector = ctx =>
|
||||
jwtConfigured && OtOpcUaAuthSchemes.HasBearerToken(ctx)
|
||||
? OtOpcUaAuthSchemes.Bearer
|
||||
: CookieAuthenticationDefaults.AuthenticationScheme;
|
||||
});
|
||||
if (jwtConfigured)
|
||||
{
|
||||
authBuilder.AddScheme<AuthenticationSchemeOptions, JwtBearerAuthenticationHandler>(
|
||||
OtOpcUaAuthSchemes.Bearer, "OtOpcUa bearer token", _ => { });
|
||||
}
|
||||
if (disableLogin)
|
||||
{
|
||||
// DEV/TEST ONLY: replace the cookie handler with an always-succeeding handler registered
|
||||
@@ -142,8 +174,8 @@ public static class ServiceCollectionExtensions
|
||||
|
||||
services.AddAuthorization(o =>
|
||||
{
|
||||
o.FallbackPolicy = new AuthorizationPolicyBuilder(
|
||||
CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
// Named on the selector, not the cookie scheme, so the fallback accepts a bearer token too.
|
||||
o.FallbackPolicy = new AuthorizationPolicyBuilder(OtOpcUaAuthSchemes.CookieOrBearer)
|
||||
.RequireAuthenticatedUser()
|
||||
.Build();
|
||||
|
||||
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
using Akka.Actor;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
|
||||
using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// R03:4.1 — admin operations echoed <c>ex.Message</c> to the UI, so a SqlException's connection-string
|
||||
/// fragment reached any ConfigEditor browser session. The reply is now a fixed sentence plus the request's
|
||||
/// correlation id; the detail stays in the central log.
|
||||
/// </summary>
|
||||
public sealed class AdminOperationsActorServerFaultMessageTests : ControlPlaneActorTestBase
|
||||
{
|
||||
private const string Secret = "Server=sql-prod-01;Password=hunter2";
|
||||
|
||||
[Fact]
|
||||
public void StartDeployment_server_fault_reply_carries_the_correlation_id_and_not_the_exception_text()
|
||||
{
|
||||
var coordinator = CreateTestProbe("coord");
|
||||
var actor = Sys.ActorOf(AdminOperationsActor.Props(new ThrowingDbFactory(), coordinator.Ref, Enumerable.Empty<IDriverProbe>()));
|
||||
var correlationId = CorrelationId.NewId();
|
||||
|
||||
actor.Tell(new StartDeployment("joe", correlationId));
|
||||
|
||||
var reply = ExpectMsg<StartDeploymentResult>(PresenceBudget);
|
||||
reply.Outcome.ShouldBe(StartDeploymentOutcome.Rejected);
|
||||
reply.Message.ShouldNotBeNull();
|
||||
reply.Message.ShouldNotContain("hunter2");
|
||||
reply.Message.ShouldNotContain("sql-prod-01");
|
||||
reply.Message.ShouldContain(correlationId.ToString()); // the operator can find the log line
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TestDriverConnect_probe_throw_reply_carries_the_correlation_id_and_not_the_exception_text()
|
||||
{
|
||||
var coordinator = CreateTestProbe("coord");
|
||||
var actor = Sys.ActorOf(AdminOperationsActor.Props(NewInMemoryDbFactory(), coordinator.Ref, new IDriverProbe[] { new ThrowingProbe() }));
|
||||
var correlationId = Guid.NewGuid();
|
||||
|
||||
actor.Tell(new TestDriverConnect("Throwing", "{}", 5, correlationId));
|
||||
|
||||
var reply = ExpectMsg<TestDriverConnectResult>(PresenceBudget);
|
||||
reply.Ok.ShouldBeFalse();
|
||||
reply.Message.ShouldNotBeNull();
|
||||
reply.Message.ShouldNotContain("hunter2");
|
||||
reply.Message.ShouldContain(correlationId.ToString());
|
||||
}
|
||||
|
||||
private sealed class ThrowingDbFactory : IDbContextFactory<OtOpcUaConfigDbContext>
|
||||
{
|
||||
public OtOpcUaConfigDbContext CreateDbContext() =>
|
||||
throw new InvalidOperationException("Cannot open database: " + Secret);
|
||||
}
|
||||
|
||||
private sealed class ThrowingProbe : IDriverProbe
|
||||
{
|
||||
public string DriverType => "Throwing";
|
||||
public Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct) =>
|
||||
throw new InvalidOperationException("probe blew up: " + Secret);
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// R03:2.3 — an unset or placeholder pepper must fail host start, not surface as "Invalid API key"
|
||||
/// on the first activation.
|
||||
/// </summary>
|
||||
public sealed class ApiKeyOptionsValidatorTests
|
||||
{
|
||||
private static ValidateOptionsResult Validate(ApiKeyOptions options) =>
|
||||
new ApiKeyOptionsValidator().Validate(Options.DefaultName, options);
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Blank_pepper_fails(string pepper)
|
||||
{
|
||||
var result = Validate(new ApiKeyOptions { Pepper = pepper });
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.FailureMessage.ShouldNotBeNull();
|
||||
result.FailureMessage.ShouldContain("Pepper is required");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Short_pepper_fails()
|
||||
{
|
||||
var result = Validate(new ApiKeyOptions { Pepper = "x" });
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.FailureMessage.ShouldNotBeNull();
|
||||
result.FailureMessage.ShouldContain($"at least {ApiKeyOptionsValidator.MinPepperLength}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Blank_username_fails()
|
||||
{
|
||||
var result = Validate(new ApiKeyOptions { Pepper = new string('p', 32), Username = " " });
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.FailureMessage.ShouldNotBeNull();
|
||||
result.FailureMessage.ShouldContain("Username");
|
||||
}
|
||||
|
||||
/// <summary>Positive control: the docker-dev rig's pepper and the defaults pass unchanged.</summary>
|
||||
[Fact]
|
||||
public void Configured_pepper_passes()
|
||||
{
|
||||
Validate(new ApiKeyOptions { Pepper = "docker-dev-apikey-pepper-not-for-production" })
|
||||
.Succeeded.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.Auth.AspNetCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Audit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Security.Tests.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// R03:2.5 — inside a Blazor circuit there is no <c>HttpContext</c> after the first render, so an
|
||||
/// HTTP-only accessor stamps every audit row from an interactive page <c>"system"</c>. The circuit's
|
||||
/// <see cref="AuthenticationStateProvider"/> is consulted first; the HTTP context is the fallback.
|
||||
/// </summary>
|
||||
public sealed class AuthenticationStateAuditActorAccessorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Circuit_principal_wins_when_there_is_no_http_context()
|
||||
{
|
||||
var accessor = new AuthenticationStateAuditActorAccessor(
|
||||
new HttpContextAccessorStub(null),
|
||||
new FixedStateProvider(Authenticated(new Claim(ZbClaimTypes.Username, "alice"))));
|
||||
|
||||
accessor.CurrentActor.ShouldBe("alice");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Http_principal_is_the_fallback_outside_a_circuit()
|
||||
{
|
||||
var context = new DefaultHttpContext { User = Authenticated(new Claim(ZbClaimTypes.Username, "bob")) };
|
||||
var accessor = new AuthenticationStateAuditActorAccessor(new HttpContextAccessorStub(context), authenticationState: null);
|
||||
|
||||
accessor.CurrentActor.ShouldBe("bob");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unauthenticated_circuit_falls_through_to_http_then_null()
|
||||
{
|
||||
var anonymousCircuit = new FixedStateProvider(new ClaimsPrincipal(new ClaimsIdentity()));
|
||||
|
||||
new AuthenticationStateAuditActorAccessor(new HttpContextAccessorStub(null), anonymousCircuit)
|
||||
.CurrentActor.ShouldBeNull();
|
||||
|
||||
var context = new DefaultHttpContext { User = Authenticated(new Claim(ZbClaimTypes.Name, "carol")) };
|
||||
new AuthenticationStateAuditActorAccessor(new HttpContextAccessorStub(context), anonymousCircuit)
|
||||
.CurrentActor.ShouldBe("carol");
|
||||
}
|
||||
|
||||
/// <summary>An incomplete state task is "no circuit identity", never a block.</summary>
|
||||
[Fact]
|
||||
public void Pending_authentication_state_is_not_awaited()
|
||||
{
|
||||
var accessor = new AuthenticationStateAuditActorAccessor(
|
||||
new HttpContextAccessorStub(null),
|
||||
new PendingStateProvider());
|
||||
|
||||
accessor.CurrentActor.ShouldBeNull();
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal Authenticated(params Claim[] claims) =>
|
||||
new(new ClaimsIdentity(claims, "TestScheme", ZbClaimTypes.Name, ZbClaimTypes.Role));
|
||||
|
||||
private sealed class HttpContextAccessorStub(HttpContext? context) : IHttpContextAccessor
|
||||
{
|
||||
public HttpContext? HttpContext { get; set; } = context;
|
||||
}
|
||||
|
||||
private sealed class FixedStateProvider(ClaimsPrincipal user) : AuthenticationStateProvider
|
||||
{
|
||||
public override Task<AuthenticationState> GetAuthenticationStateAsync() =>
|
||||
Task.FromResult(new AuthenticationState(user));
|
||||
}
|
||||
|
||||
private sealed class PendingStateProvider : AuthenticationStateProvider
|
||||
{
|
||||
public override Task<AuthenticationState> GetAuthenticationStateAsync() =>
|
||||
new TaskCompletionSource<AuthenticationState>().Task;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.Auth.AspNetCore;
|
||||
@@ -37,6 +38,7 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
|
||||
private IHost _host = null!;
|
||||
private TestServer _server = null!;
|
||||
private readonly StubLdapGroupRoleMappingService _roleMappings = new();
|
||||
private readonly CapturingLoggerProvider _logs = new();
|
||||
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
@@ -70,6 +72,7 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
|
||||
["Security:Ldap:GroupToRole:ReadOnly"] = "Viewer",
|
||||
}).Build();
|
||||
services.AddOtOpcUaAuth(configuration);
|
||||
services.AddLogging(l => l.AddProvider(_logs));
|
||||
services.AddSingleton<ILdapAuthService, StubLdapAuthService>();
|
||||
// The login handler now resolves the DB role-map service via DI to merge
|
||||
// DB-backed grants on top of the appsettings baseline. Register the stub so
|
||||
@@ -96,6 +99,10 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
|
||||
.ToArray();
|
||||
return Results.Ok(claims);
|
||||
}).RequireAuthorization();
|
||||
// A role-gated machine endpoint: proves a bearer principal's roles resolve through
|
||||
// IsInRole (the validation parameters pair RoleClaimType with the minted key).
|
||||
e.MapGet("/api/viewer-only", () => Results.Ok("viewer"))
|
||||
.RequireAuthorization(p => p.RequireRole("Viewer"));
|
||||
});
|
||||
});
|
||||
})
|
||||
@@ -153,6 +160,66 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
|
||||
new AuthEndpoints.LoginRequest("ldap-down", "anything"), Ct);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.ServiceUnavailable);
|
||||
// R03:2.7 — the outage is named in the server log, not inferred from the directory side.
|
||||
_logs.Entries.Any(e =>
|
||||
e.Level == LogLevel.Warning && e.Message.Contains("LDAP authentication failed") && e.Exception != null)
|
||||
.ShouldBeTrue("a Warning naming the LDAP failure must be logged");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R03:2.4 — a token from <c>/auth/token</c> authenticates a request carrying it as a bearer:
|
||||
/// the fallback-protected root AND a role-gated endpoint (the bearer principal's "Role" claims
|
||||
/// resolve through <c>IsInRole</c>). No cookie is sent.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Bearer_token_authenticates_api_requests_without_a_cookie()
|
||||
{
|
||||
var token = await LoginAndIssueTokenAsync();
|
||||
var client = NewClient();
|
||||
|
||||
var root = new HttpRequestMessage(HttpMethod.Get, "/");
|
||||
root.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||
(await client.SendAsync(root, Ct)).StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var gated = new HttpRequestMessage(HttpMethod.Get, "/api/viewer-only");
|
||||
gated.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||
var gatedResponse = await client.SendAsync(gated, Ct);
|
||||
gatedResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
(await gatedResponse.Content.ReadAsStringAsync(Ct)).ShouldContain("viewer");
|
||||
}
|
||||
|
||||
/// <summary>A tampered or garbage bearer fails closed (401), it never falls through to anonymous.</summary>
|
||||
[Theory]
|
||||
[InlineData("not-a-jwt")]
|
||||
[InlineData("tampered")]
|
||||
public async Task Bad_bearer_token_is_rejected(string kind)
|
||||
{
|
||||
var token = kind == "tampered"
|
||||
? (await LoginAndIssueTokenAsync()) + "x"
|
||||
: kind;
|
||||
var client = NewClient();
|
||||
var req = new HttpRequestMessage(HttpMethod.Get, "/");
|
||||
req.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||
req.Headers.Add("X-Requested-With", "XMLHttpRequest");
|
||||
|
||||
var response = await client.SendAsync(req, Ct);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||
response.Headers.WwwAuthenticate.ToString().ShouldContain("Bearer");
|
||||
}
|
||||
|
||||
private async Task<string> LoginAndIssueTokenAsync()
|
||||
{
|
||||
var client = NewClient();
|
||||
var loginResponse = await client.PostAsJsonAsync("/auth/login",
|
||||
new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct);
|
||||
loginResponse.EnsureSuccessStatusCode();
|
||||
var tokenReq = new HttpRequestMessage(HttpMethod.Post, "/auth/token");
|
||||
AttachCookies(tokenReq, loginResponse);
|
||||
var response = await client.SendAsync(tokenReq, Ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var payload = await response.Content.ReadFromJsonAsync<JsonElement>(Ct);
|
||||
return payload.GetProperty("token").GetString()!;
|
||||
}
|
||||
|
||||
/// <summary>Tests that ping anonymous returns 401.</summary>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Security.Tests;
|
||||
|
||||
/// <summary>Collects every log entry written through the host's logger factory, for assertions on what was logged.</summary>
|
||||
internal sealed class CapturingLoggerProvider : ILoggerProvider
|
||||
{
|
||||
public ConcurrentQueue<Entry> Entries { get; } = new();
|
||||
|
||||
public ILogger CreateLogger(string categoryName) => new Logger(categoryName, Entries);
|
||||
|
||||
public void Dispose() { }
|
||||
|
||||
internal sealed record Entry(string Category, LogLevel Level, string Message, Exception? Exception);
|
||||
|
||||
private sealed class Logger(string category, ConcurrentQueue<Entry> entries) : ILogger
|
||||
{
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) =>
|
||||
entries.Enqueue(new Entry(category, logLevel, formatter(state, exception), exception));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Services;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Auth;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Endpoints;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Jwt;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Security.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// R03:2.4 — JWT is opt-in by signing key. Unconfigured: <c>/auth/token</c> is not mapped (it used to
|
||||
/// answer HTTP 500 from the service constructor) and no bearer scheme exists. Configured badly: the
|
||||
/// host refuses to START rather than failing on the first token request.
|
||||
/// </summary>
|
||||
public sealed class JwtConfigurationGateTests
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
[Fact]
|
||||
public async Task Without_a_signing_key_auth_token_is_not_mapped_and_bearer_is_ignored()
|
||||
{
|
||||
using var host = await StartHostAsync(signingKey: null);
|
||||
var server = host.GetTestServer();
|
||||
var client = server.CreateClient();
|
||||
|
||||
// The login still works — the cookie path is untouched.
|
||||
var login = await client.PostAsJsonAsync("/auth/login", new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct);
|
||||
login.StatusCode.ShouldBe(HttpStatusCode.NoContent);
|
||||
|
||||
// /auth/token is simply absent (404), not a 500.
|
||||
var tokenReq = new HttpRequestMessage(HttpMethod.Post, "/auth/token");
|
||||
foreach (var c in login.Headers.GetValues("Set-Cookie")) tokenReq.Headers.Add("Cookie", c.Split(';')[0]);
|
||||
(await client.SendAsync(tokenReq, Ct)).StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
||||
|
||||
// No bearer scheme is registered, so a bearer header is ignored and the cookie session still wins.
|
||||
var schemes = host.Services.GetRequiredService<IAuthenticationSchemeProvider>();
|
||||
(await schemes.GetSchemeAsync(OtOpcUaAuthSchemes.Bearer)).ShouldBeNull();
|
||||
var withBogusBearer = new HttpRequestMessage(HttpMethod.Get, "/");
|
||||
foreach (var c in login.Headers.GetValues("Set-Cookie")) withBogusBearer.Headers.Add("Cookie", c.Split(';')[0]);
|
||||
withBogusBearer.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", "garbage");
|
||||
(await client.SendAsync(withBogusBearer, Ct)).StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
await host.StopAsync(Ct);
|
||||
}
|
||||
|
||||
/// <summary>Positive control for the gate: with a key, the bearer scheme IS registered.</summary>
|
||||
[Fact]
|
||||
public async Task With_a_signing_key_the_bearer_scheme_is_registered()
|
||||
{
|
||||
using var host = await StartHostAsync(signingKey: "test-signing-key-with-at-least-32-bytes-of-utf8-content");
|
||||
var schemes = host.Services.GetRequiredService<IAuthenticationSchemeProvider>();
|
||||
var bearer = await schemes.GetSchemeAsync(OtOpcUaAuthSchemes.Bearer);
|
||||
bearer.ShouldNotBeNull();
|
||||
bearer.HandlerType.ShouldBe(typeof(JwtBearerAuthenticationHandler));
|
||||
await host.StopAsync(Ct);
|
||||
}
|
||||
|
||||
/// <summary>A configured-but-short key fails at host start (ValidateOnStart), not on the first /auth/token.</summary>
|
||||
[Fact]
|
||||
public async Task A_short_signing_key_fails_host_start()
|
||||
{
|
||||
var ex = await Should.ThrowAsync<OptionsValidationException>(async () =>
|
||||
{
|
||||
using var host = await StartHostAsync(signingKey: "too-short");
|
||||
});
|
||||
ex.Message.ShouldContain("SigningKey");
|
||||
}
|
||||
|
||||
private static async Task<IHost> StartHostAsync(string? signingKey)
|
||||
{
|
||||
var dbName = $"jwt-gate-{Guid.NewGuid():N}";
|
||||
var settings = new Dictionary<string, string?>();
|
||||
if (signingKey is not null) settings["Security:Jwt:SigningKey"] = signingKey;
|
||||
var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build();
|
||||
|
||||
var host = new HostBuilder()
|
||||
.ConfigureWebHost(web =>
|
||||
{
|
||||
web.UseTestServer();
|
||||
web.ConfigureServices(services =>
|
||||
{
|
||||
services.AddDbContextFactory<OtOpcUaConfigDbContext>(opt => opt.UseInMemoryDatabase(dbName));
|
||||
services.AddDbContext<OtOpcUaConfigDbContext>(opt => opt.UseInMemoryDatabase(dbName));
|
||||
services.AddRouting();
|
||||
services.AddOtOpcUaAuth(configuration);
|
||||
services.AddSingleton<ILdapAuthService, AlwaysAliceLdap>();
|
||||
services.AddSingleton<ILdapGroupRoleMappingService, NullLdapGroupRoleMappingService>();
|
||||
});
|
||||
web.Configure(app =>
|
||||
{
|
||||
app.UseRouting();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseEndpoints(e =>
|
||||
{
|
||||
e.MapOtOpcUaAuth();
|
||||
e.MapGet("/", () => Results.Ok("authenticated")).RequireAuthorization();
|
||||
});
|
||||
});
|
||||
})
|
||||
.Build();
|
||||
await host.StartAsync(Ct);
|
||||
return host;
|
||||
}
|
||||
|
||||
private sealed class AlwaysAliceLdap : ILdapAuthService
|
||||
{
|
||||
public Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default) =>
|
||||
Task.FromResult(new LdapAuthResult(
|
||||
Success: password == "valid-password", DisplayName: "Alice", Username: username,
|
||||
Groups: [], Roles: [], Error: null));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user