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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user