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:
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user