Phase 3 PR 19 — LDAP user identity + Basic256Sha256 security profile. Replaces the anonymous-only endpoint with a configurable security profile and an LDAP-backed UserName token validator. New IUserAuthenticator abstraction in Backend/Security/: LdapUserAuthenticator binds to the configured directory (reuses the pattern from Admin.Security.LdapAuthService without the cross-app dependency — Novell.Directory.Ldap.NETStandard 3.6.0 package ref added to Server alongside the existing OPCFoundation packages) and maps group membership to OPC UA roles via LdapOptions.GroupToRole (case-insensitive). DenyAllUserAuthenticator is the default when Ldap.Enabled=false so UserName token attempts return a clean BadUserAccessDenied rather than hanging on a localhost:3893 bind attempt. OpcUaSecurityProfile enum + LdapOptions nested record on OpcUaServerOptions. Profile=None keeps the PR 17 shape (SecurityPolicies.None + Anonymous token only) so existing integration tests stay green; Profile=Basic256Sha256SignAndEncrypt adds a second ServerSecurityPolicy (Basic256Sha256 + SignAndEncrypt) to the collection and, when Ldap.Enabled=true, adds a UserName token policy scoped to SecurityPolicies.Basic256Sha256 only — passwords must ride an encrypted channel, the stack rejects UserName over None. OtOpcUaServer.OnServerStarted hooks SessionManager.ImpersonateUser: AnonymousIdentityToken passes through; UserNameIdentityToken delegates to IUserAuthenticator.AuthenticateAsync — rejected identities throw ServiceResultException(BadUserAccessDenied); accepted identities get a RoleBasedIdentity that carries the resolved roles through session.Identity so future PRs can gate writes by role. OpcUaApplicationHost + OtOpcUaServer constructors take IUserAuthenticator as a dependency. Program.cs binds the new OpcUaServer:Ldap section from appsettings (Enabled defaults false, GroupToRole parsed as Dictionary<string,string>), registers IUserAuthenticator as LdapUserAuthenticator when enabled or DenyAllUserAuthenticator otherwise. PR 17 integration test updated to pass DenyAllUserAuthenticator so it keeps exercising the anonymous-only path unchanged. Tests — SecurityConfigurationTests (new, 13 cases): DenyAllAuthenticator rejects every credential; LdapAuthenticator rejects blank creds without hitting the server; rejects when Enabled=false; rejects plaintext when both UseTls=false AND AllowInsecureLdap=false (safety guard matching the Admin service); EscapeLdapFilter theory (4 rows: plain passthrough, parens/asterisk/backslash → hex escape) — regression guard against LDAP injection; ExtractOuSegment theory (3 rows: finds ou=, returns null when absent, handles multiple ou segments by returning first); ExtractFirstRdnValue theory (3 rows: strips cn= prefix, handles single-segment DN, returns plain string unchanged when no =). OpcUaServerOptions_default_is_anonymous_only asserts the default posture preserves PR 17 behavior. InternalsVisibleTo('ZB.MOM.WW.OtOpcUa.Server.Tests') added to Server csproj so ExtractOuSegment and siblings are reachable from the tests. Full solution: 0 errors, 180 tests pass (8 Core + 14 Proxy + 24 Configuration + 6 Shared + 91 Galaxy.Host + 19 Server (17 unit + 2 integration) + 18 Admin). Live-LDAP integration test (connect via Basic256Sha256 endpoint with a real user from GLAuth, assert the session.Identity carries the mapped role) is deferred to a follow-up — it requires the GLAuth dev instance to be running at localhost:3893 which is dev-machine-specific, and the test harness for that also needs a fresh client-side certificate provisioned by the live server's trusted store.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-04-18 08:49:46 -04:00
parent 55696a8750
commit 22d3b0d23c
10 changed files with 477 additions and 21 deletions

View File

@@ -5,6 +5,7 @@ using Opc.Ua.Server;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
using ZB.MOM.WW.OtOpcUa.Server.Security;
namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa;
@@ -17,12 +18,14 @@ namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa;
public sealed class OtOpcUaServer : StandardServer
{
private readonly DriverHost _driverHost;
private readonly IUserAuthenticator _authenticator;
private readonly ILoggerFactory _loggerFactory;
private readonly List<DriverNodeManager> _driverNodeManagers = new();
public OtOpcUaServer(DriverHost driverHost, ILoggerFactory loggerFactory)
public OtOpcUaServer(DriverHost driverHost, IUserAuthenticator authenticator, ILoggerFactory loggerFactory)
{
_driverHost = driverHost;
_authenticator = authenticator;
_loggerFactory = loggerFactory;
}
@@ -50,6 +53,63 @@ public sealed class OtOpcUaServer : StandardServer
return new MasterNodeManager(server, configuration, null, _driverNodeManagers.ToArray());
}
protected override void OnServerStarted(IServerInternal server)
{
base.OnServerStarted(server);
// Hook UserName / Anonymous token validation here. Anonymous passes through; UserName
// is validated against the IUserAuthenticator (LDAP in production). Rejected identities
// throw ServiceResultException which the stack translates to Bad_IdentityTokenInvalid.
server.SessionManager.ImpersonateUser += OnImpersonateUser;
}
private void OnImpersonateUser(Session session, ImpersonateEventArgs args)
{
switch (args.NewIdentity)
{
case AnonymousIdentityToken:
args.Identity = new UserIdentity(); // anonymous
return;
case UserNameIdentityToken user:
{
var result = _authenticator.AuthenticateAsync(
user.UserName, user.DecryptedPassword, CancellationToken.None)
.GetAwaiter().GetResult();
if (!result.Success)
{
throw ServiceResultException.Create(
StatusCodes.BadUserAccessDenied,
"Invalid username or password ({0})", result.Error ?? "no detail");
}
args.Identity = new RoleBasedIdentity(user.UserName, result.DisplayName, result.Roles);
return;
}
default:
throw ServiceResultException.Create(
StatusCodes.BadIdentityTokenInvalid,
"Unsupported user identity token type: {0}", args.NewIdentity?.GetType().Name ?? "null");
}
}
/// <summary>
/// Tiny UserIdentity carrier that preserves the resolved roles so downstream node
/// managers can gate writes by role via <c>session.Identity</c>. Anonymous identity still
/// uses the stack's default.
/// </summary>
private sealed class RoleBasedIdentity : UserIdentity
{
public IReadOnlyList<string> Roles { get; }
public string? Display { get; }
public RoleBasedIdentity(string userName, string? displayName, IReadOnlyList<string> roles)
: base(userName, "")
{
Display = displayName;
Roles = roles;
}
}
protected override ServerProperties LoadServerProperties() => new()
{
ManufacturerName = "OtOpcUa",