diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapAuthService.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapAuthService.cs
index bbce996..676cbd6 100644
--- a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapAuthService.cs
+++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapAuthService.cs
@@ -38,6 +38,10 @@ namespace ZB.MOM.WW.Auth.Ldap;
/// only mutable state; concurrent logins may race on it, which is benign — the worst outcome
/// is one login starting at a stale endpoint and walking on.
///
+///
+/// With BindMode = User the service-account bind and the user re-bind are replaced by a
+/// single bind as the user (); see .
+///
///
public sealed class LdapAuthService : ILdapAuthService
{
@@ -141,6 +145,18 @@ public sealed class LdapAuthService : ILdapAuthService
// filter and the returned result (avoids two identities for one person).
username = username.Trim();
+ // 3b. User-bind mode guards, before any network call. An empty password must never reach
+ // the directory: AD answers a simple bind with an empty password as an UNAUTHENTICATED
+ // bind and reports success, which would admit anyone. The name is reduced to the bare
+ // account name so the bind template, the search filter and the result share one spelling.
+ if (_options.BindMode == LdapBindMode.User)
+ {
+ if (string.IsNullOrEmpty(password))
+ return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
+ if (!LdapUserName.TryNormalize(username, _options.AcceptDomainQualifiedNames, out username))
+ return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
+ }
+
// 4. Walk the endpoint list, preferred one first. A single-endpoint configuration
// (no FallbackServers) collapses to exactly one attempt — unchanged behaviour.
var preferred = Volatile.Read(ref _preferredIndex);
@@ -235,74 +251,9 @@ public sealed class LdapAuthService : ILdapAuthService
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "connect");
}
- // 6. 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, "service-bind");
- }
-
- // 7. 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, "search");
- }
-
- // 8. Require exactly one match. Zero -> UserNotFound; two or more -> AmbiguousUser.
- // Both are authoritative answers from a healthy directory, so they end the walk.
- // 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];
-
- // 9. User bind: re-bind as the resolved DN to verify the password. A bind failure
- // here is the end user's bad credentials — the directory answered, so this is
- // returned directly and never retried against another endpoint.
- try
- {
- conn.Bind(entry.Dn, password);
- }
- catch (LdapException)
- {
- return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
- }
-
- // 10. Group extraction. Fail closed: an empty/missing group set, or any error while
- // extracting groups, is a GroupLookupFailed — never a zero-group success. This
- // too is an answer about the user, not about the endpoint, so it ends the walk.
- IReadOnlyList groups;
- try
- {
- groups = ExtractGroups(entry);
- }
- catch
- {
- return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed);
- }
-
- if (groups.Count == 0)
- return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed);
-
- // 11. Success — and only here, with a verified password and >= 1 group.
- var displayName = ExtractDisplayName(entry, username);
- return LdapAuthResult.Success(username, displayName, groups);
+ return _options.BindMode == LdapBindMode.User
+ ? CompleteAsUser(conn, username, password)
+ : CompleteWithServiceAccount(conn, username, password);
}
catch (StageFailure stage)
{
@@ -323,6 +274,106 @@ public sealed class LdapAuthService : ILdapAuthService
}
}
+ /// Steps 6–11 of the 0.3.0 flow: service-account bind, search, user re-bind, groups.
+ private LdapAuthResult CompleteWithServiceAccount(ZB.MOM.WW.Auth.Ldap.Internal.ILdapConnection conn, string username, string password)
+ {
+ // 6. 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.
+ try
+ {
+ conn.Bind(_options.ServiceAccountDn, _options.ServiceAccountPassword);
+ }
+ catch (LdapException)
+ {
+ throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "service-bind");
+ }
+
+ // 7–8. Exactly one match. Zero -> UserNotFound; two or more -> AmbiguousUser.
+ var entries = SearchUser(conn, username);
+ if (entries.Count == 0)
+ return LdapAuthResult.Fail(LdapAuthFailure.UserNotFound);
+ if (entries.Count >= 2)
+ return LdapAuthResult.Fail(LdapAuthFailure.AmbiguousUser);
+
+ var entry = entries[0];
+
+ // 9. User bind verifies the password; a failure is the user's bad credentials and final.
+ try
+ {
+ conn.Bind(entry.Dn, password);
+ }
+ catch (LdapException)
+ {
+ return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
+ }
+
+ return SucceedFromEntry(entry, username);
+ }
+
+ ///
+ /// : the user's own bind is the authentication; the same
+ /// connection then reads their entry. Only result 49 (invalid credentials — AD's answer for a
+ /// wrong password AND for unknown, locked, disabled or expired accounts) is the user's failure
+ /// and final; any other bind error means this endpoint gave no answer, so the walk moves on.
+ ///
+ private LdapAuthResult CompleteAsUser(ZB.MOM.WW.Auth.Ldap.Internal.ILdapConnection conn, string username, string password)
+ {
+ var bindName = _options.UserBindTemplate.Replace("{0}", username, StringComparison.Ordinal);
+ try
+ {
+ conn.Bind(bindName, password);
+ }
+ catch (LdapException ex) when (ex.ResultCode == LdapException.InvalidCredentials)
+ {
+ return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
+ }
+ catch (LdapException)
+ {
+ throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "user-bind");
+ }
+
+ // After a good bind, anything but exactly one readable entry is a failure to establish the
+ // user's groups — never a success, and never UserNotFound (the directory just accepted them).
+ var entries = SearchUser(conn, username);
+ if (entries.Count != 1)
+ return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed);
+
+ return SucceedFromEntry(entries[0], username);
+ }
+
+ /// Step 7: search for the user entry; a search fault is system-side.
+ private IReadOnlyList SearchUser(ZB.MOM.WW.Auth.Ldap.Internal.ILdapConnection conn, string username)
+ {
+ try
+ {
+ var filter = $"({_options.UserNameAttribute}={LdapEscaping.Filter(username)})";
+ return conn.Search(_options.SearchBase, filter, BuildSearchAttributes());
+ }
+ catch (LdapException)
+ {
+ throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "search");
+ }
+ }
+
+ /// Steps 10–11: fail-closed group extraction, then success.
+ private LdapAuthResult SucceedFromEntry(LdapSearchEntry entry, string username)
+ {
+ IReadOnlyList groups;
+ try
+ {
+ groups = ExtractGroups(entry);
+ }
+ catch
+ {
+ return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed);
+ }
+
+ if (groups.Count == 0)
+ return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed);
+
+ return LdapAuthResult.Success(username, ExtractDisplayName(entry, username), groups);
+ }
+
///
/// Internal control-flow exception that carries an already-mapped
/// out of a stage to the single fail-closed catch site. Never escapes this type.
@@ -337,7 +388,7 @@ public sealed class LdapAuthService : ILdapAuthService
public LdapAuthFailure Failure { get; }
- /// Which stage faulted (connect / service-bind / search), for diagnostics.
+ /// Which stage faulted (connect / service-bind / user-bind / search), for diagnostics.
public string Stage { get; }
}
diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/FakeLdapConnection.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/FakeLdapConnection.cs
index 97ec79d..ddb3e23 100644
--- a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/FakeLdapConnection.cs
+++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/FakeLdapConnection.cs
@@ -16,6 +16,8 @@ internal sealed class FakeLdapConnection : ILdapConnection
private bool _throwOnConnect;
private bool _throwOnServiceBind;
private bool _throwOnUserBind;
+ private int? _firstBindResultCode;
+ private bool _throwOnSearch;
// ---- observation -----
@@ -32,6 +34,9 @@ internal sealed class FakeLdapConnection : ILdapConnection
///
public int BindAttempts { get; private set; }
+ /// Every call in order: connect, bind:<dn>, search:<filter>.
+ public List Calls { get; } = new();
+
// ---- builder methods -----
///
@@ -83,6 +88,24 @@ internal sealed class FakeLdapConnection : ILdapConnection
return this;
}
+ ///
+ /// Throw with on the
+ /// FIRST bind. In user-bind mode the first bind is the user's own, so 49 simulates a wrong
+ /// password and e.g. 52 (Unavailable) a domain controller that cannot answer.
+ ///
+ public FakeLdapConnection ThrowOnFirstBind(int resultCode)
+ {
+ _firstBindResultCode = resultCode;
+ return this;
+ }
+
+ /// Throw from (unhealthy directory).
+ public FakeLdapConnection ThrowOnSearch()
+ {
+ _throwOnSearch = true;
+ return this;
+ }
+
///
/// Throw from to
/// simulate an unreachable directory (infrastructure failure).
@@ -120,6 +143,7 @@ internal sealed class FakeLdapConnection : ILdapConnection
int timeoutMs,
RemoteCertificateValidationCallback? serverCertificateValidationCallback = null)
{
+ Calls.Add("connect");
ConnectArgs = (host, port, transport, allowInsecure, timeoutMs);
ConnectCertCallback = serverCertificateValidationCallback;
if (_throwOnConnect)
@@ -130,8 +154,12 @@ internal sealed class FakeLdapConnection : ILdapConnection
public void Bind(string dn, string password)
{
BindAttempts++;
+ Calls.Add($"bind:{dn}");
var isServiceBind = BindAttempts == 1;
+ if (isServiceBind && _firstBindResultCode is { } code)
+ throw new Novell.Directory.Ldap.LdapException("Bind refused", code, dn);
+
if ((_throwOnServiceBind && isServiceBind)
|| (_throwOnUserBind && !isServiceBind)
|| _throwBindDns.Contains(dn))
@@ -147,7 +175,13 @@ internal sealed class FakeLdapConnection : ILdapConnection
string searchBase,
string filter,
IReadOnlyList attributes)
- => _scriptedEntries.AsReadOnly();
+ {
+ Calls.Add($"search:{filter}");
+ if (_throwOnSearch)
+ throw new Novell.Directory.Ldap.LdapException(
+ "Search failed", Novell.Directory.Ldap.LdapException.Unavailable, searchBase);
+ return _scriptedEntries.AsReadOnly();
+ }
public void Dispose() { /* nothing to clean up */ }
}
diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapAuthServiceUserBindTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapAuthServiceUserBindTests.cs
new file mode 100644
index 0000000..1276c1a
--- /dev/null
+++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapAuthServiceUserBindTests.cs
@@ -0,0 +1,181 @@
+using Novell.Directory.Ldap;
+using ZB.MOM.WW.Auth.Abstractions.Ldap;
+
+namespace ZB.MOM.WW.Auth.Ldap.Tests;
+
+///
+/// : bind as the signing-in user, then read their own entry on the
+/// same connection. The service account in is deliberately populated so the
+/// tests prove it is never used.
+///
+public class LdapAuthServiceUserBindTests
+{
+ private static LdapOptions Opts(params string[] fallbacks) => new()
+ {
+ Enabled = true,
+ Server = "dc1",
+ Port = 636,
+ Transport = LdapTransport.Ldaps,
+ SearchBase = "dc=x",
+ UserNameAttribute = "sAMAccountName",
+ DisplayNameAttribute = "displayName",
+ GroupAttribute = "memberOf",
+ BindMode = LdapBindMode.User,
+ UserBindTemplate = "{0}@corp.test",
+ ServiceAccountDn = "cn=svc,dc=x",
+ ServiceAccountPassword = "svcpw",
+ FallbackServers = fallbacks,
+ };
+
+ private static FakeLdapConnection Jane() => new FakeLdapConnection()
+ .WithUserEntry("CN=Jane Doe,OU=u,DC=x", new[] { "CN=UG_A,OU=g,DC=x", "CN=UG_B,OU=g,DC=x" }, "Jane Doe");
+
+ private static Task Login(LdapAuthService svc, string user, string pw = "pw") =>
+ svc.AuthenticateAsync(user, pw, CancellationToken.None);
+
+ [Fact]
+ public async Task Success_BindsAsUser_ThenSearchesOnTheSameConnection()
+ {
+ var factory = new SequenceLdapConnectionFactory(Jane());
+ var result = await Login(new LdapAuthService(Opts(), factory), "jdoe");
+
+ Assert.True(result.Succeeded);
+ Assert.Equal("jdoe", result.Username);
+ Assert.Equal("Jane Doe", result.DisplayName);
+ Assert.Equal(new[] { "UG_A", "UG_B" }, result.Groups);
+ var conn = Assert.Single(factory.Created);
+ Assert.Equal(new[] { "connect", "bind:jdoe@corp.test", "search:(sAMAccountName=jdoe)" }, conn.Calls);
+ Assert.Equal(new[] { "jdoe@corp.test" }, conn.BoundDns);
+ }
+
+ [Theory]
+ [InlineData(@"NAM\jdoe")]
+ [InlineData("jdoe@corp.test")]
+ [InlineData(" jdoe ")]
+ public async Task QualifiedNames_BindAsTheTemplate_AndReturnTheBareName(string typed)
+ {
+ var factory = new SequenceLdapConnectionFactory(Jane());
+ var result = await Login(new LdapAuthService(Opts(), factory), typed);
+
+ Assert.True(result.Succeeded);
+ Assert.Equal("jdoe", result.Username);
+ Assert.Equal(new[] { "jdoe@corp.test" }, factory.Created[0].BoundDns);
+ }
+
+ [Fact]
+ public async Task EmptyPassword_IsRefused_WithoutContactingTheDirectory()
+ {
+ var factory = new SequenceLdapConnectionFactory(Jane());
+ var result = await Login(new LdapAuthService(Opts(), factory), "jdoe", "");
+
+ Assert.Equal(LdapAuthFailure.BadCredentials, result.Failure);
+ Assert.Empty(factory.Created);
+ }
+
+ [Theory]
+ [InlineData("@x")]
+ [InlineData(@"a\b\c")]
+ [InlineData("a@b@c")]
+ public async Task UnusableNames_AreRefused_WithoutContactingTheDirectory(string typed)
+ {
+ var factory = new SequenceLdapConnectionFactory(Jane());
+ var result = await Login(new LdapAuthService(Opts(), factory), typed);
+
+ Assert.Equal(LdapAuthFailure.BadCredentials, result.Failure);
+ Assert.Empty(factory.Created);
+ }
+
+ [Fact]
+ public async Task StrictMode_RefusesQualifiedNames_WithoutContactingTheDirectory()
+ {
+ var factory = new SequenceLdapConnectionFactory(Jane());
+ var svc = new LdapAuthService(Opts() with { AcceptDomainQualifiedNames = false }, factory);
+
+ var result = await Login(svc, @"NAM\jdoe");
+
+ Assert.Equal(LdapAuthFailure.BadCredentials, result.Failure);
+ Assert.Empty(factory.Created);
+ }
+
+ [Fact]
+ public async Task WrongPassword_IsFinal_AndNeverAsksTheFallback()
+ {
+ var factory = new SequenceLdapConnectionFactory(
+ Jane().ThrowOnFirstBind(LdapException.InvalidCredentials), Jane());
+ var result = await Login(new LdapAuthService(Opts("dc2"), factory), "jdoe", "wrong");
+
+ Assert.Equal(LdapAuthFailure.BadCredentials, result.Failure);
+ Assert.Single(factory.Created);
+ }
+
+ [Fact]
+ public async Task NonCredentialBindError_FailsOver()
+ {
+ var factory = new SequenceLdapConnectionFactory(
+ Jane().ThrowOnFirstBind(LdapException.Unavailable), Jane());
+ var result = await Login(new LdapAuthService(Opts("dc2"), factory), "jdoe");
+
+ Assert.True(result.Succeeded);
+ Assert.Equal(2, factory.Created.Count);
+ Assert.Equal("dc2", factory.Created[1].ConnectArgs!.Value.Host);
+ }
+
+ [Fact]
+ public async Task ConnectFailure_FailsOver()
+ {
+ var factory = new SequenceLdapConnectionFactory(new FakeLdapConnection().ThrowOnConnect(), Jane());
+ var result = await Login(new LdapAuthService(Opts("dc2"), factory), "jdoe");
+
+ Assert.True(result.Succeeded);
+ Assert.Equal(2, factory.Created.Count);
+ }
+
+ [Fact]
+ public async Task SearchFailure_FailsOver()
+ {
+ var factory = new SequenceLdapConnectionFactory(Jane().ThrowOnSearch(), Jane());
+ var result = await Login(new LdapAuthService(Opts("dc2"), factory), "jdoe");
+
+ Assert.True(result.Succeeded);
+ Assert.Equal(2, factory.Created.Count);
+ }
+
+ [Fact]
+ public async Task EveryEndpointDown_IsSystemSide()
+ {
+ var factory = new SequenceLdapConnectionFactory(
+ new FakeLdapConnection().ThrowOnConnect(), new FakeLdapConnection().ThrowOnConnect());
+ var result = await Login(new LdapAuthService(Opts("dc2"), factory), "jdoe");
+
+ Assert.Equal(LdapAuthFailure.ServiceAccountBindFailed, result.Failure);
+ }
+
+ [Fact]
+ public async Task NoEntryAfterAGoodBind_IsGroupLookupFailed()
+ {
+ var factory = new SequenceLdapConnectionFactory(new FakeLdapConnection().WithNoMatch());
+ var result = await Login(new LdapAuthService(Opts("dc2"), factory), "jdoe");
+
+ Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
+ Assert.Single(factory.Created);
+ }
+
+ [Fact]
+ public async Task TwoEntriesAfterAGoodBind_IsGroupLookupFailed()
+ {
+ var factory = new SequenceLdapConnectionFactory(new FakeLdapConnection().WithDuplicateMatch());
+ var result = await Login(new LdapAuthService(Opts(), factory), "jdoe");
+
+ Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
+ }
+
+ [Fact]
+ public async Task NoGroups_IsGroupLookupFailed()
+ {
+ var factory = new SequenceLdapConnectionFactory(
+ new FakeLdapConnection().WithUserEntry("CN=Jane,DC=x", Array.Empty(), "Jane"));
+ var result = await Login(new LdapAuthService(Opts(), factory), "jdoe");
+
+ Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
+ }
+}