From 302d1245cd67be361ec3b46d71659b528ac2bc86 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 1 Oct 2026 00:55:04 -0400 Subject: [PATCH] feat(auth): LdapAuthService user-bind flow (bind as the user, read own groups on the same connection) --- .../ZB.MOM.WW.Auth.Ldap/LdapAuthService.cs | 189 +++++++++++------- .../FakeLdapConnection.cs | 36 +++- .../LdapAuthServiceUserBindTests.cs | 181 +++++++++++++++++ 3 files changed, 336 insertions(+), 70 deletions(-) create mode 100644 ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapAuthServiceUserBindTests.cs 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); + } +}