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)); } // virtual: a test seam so HTTP-pipeline tests (e.g. the #23 M8 audit // endpoints) can substitute the LDAP bind without standing up a directory. public virtual 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."); // Trim once, up front: a username with leading/trailing whitespace (copy-paste // artefacts, mobile keyboards) is otherwise passed verbatim into the LDAP filter, // the fallback bind DN, and — most consequentially — the JWT Username claim and // audit trail, producing two distinct identities for the same person // (Security-015). The IsNullOrWhiteSpace guard above already rejects an // all-whitespace value, so the trimmed result here is always non-empty. username = NormalizeUsername(username); // 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(); var groupLookupSucceeded = true; 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); // `HasMore()` is the loop guard for end-of-results; it returns false // when the enumeration is exhausted. An LdapException thrown by // `Next()` inside a HasMore()-guarded loop is therefore NOT a benign // "no more results" sentinel — it is a genuine error (referral failure, // server-side limit, transport drop mid-enumeration). The previous // `catch (LdapException) { break; }` silently truncated the group list // and masked a partial outage (Security-012); such an exception now // propagates to the outer catch and fails the login. while (searchResults.HasMore()) { 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 ex) { // A failed group/attribute lookup on initial login means the directory // is partially unavailable. The design's LDAP-failure rule requires new // logins to FAIL when LDAP is unavailable — admitting the user here // would yield an authenticated session with zero roles (Security-012). _logger.LogWarning(ex, "LDAP group/attribute lookup failed for user {Username}; failing the login per the LDAP-failure rule", username); groupLookupSucceeded = false; } connection.Disconnect(); return BuildAuthResultFromGroupLookup(username, displayName, groups, groupLookupSucceeded); } 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"); } /// /// Normalises a username by trimming leading and trailing whitespace. Applied once /// at the top of so the same canonical value flows /// into the LDAP filter, the fallback bind DN, and the JWT Username claim — /// avoiding two distinct identities for the same person (Security-015). /// public static string NormalizeUsername(string username) => username?.Trim() ?? string.Empty; /// /// Builds the final for a login attempt once the user /// bind has succeeded. When the group/attribute lookup failed /// ( is false) the directory is partially /// unavailable, so the login is FAILED per the design's LDAP-failure rule rather /// than returning an authenticated session with zero roles (Security-012). When the /// lookup succeeded, an empty list is a genuine /// "no mapped groups" outcome and the login succeeds. /// public static LdapAuthResult BuildAuthResultFromGroupLookup( string username, string displayName, IReadOnlyList groups, bool groupLookupSucceeded) { if (!groupLookupSucceeded) { return new LdapAuthResult(false, null, username, null, "The directory is temporarily unavailable. Please try again."); } return new LdapAuthResult(true, displayName, username, groups, null); } /// /// Extracts the value of the first RDN from a DN, e.g. /// ou=SCADA-Admins,ou=groups,dc=...SCADA-Admins. The scan is /// RFC 4514 escape-aware: a backslash-escaped , inside the RDN value does /// not terminate it, and recognised escape sequences are unescaped, so a group CN /// that legitimately contains a comma is returned intact (Security-013). /// public static string ExtractFirstRdnValue(string dn) { if (string.IsNullOrEmpty(dn)) return dn; var equalsIndex = dn.IndexOf('='); if (equalsIndex < 0) return dn; var valueStart = equalsIndex + 1; var sb = new System.Text.StringBuilder(dn.Length - valueStart); for (var i = valueStart; i < dn.Length; i++) { var c = dn[i]; if (c == '\\' && i + 1 < dn.Length) { var next = dn[i + 1]; // RFC 4514 hex escape: \XX (two hex digits). if (i + 2 < dn.Length && IsHexDigit(next) && IsHexDigit(dn[i + 2])) { sb.Append((char)Convert.ToInt32(dn.Substring(i + 1, 2), 16)); i += 2; } else { // Single-character escape (e.g. \, \+ \\ \" \; etc.) — emit the // escaped character literally and skip the backslash. sb.Append(next); i += 1; } continue; } if (c == ',') { // Unescaped comma terminates the first RDN. break; } sb.Append(c); } return sb.ToString(); } private static bool IsHexDigit(char c) => (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); }