using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Novell.Directory.Ldap; namespace ZB.MOM.WW.OtOpcUa.Admin.Security; /// /// LDAP bind-and-search authentication mirrored from ScadaLink's LdapAuthService /// (CLAUDE.md memory: scadalink_reference.md) — same bind semantics, TLS guard, and /// service-account search-then-bind path. Adapted for the Admin app's role-mapping shape /// (LDAP group names → Admin roles via ). /// public sealed class LdapAuthService(IOptions options, ILogger logger) : ILdapAuthService { private readonly LdapOptions _options = options.Value; public async Task AuthenticateAsync(string username, string password, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(username)) return new(false, null, null, [], [], "Username is required"); if (string.IsNullOrWhiteSpace(password)) return new(false, null, null, [], [], "Password is required"); if (!_options.UseTls && !_options.AllowInsecureLdap) return new(false, null, username, [], [], "Insecure LDAP is disabled. Enable UseTls or set 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); 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, // request ALL attributes so we can inspect memberOf + dn-derived group 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)); } // Fallback: GLAuth places users under ou=PrimaryGroup,baseDN. When the // directory doesn't populate memberOf (or populates it differently), the // user's primary group name is recoverable from the second RDN of the DN. if (groups.Count == 0 && !string.IsNullOrEmpty(entry.Dn)) { var primary = ExtractOuSegment(entry.Dn); if (primary is not null) groups.Add(primary); } } catch (LdapException) { break; } // no-more-entries signalled by exception } } catch (LdapException ex) { logger.LogWarning(ex, "LDAP attribute lookup failed for {User}", username); } conn.Disconnect(); var roles = RoleMapper.Map(groups, _options.GroupToRole); return new(true, displayName, username, groups, roles, null); } catch (LdapException ex) { logger.LogWarning(ex, "LDAP bind failed for {User}", username); return new(false, null, username, [], [], "Invalid username or password"); } catch (Exception ex) when (ex is not OperationCanceledException) { logger.LogError(ex, "Unexpected LDAP error for {User}", username); return new(false, null, username, [], [], "Unexpected authentication error"); } } private async Task ResolveUserDnAsync(LdapConnection conn, string username, CancellationToken ct) { if (username.Contains('=')) return username; // already a DN if (!string.IsNullOrWhiteSpace(_options.ServiceAccountDn)) { await Task.Run(() => conn.Bind(_options.ServiceAccountDn, _options.ServiceAccountPassword), ct); var filter = $"(uid={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"); /// /// Pulls the first ou=Value segment from a DN. GLAuth encodes a user's primary /// group as an ou= RDN immediately above the user's cn=, so this recovers /// the group name when is absent from the entry. /// internal static string? ExtractOuSegment(string dn) { var segments = dn.Split(','); foreach (var segment in segments) { var trimmed = segment.Trim(); if (trimmed.StartsWith("ou=", StringComparison.OrdinalIgnoreCase)) return trimmed[3..]; } return null; } internal static string ExtractFirstRdnValue(string dn) { var equalsIdx = dn.IndexOf('='); if (equalsIdx < 0) return dn; var valueStart = equalsIdx + 1; var commaIdx = dn.IndexOf(',', valueStart); return commaIdx > valueStart ? dn[valueStart..commaIdx] : dn[valueStart..]; } }