using Novell.Directory.Ldap; using ZB.MOM.WW.Auth.Abstractions.Ldap; using ZB.MOM.WW.Auth.Ldap.Internal; namespace ZB.MOM.WW.Auth.Ldap; /// /// Authenticates a user against an LDAP directory using the bind-then-search idiom: /// bind as the service account, search for the user entry, then re-bind as the user /// to verify their password. Connection mechanics are delegated to an /// so the logic is unit-testable without a live server. /// /// /// Fully fail-closed: authentication never throws to the caller — every error, expected /// or unexpected, is mapped to a structured . /// A success is only returned for a user that resolved to exactly one entry, whose password /// verified, AND who has at least one group (zero groups is never admitted as success). /// Service-account bind failures () are /// kept distinct from end-user bind failures () so /// a system misconfiguration is not mistaken for bad user input. /// public sealed class LdapAuthService : ILdapAuthService { private readonly LdapOptions _options; private readonly ILdapConnectionFactory _connectionFactory; /// /// Production constructor: binds against a live directory via the real /// Novell-backed connection factory. The TLS server-certificate validation policy /// ( or the /// bypass) is carried into the factory so each /// connection is built with it. /// public LdapAuthService(LdapOptions options) : this(options, new NovellLdapConnectionFactory( options.AllowInsecure, options.ServerCertificateValidationCallback)) { } /// /// Test/seam constructor: accepts an injected /// so the bind/search logic can be exercised without a live directory. Internal /// because the connection seam is an implementation detail. /// internal LdapAuthService(LdapOptions options, ILdapConnectionFactory connectionFactory) { _options = options; _connectionFactory = connectionFactory; } /// /// /// Fail-closed contract: this method never throws to the caller. Every stage of the /// bind-then-search-then-bind flow is wrapped so that any error — expected or unexpected — /// is mapped to a structured . A /// Succeeded == true result is only ever returned when the user resolved to exactly /// one entry, their password verified, AND at least one group was extracted. /// public Task AuthenticateAsync(string username, string password, CancellationToken ct) { // The Novell calls behind ILdapConnection are synchronous and blocking, so the token // cannot interrupt an in-progress operation; it is only observed here at entry, before // any work begins. ct.ThrowIfCancellationRequested(); return Task.FromResult(Authenticate(username, password)); } private LdapAuthResult Authenticate(string username, string password) { // 1. Feature gate: an explicitly disabled provider must never touch the network. if (!_options.Enabled) return LdapAuthResult.Fail(LdapAuthFailure.Disabled); // 2. Reject a missing username before anything else — guarding here means a null // username can't NRE into the catch-all and surface as a system-side failure. if (string.IsNullOrWhiteSpace(username)) return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials); // 3. Normalise once, up front, so the same canonical value flows into the LDAP // filter and the returned result (avoids two identities for one person). username = username.Trim(); // The whole flow runs inside an outer fail-closed guard: a StageFailure carries an // already-mapped failure out of a stage, and any OTHER unexpected exception defaults to // the most conservative system-side bucket. Either way the caller gets a structured result. try { using var conn = _connectionFactory.Create(); // 4. Open the connection (transport/TLS handling lives in the adapter). The // per-operation timeout (ConnectionTimeoutMs) is applied by the adapter here. // A failure to connect/upgrade means the directory is unreachable — a // system-side fault, not the user's, so map it to ServiceAccountBindFailed. // NOTE: the LdapAuthFailure enum has no dedicated DirectoryUnavailable value; // ServiceAccountBindFailed is the closest system-side bucket. A future // Abstractions change could add DirectoryUnavailable to disambiguate. try { conn.Connect( _options.Server, _options.Port, _options.Transport, _options.AllowInsecure, _options.ConnectionTimeoutMs, _options.ServerCertificateValidationCallback); } catch (LdapException) { throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed); } // 5. Service-account bind so we can search for the user's DN. A bind failure // here is a service-account misconfiguration — DISTINCT from a user-credential // failure — so it maps to ServiceAccountBindFailed. try { conn.Bind(_options.ServiceAccountDn, _options.ServiceAccountPassword); } catch (LdapException) { throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed); } // 6. Search for the user entry by the configured username attribute. A search // failure is infrastructure (directory unreachable / unhealthy) — system-side. // NOTE: same enum limitation as Connect above; ServiceAccountBindFailed is the // closest system-side bucket until a DirectoryUnavailable value exists. IReadOnlyList entries; try { var filter = $"({_options.UserNameAttribute}={LdapEscaping.Filter(username)})"; entries = conn.Search(_options.SearchBase, filter, BuildSearchAttributes()); } catch (LdapException) { throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed); } // 7. Require exactly one match. Zero -> UserNotFound; two or more -> AmbiguousUser. // We never attempt a user bind against an ambiguous DN. if (entries.Count == 0) return LdapAuthResult.Fail(LdapAuthFailure.UserNotFound); if (entries.Count >= 2) return LdapAuthResult.Fail(LdapAuthFailure.AmbiguousUser); var entry = entries[0]; // 8. User bind: re-bind as the resolved DN to verify the password. A bind failure // here is the end user's bad credentials. try { conn.Bind(entry.Dn, password); } catch (LdapException) { throw new StageFailure(LdapAuthFailure.BadCredentials); } // 9. Group extraction. Fail closed: an empty/missing group set, or any error while // extracting groups, is a GroupLookupFailed — never a zero-group success. IReadOnlyList groups; try { groups = ExtractGroups(entry); } catch { throw new StageFailure(LdapAuthFailure.GroupLookupFailed); } if (groups.Count == 0) return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed); // 10. Success — and only here, with a verified password and >= 1 group. var displayName = ExtractDisplayName(entry, username); return LdapAuthResult.Success(username, displayName, groups); } catch (StageFailure stage) { // A stage mapped its own failure; surface it as a structured result. return LdapAuthResult.Fail(stage.Failure); } catch { // Belt-and-braces: ANY unexpected exception fails closed to the most conservative // system-side bucket rather than propagating to the caller. return LdapAuthResult.Fail(LdapAuthFailure.ServiceAccountBindFailed); } } /// /// Internal control-flow exception that carries an already-mapped /// out of a stage to the single fail-closed catch site. Never escapes this type. /// private sealed class StageFailure : Exception { public StageFailure(LdapAuthFailure failure) => Failure = failure; public LdapAuthFailure Failure { get; } } /// /// Builds the distinct attribute list requested from the directory. The display-name /// and group attributes are de-duplicated so we never request the same attribute twice /// when an operator configures them to the same value. /// private IReadOnlyList BuildSearchAttributes() { if (string.Equals(_options.DisplayNameAttribute, _options.GroupAttribute, StringComparison.OrdinalIgnoreCase)) return new[] { _options.DisplayNameAttribute }; return new[] { _options.DisplayNameAttribute, _options.GroupAttribute }; } /// /// Returns the first value of the configured display-name attribute, falling back to /// the (already normalised) username when the directory entry has no such attribute. /// private string ExtractDisplayName(LdapSearchEntry entry, string username) { if (entry.Attributes.TryGetValue(_options.DisplayNameAttribute, out var values) && values.Count > 0) return values[0]; return username; } /// /// Extracts group short names from the configured group attribute. Each value is a /// group DN (e.g. cn=Engineers,ou=g,dc=x); the first RDN's value is returned /// (e.g. Engineers), RFC 4514 escape-aware so an escaped comma in the CN is /// preserved rather than truncating the name. /// private IReadOnlyList ExtractGroups(LdapSearchEntry entry) { if (!entry.Attributes.TryGetValue(_options.GroupAttribute, out var values) || values.Count == 0) return Array.Empty(); var groups = new List(values.Count); foreach (var value in values) groups.Add(ToGroupShortName(value)); return groups; } /// /// Yields a group's short name from its DN by returning the value of the first RDN /// (e.g. cn=Engineers,ou=g,dc=xEngineers). The extraction is RFC 4514 /// escape-aware (), so a CN that legitimately /// contains an escaped comma — cn=Eng\,ineers,... or cn=A\2cB,... — is /// returned intact rather than truncated at the escaped comma. Values with no = /// are returned unchanged. /// private static string ToGroupShortName(string groupDn) => LdapEscaping.FirstRdnValue(groupDn); }