Active Directory compatibility. LdapOptions xml-doc expanded with a cheat-sheet covering Server (DC FQDN), Port 389 vs 636, UseTls=true under AD LDAP-signing enforcement, dedicated read-only service account DN, sAMAccountName vs userPrincipalName vs cn trade-offs, memberOf DN shape (CN=Group,OU=...,DC=... with the CN= RDN stripped to become the GroupToRole key), and the explicit 'nested groups NOT expanded' call-out (LDAP_MATCHING_RULE_IN_CHAIN / tokenGroups is a future authenticator enhancement, not a config change). docs/security.md §'Active Directory configuration' adds a complete appsettings.json snippet with realistic AD group names (OPCUA-Operators → WriteOperate, OPCUA-Engineers → WriteConfigure, OPCUA-AlarmAck → AlarmAck, OPCUA-Tuners → WriteTune), LDAPS port 636, TLS on, insecure-LDAP off, and operator-facing notes on each field. LdapUserAuthenticatorAdCompatTests (5 unit guards): ExtractFirstRdnValue parses AD-style 'CN=OPCUA-Operators,OU=...,DC=...' DNs correctly (case-preserving — operators' GroupToRole keys stay readable); also handles mixed case and spaces in group names ('Domain Users'); also works against the OpenLDAP ou=<group>,ou=groups shape (GLAuth) so one extractor tolerates both memberOf formats common in the field; EscapeLdapFilter escapes the RFC 4515 injection set (\, *, (, ), \0) so a malicious login like 'admin)(cn=*' can't break out of the filter; default UserNameAttribute regression guard.
Test posture — Server.Tests Unit: 43 pass / 0 fail (38 prior + 5 new AD-compat guards). Server.Tests LiveLdap category: 6 pass / 0 fail against running GLAuth (would skip cleanly without). Server build clean, 0 errors, 0 warnings.
Deferred: the session-identity end-to-end check (drive a full OPC UA UserName session, then read a 'whoami' node to verify the role landed on RoleBasedIdentity). That needs a test-only address-space node and is scoped for a separate PR.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
155 lines
7.1 KiB
C#
155 lines
7.1 KiB
C#
using System.Net.Sockets;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
|
|
|
/// <summary>
|
|
/// Live-service tests against the dev GLAuth instance at <c>localhost:3893</c>. Skipped
|
|
/// when the port is unreachable so the test suite stays portable on boxes without a
|
|
/// running directory. Closes LMX follow-up #4 — the server-side <see cref="LdapUserAuthenticator"/>
|
|
/// is exercised end-to-end against a real LDAP server (same one the Admin process uses),
|
|
/// not just the flow-shape unit tests from PR 19.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The <c>Admin.Tests</c> project already has a live-bind test for its own
|
|
/// <c>LdapAuthService</c>; this pair catches divergence between the two bind paths — the
|
|
/// Server authenticator has to work even when the Server process is on a machine that
|
|
/// doesn't have the Admin assemblies loaded, and the two share no code by design
|
|
/// (cross-app dependency avoidance). If one side drifts past the other on LDAP filter
|
|
/// construction, DN resolution, or memberOf parsing, these tests surface it.
|
|
/// </remarks>
|
|
[Trait("Category", "LiveLdap")]
|
|
public sealed class LdapUserAuthenticatorLiveTests
|
|
{
|
|
private const string GlauthHost = "localhost";
|
|
private const int GlauthPort = 3893;
|
|
|
|
private static bool GlauthReachable()
|
|
{
|
|
try
|
|
{
|
|
using var client = new TcpClient();
|
|
var task = client.ConnectAsync(GlauthHost, GlauthPort);
|
|
return task.Wait(TimeSpan.FromSeconds(1)) && client.Connected;
|
|
}
|
|
catch { return false; }
|
|
}
|
|
|
|
// GLAuth dev directory groups are named identically to the OPC UA roles
|
|
// (ReadOnly / WriteOperate / WriteTune / WriteConfigure / AlarmAck), so the map is an
|
|
// identity translation. The authenticator still exercises every step of the pipeline —
|
|
// bind, memberOf lookup, group-name extraction, GroupToRole lookup — against real LDAP
|
|
// data; the identity map just means the assertion is phrased with no surprise rename
|
|
// in the middle.
|
|
private static LdapOptions GlauthOptions() => new()
|
|
{
|
|
Enabled = true,
|
|
Server = GlauthHost,
|
|
Port = GlauthPort,
|
|
UseTls = false,
|
|
AllowInsecureLdap = true,
|
|
SearchBase = "dc=lmxopcua,dc=local",
|
|
// Search-then-bind: service account resolves the user's full DN (cn=<user> lives
|
|
// under ou=<primary-group>,ou=users), the authenticator binds that DN with the
|
|
// user's password, then stays on the service-account session for memberOf lookup.
|
|
// Without this path, GLAuth ACLs block the authenticated user from reading their
|
|
// own entry in full — a plain self-search returns zero results and the role list
|
|
// ends up empty.
|
|
ServiceAccountDn = "cn=serviceaccount,dc=lmxopcua,dc=local",
|
|
ServiceAccountPassword = "serviceaccount123",
|
|
DisplayNameAttribute = "cn",
|
|
GroupAttribute = "memberOf",
|
|
UserNameAttribute = "cn", // GLAuth keys users by cn — see LdapOptions xml-doc.
|
|
GroupToRole = new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
["ReadOnly"] = "ReadOnly",
|
|
["WriteOperate"] = WriteAuthzPolicy.RoleWriteOperate,
|
|
["WriteTune"] = WriteAuthzPolicy.RoleWriteTune,
|
|
["WriteConfigure"] = WriteAuthzPolicy.RoleWriteConfigure,
|
|
["AlarmAck"] = "AlarmAck",
|
|
},
|
|
};
|
|
|
|
private static LdapUserAuthenticator NewAuthenticator() =>
|
|
new(GlauthOptions(), NullLogger<LdapUserAuthenticator>.Instance);
|
|
|
|
[Fact]
|
|
public async Task Valid_credentials_bind_and_return_success()
|
|
{
|
|
if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893 — start the dev directory to run this test.");
|
|
|
|
var result = await NewAuthenticator().AuthenticateAsync("readonly", "readonly123", TestContext.Current.CancellationToken);
|
|
|
|
result.Success.ShouldBeTrue(result.Error);
|
|
result.DisplayName.ShouldNotBeNullOrEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Writeop_user_gets_WriteOperate_role_from_group_mapping()
|
|
{
|
|
// Drives end-to-end: bind as writeop, memberOf lists the WriteOperate group, the
|
|
// authenticator surfaces WriteOperate via GroupToRole. If this test fails,
|
|
// WriteAuthzPolicy.IsAllowed for an Operate-tier write would also fail
|
|
// (WriteOperate is the exact string the policy checks for), so the failure mode is
|
|
// concrete, not abstract.
|
|
if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893 — start the dev directory to run this test.");
|
|
|
|
var result = await NewAuthenticator().AuthenticateAsync("writeop", "writeop123", TestContext.Current.CancellationToken);
|
|
|
|
result.Success.ShouldBeTrue(result.Error);
|
|
result.Roles.ShouldContain(WriteAuthzPolicy.RoleWriteOperate);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Admin_user_gets_multiple_roles_from_multiple_groups()
|
|
{
|
|
if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893 — start the dev directory to run this test.");
|
|
|
|
// 'admin' has primarygroup=ReadOnly and othergroups=[WriteOperate, AlarmAck,
|
|
// WriteTune, WriteConfigure] per the GLAuth dev config — the authenticator must
|
|
// surface every mapped role, not just the primary group. Guards against a regression
|
|
// where the memberOf parsing stops after the first match or misses the primary-group
|
|
// fallback.
|
|
var result = await NewAuthenticator().AuthenticateAsync("admin", "admin123", TestContext.Current.CancellationToken);
|
|
|
|
result.Success.ShouldBeTrue(result.Error);
|
|
result.Roles.ShouldContain(WriteAuthzPolicy.RoleWriteOperate);
|
|
result.Roles.ShouldContain(WriteAuthzPolicy.RoleWriteTune);
|
|
result.Roles.ShouldContain(WriteAuthzPolicy.RoleWriteConfigure);
|
|
result.Roles.ShouldContain("AlarmAck");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Wrong_password_returns_failure()
|
|
{
|
|
if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893 — start the dev directory to run this test.");
|
|
|
|
var result = await NewAuthenticator().AuthenticateAsync("readonly", "wrong-pw", TestContext.Current.CancellationToken);
|
|
|
|
result.Success.ShouldBeFalse();
|
|
result.Error.ShouldNotBeNullOrEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Unknown_user_returns_failure()
|
|
{
|
|
if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893 — start the dev directory to run this test.");
|
|
|
|
var result = await NewAuthenticator().AuthenticateAsync("no-such-user-42", "whatever", TestContext.Current.CancellationToken);
|
|
|
|
result.Success.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Empty_credentials_fail_without_touching_the_directory()
|
|
{
|
|
// Pre-flight guard — doesn't require GLAuth.
|
|
var result = await NewAuthenticator().AuthenticateAsync("", "", TestContext.Current.CancellationToken);
|
|
result.Success.ShouldBeFalse();
|
|
result.Error.ShouldContain("Credentials", Case.Insensitive);
|
|
}
|
|
}
|