Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs
T
Joseph Doherty 9cad9ed0fc
v2-ci / build (push) Failing after 41s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
docs(xmldoc): fill missing XML docs + strip tracking-ID comments across src
Adds <summary>/<param>/<returns>/<inheritdoc> where missing and removes
project bookkeeping IDs (task/tracking refs) from shipped code comments,
so the docs read cleanly and CommentChecker is quiet except for known
false positives (PLC/protocol terms, event/IEqualityComparer inheritdoc).
Doc/comment-only; no logic changed; solution builds clean.
2026-07-07 12:38:39 -04:00

166 lines
9.2 KiB
C#

using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
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. 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>.
/// </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)
{
services.AddOptions<JwtOptions>().Bind(configuration.GetSection(JwtOptions.SectionName));
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.
services.TryAddScoped<IAuditActorAccessor, HttpAuditActorAccessor>();
// Singleton — OtOpcUaLdapAuthService is stateless (the shared-library directory client it
// wraps opens/disposes an LdapConnection per call) and must be consumable by the Singleton
// LdapOpcUaUserAuthenticator on driver-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
// LdapOpcUaUserAuthenticator (OPC UA UserName session auth). 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));
var authBuilder = services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme);
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 =>
{
o.FallbackPolicy = new AuthorizationPolicyBuilder(
CookieAuthenticationDefaults.AuthenticationScheme)
.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("DriverOperator", policy =>
policy.RequireRole("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("FleetAdmin", policy => policy.RequireRole("Administrator"));
});
return services;
}
}