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>
152 lines
6.2 KiB
C#
152 lines
6.2 KiB
C#
using Microsoft.Extensions.Logging;
|
|
using Novell.Directory.Ldap;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Server.Security;
|
|
|
|
/// <summary>
|
|
/// <see cref="IUserAuthenticator"/> that binds to the configured LDAP directory to validate
|
|
/// the (username, password) pair, then pulls group membership and maps to OPC UA roles.
|
|
/// Mirrors the bind-then-search pattern in <c>Admin.Security.LdapAuthService</c> but stays
|
|
/// in the Server project so the Server process doesn't take a cross-app dependency on Admin.
|
|
/// </summary>
|
|
public sealed class LdapUserAuthenticator(LdapOptions options, ILogger<LdapUserAuthenticator> logger)
|
|
: IUserAuthenticator
|
|
{
|
|
public async Task<UserAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
|
|
{
|
|
if (!options.Enabled)
|
|
return new UserAuthResult(false, null, [], "LDAP authentication disabled");
|
|
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
|
return new UserAuthResult(false, null, [], "Credentials required");
|
|
|
|
if (!options.UseTls && !options.AllowInsecureLdap)
|
|
return new UserAuthResult(false, null, [],
|
|
"Insecure LDAP is disabled. Set UseTls or AllowInsecureLdap for dev/test.");
|
|
|
|
try
|
|
{
|
|
using var conn = new LdapConnection();
|
|
if (options.UseTls) conn.SecureSocketLayer = true;
|
|
await Task.Run(() => conn.Connect(options.Server, options.Port), ct);
|
|
|
|
var bindDn = await ResolveUserDnAsync(conn, username, ct);
|
|
await Task.Run(() => conn.Bind(bindDn, password), ct);
|
|
|
|
// Rebind as service account for attribute read, if configured — otherwise the just-
|
|
// bound user reads their own entry (works when ACL permits self-read).
|
|
if (!string.IsNullOrWhiteSpace(options.ServiceAccountDn))
|
|
await Task.Run(() => conn.Bind(options.ServiceAccountDn, options.ServiceAccountPassword), ct);
|
|
|
|
var displayName = username;
|
|
var groups = new List<string>();
|
|
|
|
try
|
|
{
|
|
var filter = $"(cn={EscapeLdapFilter(username)})";
|
|
var results = await Task.Run(() =>
|
|
conn.Search(options.SearchBase, LdapConnection.ScopeSub, filter, attrs: null, typesOnly: false), ct);
|
|
|
|
while (results.HasMore())
|
|
{
|
|
try
|
|
{
|
|
var entry = results.Next();
|
|
var name = entry.GetAttribute(options.DisplayNameAttribute);
|
|
if (name is not null) displayName = name.StringValue;
|
|
|
|
var groupAttr = entry.GetAttribute(options.GroupAttribute);
|
|
if (groupAttr is not null)
|
|
{
|
|
foreach (var groupDn in groupAttr.StringValueArray)
|
|
groups.Add(ExtractFirstRdnValue(groupDn));
|
|
}
|
|
|
|
// GLAuth fallback: primary group is encoded as the ou= RDN above cn=.
|
|
if (groups.Count == 0 && !string.IsNullOrEmpty(entry.Dn))
|
|
{
|
|
var primary = ExtractOuSegment(entry.Dn);
|
|
if (primary is not null) groups.Add(primary);
|
|
}
|
|
}
|
|
catch (LdapException) { break; }
|
|
}
|
|
}
|
|
catch (LdapException ex)
|
|
{
|
|
logger.LogWarning(ex, "LDAP attribute lookup failed for {User}", username);
|
|
}
|
|
|
|
conn.Disconnect();
|
|
|
|
var roles = groups
|
|
.Where(g => options.GroupToRole.ContainsKey(g))
|
|
.Select(g => options.GroupToRole[g])
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
|
|
return new UserAuthResult(true, displayName, roles, null);
|
|
}
|
|
catch (LdapException ex)
|
|
{
|
|
logger.LogInformation("LDAP bind rejected user {User}: {Reason}", username, ex.ResultCode);
|
|
return new UserAuthResult(false, null, [], "Invalid username or password");
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
{
|
|
logger.LogError(ex, "Unexpected LDAP error for {User}", username);
|
|
return new UserAuthResult(false, null, [], "Authentication error");
|
|
}
|
|
}
|
|
|
|
private async Task<string> ResolveUserDnAsync(LdapConnection conn, string username, CancellationToken ct)
|
|
{
|
|
if (username.Contains('=')) return username; // caller passed a DN directly
|
|
|
|
if (!string.IsNullOrWhiteSpace(options.ServiceAccountDn))
|
|
{
|
|
await Task.Run(() => conn.Bind(options.ServiceAccountDn, options.ServiceAccountPassword), ct);
|
|
|
|
var filter = $"({options.UserNameAttribute}={EscapeLdapFilter(username)})";
|
|
var results = await Task.Run(() =>
|
|
conn.Search(options.SearchBase, LdapConnection.ScopeSub, filter, ["dn"], false), ct);
|
|
|
|
if (results.HasMore())
|
|
return results.Next().Dn;
|
|
|
|
throw new LdapException("User not found", LdapException.NoSuchObject,
|
|
$"No entry for uid={username}");
|
|
}
|
|
|
|
return string.IsNullOrWhiteSpace(options.SearchBase)
|
|
? $"cn={username}"
|
|
: $"cn={username},{options.SearchBase}";
|
|
}
|
|
|
|
internal static string EscapeLdapFilter(string input) =>
|
|
input.Replace("\\", "\\5c")
|
|
.Replace("*", "\\2a")
|
|
.Replace("(", "\\28")
|
|
.Replace(")", "\\29")
|
|
.Replace("\0", "\\00");
|
|
|
|
internal static string? ExtractOuSegment(string dn)
|
|
{
|
|
foreach (var segment in dn.Split(','))
|
|
{
|
|
var trimmed = segment.Trim();
|
|
if (trimmed.StartsWith("ou=", StringComparison.OrdinalIgnoreCase))
|
|
return trimmed[3..];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
internal static string ExtractFirstRdnValue(string dn)
|
|
{
|
|
var eq = dn.IndexOf('=');
|
|
if (eq < 0) return dn;
|
|
var valueStart = eq + 1;
|
|
var comma = dn.IndexOf(',', valueStart);
|
|
return comma > valueStart ? dn[valueStart..comma] : dn[valueStart..];
|
|
}
|
|
}
|