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.LdapTransport == LdapTransport.None && !_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(); // Bound how long a hung LDAP server can pin a thread-pool thread. The // `ct` passed to Task.Run below only prevents the work item from starting; // it cannot interrupt an in-progress blocking Connect/Bind/Search. This // timeout is the real safeguard (Security-009). ApplyConnectionTimeout(connection); // LDAPS: TLS negotiated at connection time. StartTLS: connect plaintext, // then upgrade the session before any credentials are sent. if (_options.LdapTransport == LdapTransport.Ldaps) { connection.SecureSocketLayer = true; } await Task.Run(() => connection.Connect(_options.LdapServer, _options.LdapPort), ct); if (_options.LdapTransport == LdapTransport.StartTls) { await Task.Run(() => connection.StartTls(), ct); if (!connection.Tls) { return new LdapAuthResult(false, null, null, null, "StartTLS upgrade did not produce an encrypted session."); } } // 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 = $"({_options.LdapUserIdAttribute}={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."); } } /// /// Applies to both the socket /// connect timeout and the per-operation (bind/search) time limit, so a hung or /// unresponsive LDAP server cannot pin a thread-pool thread indefinitely. The /// CancellationToken handed to the Task.Run wrappers only guards /// work-item scheduling and cannot interrupt an in-progress blocking call. /// private void ApplyConnectionTimeout(LdapConnection connection) { var timeoutMs = _options.LdapConnectionTimeoutMs; if (timeoutMs <= 0) return; connection.ConnectionTimeout = timeoutMs; // LdapConstraints.TimeLimit is the server-side operation time limit in ms. var constraints = connection.Constraints; constraints.TimeLimit = timeoutMs; connection.Constraints = constraints; } /// /// 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 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 = $"({_options.LdapUserIdAttribute}={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 {_options.LdapUserIdAttribute}={username}"); } // Fallback: construct the bind DN directly from the configured user-id // attribute. The username is RFC 4514 DN-escaped so it cannot alter the // DN structure (Security-005). The previous Contains('=') shortcut that // accepted a raw caller-supplied DN has been removed — accepting an // arbitrary DN from untrusted input let a client choose the bind identity. return BuildFallbackUserDn(username, _options.LdapSearchBase, _options.LdapUserIdAttribute); } /// /// Builds the no-service-account fallback bind DN as /// {userIdAttribute}={escaped-username}[,{searchBase}]. The username is /// escaped per RFC 4514 so DN metacharacters in untrusted input cannot inject /// additional RDN components or change the bind identity. /// public static string BuildFallbackUserDn(string username, string searchBase, string userIdAttribute) { var rdn = $"{userIdAttribute}={EscapeLdapDn(username)}"; return string.IsNullOrWhiteSpace(searchBase) ? rdn : $"{rdn},{searchBase}"; } /// /// Escapes a string for use as an RFC 4514 DN attribute value: the special /// characters , + " \ < > ; are backslash-escaped, as are a leading /// or trailing space and a leading #. /// public static string EscapeLdapDn(string input) { if (string.IsNullOrEmpty(input)) return input; var sb = new System.Text.StringBuilder(input.Length + 8); for (var i = 0; i < input.Length; i++) { var c = input[i]; var isEdgeSpace = c == ' ' && (i == 0 || i == input.Length - 1); var isLeadingHash = c == '#' && i == 0; switch (c) { case ',': case '+': case '"': case '\\': case '<': case '>': case ';': sb.Append('\\').Append(c); break; case '\0': sb.Append("\\00"); break; default: if (isEdgeSpace || isLeadingHash) sb.Append('\\'); sb.Append(c); break; } } return sb.ToString(); } 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..]; } }