using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Novell.Directory.Ldap; namespace ScadaLink.Security; public class LdapAuthService { private readonly SecurityOptions _options; private readonly ILogger _logger; public LdapAuthService(IOptions options, ILogger logger) { _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task AuthenticateAsync(string username, string password, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(username)) return new LdapAuthResult(false, null, null, null, "Username is required."); if (string.IsNullOrWhiteSpace(password)) return new LdapAuthResult(false, null, null, null, "Password is required."); // Enforce TLS unless explicitly allowed for dev/test if (!_options.LdapUseTls && !_options.AllowInsecureLdap) { return new LdapAuthResult(false, null, null, null, "Insecure LDAP connections are not allowed. Enable TLS or set AllowInsecureLdap for dev/test."); } try { using var connection = new LdapConnection(); if (_options.LdapUseTls) { connection.SecureSocketLayer = true; } await Task.Run(() => connection.Connect(_options.LdapServer, _options.LdapPort), ct); if (_options.LdapUseTls && !connection.SecureSocketLayer) { await Task.Run(() => connection.StartTls(), ct); } // Resolve the user's actual DN, then bind with their credentials var bindDn = await ResolveUserDnAsync(connection, username, ct); await Task.Run(() => connection.Bind(bindDn, password), ct); // Re-bind as service account for attribute/group lookup (user may lack search rights) if (!string.IsNullOrWhiteSpace(_options.LdapServiceAccountDn)) { await Task.Run(() => connection.Bind(_options.LdapServiceAccountDn, _options.LdapServiceAccountPassword), ct); } // Query for user attributes and group memberships var displayName = username; var groups = new List(); try { var searchFilter = $"(uid={EscapeLdapFilter(username)})"; var searchResults = await Task.Run(() => connection.Search( _options.LdapSearchBase, LdapConnection.ScopeSub, searchFilter, new[] { _options.LdapDisplayNameAttribute, _options.LdapGroupAttribute }, false), ct); while (searchResults.HasMore()) { try { var entry = searchResults.Next(); var dnAttr = entry.GetAttribute(_options.LdapDisplayNameAttribute); if (dnAttr != null) displayName = dnAttr.StringValue; var groupAttr = entry.GetAttribute(_options.LdapGroupAttribute); if (groupAttr != null) { foreach (var groupDn in groupAttr.StringValueArray) { groups.Add(ExtractFirstRdnValue(groupDn)); } } } catch (LdapException) { // No more results break; } } } catch (LdapException ex) { _logger.LogWarning(ex, "Failed to query LDAP attributes for user {Username}; authentication succeeded but group lookup failed", username); // Auth succeeded even if attribute lookup failed } connection.Disconnect(); return new LdapAuthResult(true, displayName, username, groups, null); } catch (LdapException ex) { _logger.LogWarning(ex, "LDAP authentication failed for user {Username}", username); return new LdapAuthResult(false, null, username, null, "Invalid username or password."); } catch (Exception ex) when (ex is not OperationCanceledException) { _logger.LogError(ex, "Unexpected error during LDAP authentication for user {Username}", username); return new LdapAuthResult(false, null, username, null, "An unexpected error occurred during authentication."); } } /// /// Resolves the user's full DN. When a service account is configured, performs a /// search-then-bind lookup. Otherwise falls back to constructing the DN directly. /// private async Task ResolveUserDnAsync(LdapConnection connection, string username, CancellationToken ct) { // If username already looks like a DN, use it as-is if (username.Contains('=')) return username; // If a service account is configured, search for the user's actual DN if (!string.IsNullOrWhiteSpace(_options.LdapServiceAccountDn)) { await Task.Run(() => connection.Bind(_options.LdapServiceAccountDn, _options.LdapServiceAccountPassword), ct); var searchFilter = $"(uid={EscapeLdapFilter(username)})"; var searchResults = await Task.Run(() => connection.Search( _options.LdapSearchBase, LdapConnection.ScopeSub, searchFilter, new[] { "dn" }, false), ct); if (searchResults.HasMore()) { var entry = searchResults.Next(); return entry.Dn; } throw new LdapException("User not found", LdapException.NoSuchObject, $"No entry found for uid={username}"); } // Fallback: construct DN directly return string.IsNullOrWhiteSpace(_options.LdapSearchBase) ? $"cn={username}" : $"cn={username},{_options.LdapSearchBase}"; } private static string EscapeLdapFilter(string input) { return input .Replace("\\", "\\5c") .Replace("*", "\\2a") .Replace("(", "\\28") .Replace(")", "\\29") .Replace("\0", "\\00"); } private static string ExtractFirstRdnValue(string dn) { // Extract the value of the first RDN from a DN. // Handles cn=, ou=, or any attribute: "ou=SCADA-Admins,ou=groups,dc=..." → "SCADA-Admins" var equalsIndex = dn.IndexOf('='); if (equalsIndex < 0) return dn; var valueStart = equalsIndex + 1; var commaIndex = dn.IndexOf(',', valueStart); return commaIndex > valueStart ? dn[valueStart..commaIndex] : dn[valueStart..]; } }