3ddb6876db
The hand-rolled bearer handler is replaced by AddJwtBearer (Microsoft.AspNetCore.Authentication.JwtBearer 10.0.7, pinned on the same servicing line as the other AspNetCore packages). Validation parameters come from JwtTokenService.BuildValidationParameters, which now pins ValidAlgorithms = HS256 for both the bearer scheme and TryValidate; MapInboundClaims is off so the minted Role/zb:username keys resolve. /auth/token requires the cookie scheme, so a bearer can no longer mint a fresh token for itself. Refs #733 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
224 lines
13 KiB
C#
224 lines
13 KiB
C#
using Microsoft.AspNetCore.Authentication;
|
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.DataProtection;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
|
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.Configuration;
|
|
using ZB.MOM.WW.OtOpcUa.Security.Audit;
|
|
using ZB.MOM.WW.OtOpcUa.Security.Auth;
|
|
using ZB.MOM.WW.OtOpcUa.Security.Jwt;
|
|
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Security;
|
|
|
|
/// <summary>
|
|
/// 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
|
|
{
|
|
/// <summary>
|
|
/// Wires cookie authentication, DataProtection key persistence to ConfigDb,
|
|
/// LDAP services, and the LDAP-backed JwtTokenService. Browser flows redirect to
|
|
/// <c>/login</c>; AJAX/JSON callers receive 401 (handled by the framework's default
|
|
/// challenge heuristic).
|
|
/// </summary>
|
|
/// <param name="services">The service collection.</param>
|
|
/// <param name="configuration">The application configuration root.</param>
|
|
/// <returns>The same service collection, for chaining.</returns>
|
|
public static IServiceCollection AddOtOpcUaAuth(this IServiceCollection services, IConfiguration configuration)
|
|
{
|
|
// 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));
|
|
|
|
services.AddSingleton<JwtTokenService>();
|
|
|
|
// IHttpContextAccessor is not registered by default — call AddHttpContextAccessor()
|
|
// so HttpAuditActorAccessor and any Blazor/minimal-API component that reads the current
|
|
// HTTP context by injection can resolve it. AddHttpContextAccessor is idempotent (internal
|
|
// TryAdd), so calling it here is safe even if the host also calls it elsewhere.
|
|
services.AddHttpContextAccessor();
|
|
|
|
// IAuditActorAccessor — resolves the authenticated HTTP principal's actor string for use
|
|
// 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.
|
|
// 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
|
|
// AdminUI login on admin-role nodes. This is the app's ILdapAuthService: it
|
|
// adds the Enabled master switch + DevStubMode bypass on top of the shared ZB.MOM.WW.Auth.Ldap
|
|
// service. TryAdd so a fused admin+driver node (which also registers it in Program.cs for the
|
|
// driver path) ends up with exactly one descriptor regardless of registration order.
|
|
services.TryAddSingleton<ILdapAuthService, OtOpcUaLdapAuthService>();
|
|
|
|
// Shared ZB.MOM.WW.Auth group→role mapper seam. Wraps the existing RoleMapper.Map +
|
|
// RoleMapper.Merge logic; consumed by AuthEndpoints.cs (/auth/login) and by
|
|
// AdminUI login (the OPC UA data plane uses API keys). Scoped to match
|
|
// ILdapGroupRoleMappingService (DbContext-backed, registered Scoped) — a singleton here
|
|
// would capture the scoped DB service.
|
|
services.TryAddScoped<IGroupRoleMapper<string>, OtOpcUaGroupRoleMapper>();
|
|
|
|
services.AddDataProtection()
|
|
.PersistKeysToDbContext<OtOpcUaConfigDbContext>()
|
|
.SetApplicationName("OtOpcUa");
|
|
|
|
var disableLogin = configuration
|
|
.GetSection(AuthDisableLoginOptions.SectionName)
|
|
.GetValue<bool>(nameof(AuthDisableLoginOptions.DisableLogin));
|
|
|
|
// 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)
|
|
{
|
|
// First-party JwtBearer (R03:2.4, fix round 1 / I1) — the earlier hand-rolled handler is gone.
|
|
// Validation parameters come from the SAME JwtTokenService that mints, so issuer/audience/key/
|
|
// claim types can never drift; BuildValidationParameters pins ValidAlgorithms = HS256 (an
|
|
// alg:none or a different-HMAC token is refused). MapInboundClaims=false keeps the minted
|
|
// "Role"/"zb:username" claim keys verbatim, which RoleClaimType/NameClaimType name. A bad token
|
|
// is AuthenticateResult.Fail ⇒ 401 + WWW-Authenticate: Bearer, never a 500.
|
|
authBuilder.AddJwtBearer(OtOpcUaAuthSchemes.Bearer, "OtOpcUa bearer token", _ => { });
|
|
services.AddOptions<JwtBearerOptions>(OtOpcUaAuthSchemes.Bearer)
|
|
.Configure<JwtTokenService>((o, jwt) =>
|
|
{
|
|
o.TokenValidationParameters = jwt.BuildValidationParameters();
|
|
o.MapInboundClaims = false;
|
|
o.SaveToken = false;
|
|
o.IncludeErrorDetails = false;
|
|
});
|
|
}
|
|
if (disableLogin)
|
|
{
|
|
// DEV/TEST ONLY: replace the cookie handler with an always-succeeding handler registered
|
|
// UNDER the cookie scheme name, so FallbackPolicy + FleetAdmin + DriverOperator (which all
|
|
// name this scheme) authenticate through it and pass with all roles — zero policy changes.
|
|
authBuilder.AddScheme<AuthenticationSchemeOptions, AutoLoginAuthenticationHandler>(
|
|
CookieAuthenticationDefaults.AuthenticationScheme, _ => { });
|
|
|
|
// Loud, once-at-first-resolve warning (mirrors the cookie RequireHttps warning idiom below).
|
|
services.AddOptions<AuthDisableLoginOptions>()
|
|
.PostConfigure<ILoggerFactory>((opts, lf) =>
|
|
{
|
|
lf.CreateLogger("ZB.MOM.WW.OtOpcUa.Security").LogWarning(
|
|
"AdminUI LOGIN DISABLED (Security:Auth:DisableLogin=true) — every request is " +
|
|
"authenticated as '{User}' with FULL permissions ({Roles}). Dev/test only; never " +
|
|
"enable in production.", opts.User, string.Join(",", DevAuthRoles.All));
|
|
});
|
|
}
|
|
else
|
|
{
|
|
authBuilder.AddCookie(o =>
|
|
{
|
|
// Static fields only — Name / ExpireTimeSpan / SecurePolicy / SlidingExpiration /
|
|
// HttpOnly / SameSite are applied from OtOpcUaCookieOptions via ZbCookieDefaults
|
|
// in the PostConfigure block below.
|
|
o.LoginPath = "/login";
|
|
o.LogoutPath = "/auth/logout";
|
|
// No OnRedirectToLogin / OnRedirectToAccessDenied overrides — let the framework's
|
|
// built-in IsAjaxRequest heuristic do its thing (302 for browsers, 401 for AJAX).
|
|
});
|
|
}
|
|
|
|
// Externalised cookie config — mirrors ScadaBridge's PostConfigure pattern. Fixes a
|
|
// pre-existing latent bug where OtOpcUaCookieOptions was bound but ignored.
|
|
// ZbCookieDefaults.Apply sets HttpOnly=true, SameSite=Strict, SlidingExpiration=true,
|
|
// SecurePolicy, and ExpireTimeSpan; we then set the app-specific cookie name on top.
|
|
services.AddOptions<CookieAuthenticationOptions>(CookieAuthenticationDefaults.AuthenticationScheme)
|
|
.Configure<IOptions<OtOpcUaCookieOptions>, ILoggerFactory>((cookieOpts, ourOpts, lf) =>
|
|
{
|
|
var v = ourOpts.Value;
|
|
|
|
// Apply canonical hardened defaults (HttpOnly, SameSite=Strict, SlidingExpiration,
|
|
// SecurePolicy, ExpireTimeSpan). Cookie name is NOT touched by ZbCookieDefaults —
|
|
// we set it below so each app keeps its own distinct cookie name.
|
|
ZbCookieDefaults.Apply(
|
|
cookieOpts,
|
|
requireHttps: v.RequireHttpsCookie,
|
|
idleTimeout: TimeSpan.FromMinutes(v.ExpiryMinutes));
|
|
|
|
// Keep OtOpcUa's own cookie name (default "ZB.MOM.WW.OtOpcUa.Auth").
|
|
cookieOpts.Cookie.Name = v.Name;
|
|
|
|
if (!v.RequireHttpsCookie)
|
|
{
|
|
lf.CreateLogger("ZB.MOM.WW.OtOpcUa.Security").LogWarning(
|
|
"Security:Cookie:RequireHttpsCookie is DISABLED — auth cookie SecurePolicy is " +
|
|
"SameAsRequest. The cookie-embedded JWT will travel in cleartext over plain HTTP. " +
|
|
"Intended for local dev only — set Security:Cookie:RequireHttpsCookie=true in production.");
|
|
}
|
|
});
|
|
|
|
services.AddAuthorization(o =>
|
|
{
|
|
// Named on the selector, not the cookie scheme, so the fallback accepts a bearer token too.
|
|
o.FallbackPolicy = new AuthorizationPolicyBuilder(OtOpcUaAuthSchemes.CookieOrBearer)
|
|
.RequireAuthenticatedUser()
|
|
.Build();
|
|
|
|
// DriverOperator (policy NAME kept stable): may issue Reconnect/Restart commands against
|
|
// live driver instances from the Admin UI DriverStatusPanel. The role STRINGS it requires
|
|
// are the canonical control-plane roles: Operator (was DriverOperator) and
|
|
// Administrator (was FleetAdmin). Map LDAP group → role via GroupToRole in appsettings
|
|
// (e.g. "ot-driver-operator": "Operator").
|
|
o.AddPolicy(AdminUiPolicies.DriverOperator, policy =>
|
|
policy.RequireRole(DevAuthRoles.Operator, "Administrator"));
|
|
|
|
// FleetAdmin (policy NAME kept stable): full administrative access; gates fleet-wide pages
|
|
// such as RoleGrants. Requires the canonical Administrator role (was FleetAdmin).
|
|
o.AddPolicy(AdminUiPolicies.FleetAdmin, policy => policy.RequireRole("Administrator"));
|
|
|
|
// ConfigEditor: may author configuration (UNS, equipment, clusters, drivers incl. live
|
|
// ResilienceConfig, ACLs, namespaces, scripts) and trigger deploys. Same semantics the
|
|
// Deployments/Scripts/ScriptEdit pages previously spelled as Roles="Administrator,Designer".
|
|
o.AddPolicy(AdminUiPolicies.ConfigEditor, policy =>
|
|
policy.RequireRole("Administrator", "Designer"));
|
|
|
|
// AuthenticatedRead: explicit name for "any authenticated user" so read-only pages can carry
|
|
// a non-bare attribute and the page-authorization guard can ban bare [Authorize] outright.
|
|
// Semantically identical to the FallbackPolicy.
|
|
o.AddPolicy(AdminUiPolicies.AuthenticatedRead, policy =>
|
|
policy.RequireAuthenticatedUser());
|
|
});
|
|
|
|
return services;
|
|
}
|
|
}
|