using Microsoft.Extensions.Logging; using Novell.Directory.Ldap; namespace ZB.MOM.WW.OtOpcUa.Server.Security; /// /// 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 Admin.Security.LdapAuthService but stays /// in the Server project so the Server process doesn't take a cross-app dependency on Admin. /// public sealed class LdapUserAuthenticator(LdapOptions options, ILogger logger) : IUserAuthenticator { public async Task 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(); 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 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..]; } }