1f8ab8e3b3
Active Directory answers any search based at a domain root with continuation references (CN=Configuration, DC=ForestDnsZones, ...). With referral following disabled -- the default, and what we want, since chasing one would re-bind somewhere we never authenticated -- Novell surfaces each by throwing LdapReferralException from Next(). That escaped NovellLdapConnection.Search, and LdapAuthService maps a failed search to ServiceAccountBindFailed. So every login against SearchBase = DC=nam,DC=zimmer,DC=com failed system-side on BOTH DCs in ~546 ms while the directory, the credentials and the service account were all healthy. Identical on each endpoint, so failover could not help either. A continuation reference is a pointer to another naming context, never a statement that this search failed, so the loop now skips them and continues -- returning exactly the entries in the naming context we bound to. Extracted as ReadEntries(ILdapSearchResults) so the handling is unit-testable through the Novell seam without a live directory. A 1000-referral cap guards a client that throws without advancing, which would otherwise spin forever inside a fail-closed auth path. Non-referral faults still propagate untouched: swallowing those would turn "the directory is broken" into "no such user", which is the wrong answer to give about someone's credentials. 7 tests, 6 verified red against the pre-fix code; the seventh is the non-referral control that must stay green either way. Suite 257/0, 2 skipped. Live-proven against the ZB NAM domain root before and after.
212 lines
8.7 KiB
C#
212 lines
8.7 KiB
C#
namespace ZB.MOM.WW.Auth.Ldap.Internal;
|
|
|
|
using Novell.Directory.Ldap;
|
|
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
|
// Disambiguate: Novell also declares a RemoteCertificateValidationCallback delegate; the seam and
|
|
// LdapConnectionOptions.ConfigureRemoteCertificateValidationCallback both use the BCL one.
|
|
using RemoteCertificateValidationCallback = System.Net.Security.RemoteCertificateValidationCallback;
|
|
|
|
/// <summary>
|
|
/// Production <see cref="ILdapConnection"/> backed by <c>Novell.Directory.Ldap.LdapConnection</c>.
|
|
/// Mirrors the connection/search idioms from ZB.MOM.WW.ScadaBridge.Security.LdapAuthService.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// TLS server-certificate validation: by default the underlying
|
|
/// <c>Novell.Directory.Ldap.NETStandard</c> client validates the server certificate against the OS
|
|
/// trust store (it does NOT blind-accept). A caller-supplied
|
|
/// <c>RemoteCertificateValidationCallback</c> overrides that default (CA pinning / SAN checks); when
|
|
/// none is supplied and <c>allowInsecure</c> is set, validation is bypassed for dev/test only.
|
|
/// </remarks>
|
|
internal sealed class NovellLdapConnection : ILdapConnection
|
|
{
|
|
private readonly LdapConnection _conn;
|
|
private bool _disposed;
|
|
|
|
/// <summary>
|
|
/// Builds the connection, wiring a TLS server-certificate validation policy: a supplied
|
|
/// <paramref name="serverCertificateValidationCallback"/> wins; otherwise <paramref name="allowInsecure"/>
|
|
/// bypasses validation (dev/test only); otherwise the OS-trust-store default applies.
|
|
/// </summary>
|
|
public NovellLdapConnection(
|
|
bool allowInsecure = false,
|
|
RemoteCertificateValidationCallback? serverCertificateValidationCallback = null)
|
|
{
|
|
if (serverCertificateValidationCallback is not null)
|
|
{
|
|
var options = new LdapConnectionOptions()
|
|
.ConfigureRemoteCertificateValidationCallback(serverCertificateValidationCallback);
|
|
_conn = new LdapConnection(options);
|
|
}
|
|
else if (allowInsecure)
|
|
{
|
|
// Dev/test only: accept any server certificate. Reachable solely when an operator has set
|
|
// AllowInsecure (rejected for plaintext-without-AllowInsecure by LdapOptionsValidator).
|
|
var options = new LdapConnectionOptions()
|
|
.ConfigureRemoteCertificateValidationCallback((_, _, _, _) => true);
|
|
_conn = new LdapConnection(options);
|
|
}
|
|
else
|
|
{
|
|
// Default: validate against the OS trust store (no blind-accept).
|
|
_conn = new LdapConnection();
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public void Connect(
|
|
string host,
|
|
int port,
|
|
LdapTransport transport,
|
|
bool allowInsecure,
|
|
int timeoutMs,
|
|
RemoteCertificateValidationCallback? serverCertificateValidationCallback)
|
|
{
|
|
// The TLS-validation policy (allowInsecure / callback) is wired at construction time on the
|
|
// LdapConnectionOptions; the per-call arguments here are accepted for seam symmetry.
|
|
ApplyTimeout(timeoutMs);
|
|
|
|
// LDAPS: TLS is negotiated at the TCP-connection level.
|
|
if (transport == LdapTransport.Ldaps)
|
|
_conn.SecureSocketLayer = true;
|
|
|
|
_conn.Connect(host, port);
|
|
|
|
// StartTLS: connect plaintext first, then upgrade inside the session.
|
|
if (transport == LdapTransport.StartTls)
|
|
{
|
|
_conn.StartTls();
|
|
|
|
if (!_conn.Tls)
|
|
throw new InvalidOperationException(
|
|
"StartTLS upgrade did not produce an encrypted session.");
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public void Bind(string dn, string password)
|
|
=> _conn.Bind(dn, password);
|
|
|
|
/// <inheritdoc/>
|
|
public IReadOnlyList<LdapSearchEntry> Search(
|
|
string searchBase,
|
|
string filter,
|
|
IReadOnlyList<string> attributes)
|
|
{
|
|
var results = _conn.Search(
|
|
searchBase,
|
|
LdapConnection.ScopeSub,
|
|
filter,
|
|
attributes.ToArray(),
|
|
typesOnly: false);
|
|
|
|
return ReadEntries(results);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Drains a result set into entries, <b>skipping continuation referrals</b>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Active Directory answers any search based at a domain root with one or more
|
|
/// continuation references (to <c>CN=Configuration</c>, <c>DC=ForestDnsZones</c> and
|
|
/// friends). With referral following disabled — the default, and what we want, since
|
|
/// chasing a referral would re-bind somewhere we did not authenticate — Novell surfaces
|
|
/// each one by throwing <see cref="LdapReferralException"/> from <see cref="ILdapSearchResults.Next"/>.
|
|
/// </para>
|
|
/// <para>
|
|
/// A continuation reference is a <em>pointer to another naming context</em>, not an error:
|
|
/// it says "there may be more results over there", never that this search failed. Letting
|
|
/// it escape made the whole search stage fail, which <c>LdapAuthService</c> maps to
|
|
/// <see cref="Abstractions.Ldap.LdapAuthFailure.ServiceAccountBindFailed"/> — so against a
|
|
/// real AD domain root, every login failed with a system-side error while the directory,
|
|
/// the credentials and both DCs were perfectly healthy. Verified against
|
|
/// <c>DC=nam,DC=zimmer,DC=com</c> on 2026-08-12.
|
|
/// </para>
|
|
/// <para>
|
|
/// Skipping is the correct behaviour rather than a workaround: the entries we can see are
|
|
/// exactly the ones in the naming context we bound to, which is the set the caller asked
|
|
/// about. Internal so the referral handling can be unit-tested through the
|
|
/// <see cref="ILdapSearchResults"/> seam without a live directory.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal static List<LdapSearchEntry> ReadEntries(ILdapSearchResults results)
|
|
{
|
|
var entries = new List<LdapSearchEntry>();
|
|
var referrals = 0;
|
|
|
|
while (true)
|
|
{
|
|
try
|
|
{
|
|
if (!results.HasMore()) break;
|
|
|
|
var entry = results.Next();
|
|
var attrs = new Dictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
foreach (LdapAttribute attr in entry.GetAttributeSet())
|
|
{
|
|
attrs[attr.Name] = attr.StringValueArray.ToList();
|
|
}
|
|
|
|
entries.Add(new LdapSearchEntry(entry.Dn, attrs));
|
|
}
|
|
catch (LdapException ex) when (ex is LdapReferralException || ex.ResultCode == LdapException.Referral)
|
|
{
|
|
// Novell dequeues the reference before throwing, so the enumeration advances and
|
|
// this terminates. The cap is a belt-and-braces guard: a client that threw without
|
|
// advancing would otherwise spin forever inside a fail-closed auth path, which is a
|
|
// far worse failure than under-reporting an implausible number of referrals.
|
|
if (++referrals > MaxReferrals) break;
|
|
}
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
/// <summary>Upper bound on continuation references tolerated in one search — see <see cref="ReadEntries"/>.</summary>
|
|
private const int MaxReferrals = 1_000;
|
|
|
|
/// <inheritdoc/>
|
|
public void Dispose()
|
|
{
|
|
if (_disposed) return;
|
|
_disposed = true;
|
|
if (_conn.Connected)
|
|
_conn.Disconnect();
|
|
_conn.Dispose();
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
private void ApplyTimeout(int timeoutMs)
|
|
{
|
|
if (timeoutMs <= 0)
|
|
return;
|
|
|
|
_conn.ConnectionTimeout = timeoutMs;
|
|
|
|
// SearchConstraints.TimeLimit is per-operation (ms). SearchConstraints getter
|
|
// returns the live LdapSearchConstraints object (read-only property), but
|
|
// TimeLimit is mutable in-place via the base LdapConstraints type.
|
|
// We then assign it back through the writable Constraints property so
|
|
// Novell picks up the change — mirrors ScadaBridge idiom.
|
|
var constraints = _conn.SearchConstraints;
|
|
constraints.TimeLimit = timeoutMs;
|
|
_conn.Constraints = constraints;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Factory that produces fresh <see cref="NovellLdapConnection"/> instances, carrying the TLS
|
|
/// server-certificate validation policy (a supplied callback, or an <c>allowInsecure</c> bypass) so
|
|
/// it is wired onto each connection at construction time.
|
|
/// </summary>
|
|
internal sealed class NovellLdapConnectionFactory(
|
|
bool allowInsecure = false,
|
|
RemoteCertificateValidationCallback? serverCertificateValidationCallback = null)
|
|
: ILdapConnectionFactory
|
|
{
|
|
public ILdapConnection Create() =>
|
|
new NovellLdapConnection(allowInsecure, serverCertificateValidationCallback);
|
|
}
|