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:
@@ -0,0 +1,88 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class SecurityConfigurationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task DenyAllAuthenticator_rejects_every_credential()
|
||||
{
|
||||
var auth = new DenyAllUserAuthenticator();
|
||||
var r = await auth.AuthenticateAsync("admin", "admin", CancellationToken.None);
|
||||
r.Success.ShouldBeFalse();
|
||||
r.Error.ShouldContain("not supported");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LdapAuthenticator_rejects_blank_credentials_without_hitting_server()
|
||||
{
|
||||
var options = new LdapOptions { Enabled = true, AllowInsecureLdap = true };
|
||||
var auth = new LdapUserAuthenticator(options, Microsoft.Extensions.Logging.Abstractions.NullLogger<LdapUserAuthenticator>.Instance);
|
||||
|
||||
var empty = await auth.AuthenticateAsync("", "", CancellationToken.None);
|
||||
empty.Success.ShouldBeFalse();
|
||||
empty.Error.ShouldContain("Credentials");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LdapAuthenticator_rejects_when_disabled()
|
||||
{
|
||||
var options = new LdapOptions { Enabled = false };
|
||||
var auth = new LdapUserAuthenticator(options, Microsoft.Extensions.Logging.Abstractions.NullLogger<LdapUserAuthenticator>.Instance);
|
||||
|
||||
var r = await auth.AuthenticateAsync("alice", "pw", CancellationToken.None);
|
||||
r.Success.ShouldBeFalse();
|
||||
r.Error.ShouldContain("disabled");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LdapAuthenticator_rejects_plaintext_when_both_TLS_and_insecure_are_disabled()
|
||||
{
|
||||
var options = new LdapOptions { Enabled = true, UseTls = false, AllowInsecureLdap = false };
|
||||
var auth = new LdapUserAuthenticator(options, Microsoft.Extensions.Logging.Abstractions.NullLogger<LdapUserAuthenticator>.Instance);
|
||||
|
||||
var r = await auth.AuthenticateAsync("alice", "pw", CancellationToken.None);
|
||||
r.Success.ShouldBeFalse();
|
||||
r.Error.ShouldContain("Insecure");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("hello", "hello")]
|
||||
[InlineData("hi(there)", "hi\\28there\\29")]
|
||||
[InlineData("name*", "name\\2a")]
|
||||
[InlineData("a\\b", "a\\5cb")]
|
||||
public void LdapFilter_escapes_reserved_characters(string input, string expected)
|
||||
{
|
||||
LdapUserAuthenticator.EscapeLdapFilter(input).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("cn=alice,ou=Engineering,dc=example,dc=com", "Engineering")]
|
||||
[InlineData("cn=bob,dc=example,dc=com", null)]
|
||||
[InlineData("cn=carol,ou=Ops,dc=example,dc=com", "Ops")]
|
||||
public void ExtractOuSegment_pulls_primary_group_from_DN(string dn, string? expected)
|
||||
{
|
||||
LdapUserAuthenticator.ExtractOuSegment(dn).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("cn=Operators,ou=Groups,dc=example", "Operators")]
|
||||
[InlineData("cn=LoneValue", "LoneValue")]
|
||||
[InlineData("plain-no-equals", "plain-no-equals")]
|
||||
public void ExtractFirstRdnValue_returns_first_rdn(string dn, string expected)
|
||||
{
|
||||
LdapUserAuthenticator.ExtractFirstRdnValue(dn).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpcUaServerOptions_default_is_anonymous_only()
|
||||
{
|
||||
var opts = new OpcUaServerOptions();
|
||||
opts.SecurityProfile.ShouldBe(OpcUaSecurityProfile.None);
|
||||
opts.Ldap.Enabled.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user