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 const string JaneDn = "CN=Jane Doe,OU=u,DC=x"; private static FakeLdapConnection Jane() => new FakeLdapConnection() .WithWhoAmI("dn:" + JaneDn) .WithUserEntry(JaneDn, 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", "whoami", "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().WithWhoAmI("dn:" + JaneDn).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().WithWhoAmI("dn:cn=dup1,dc=x").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().WithWhoAmI("dn:CN=Jane,DC=x").WithUserEntry("CN=Jane,DC=x", Array.Empty(), "Jane")); var result = await Login(new LdapAuthService(Opts(), factory), "jdoe"); Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure); } [Fact] public async Task Username_IsCanonicalisedFromTheEntry_NotTheTypedCase() { var factory = new SequenceLdapConnectionFactory(new FakeLdapConnection().WithWhoAmI(@"u:NAM\jdoe").WithUserEntry( "CN=Jane Doe,OU=u,DC=x", new[] { "CN=UG_A,OU=g,DC=x" }, "Jane Doe", new Dictionary { ["sAMAccountName"] = new[] { "jdoe" } })); var result = await Login(new LdapAuthService(Opts(), factory), @"NAM\JDOE"); Assert.True(result.Succeeded); Assert.Equal("jdoe", result.Username); } [Fact] public async Task Username_FallsBackToTheTypedName_WhenTheEntryLacksTheAttribute() { var factory = new SequenceLdapConnectionFactory(Jane()); var result = await Login(new LdapAuthService(Opts(), factory), "JDOE"); Assert.True(result.Succeeded); Assert.Equal("JDOE", result.Username); } [Fact] public async Task Search_RequestsTheUserNameAttribute() { var factory = new SequenceLdapConnectionFactory(Jane()); await Login(new LdapAuthService(Opts(), factory), "jdoe"); Assert.Equal(new[] { "displayName", "memberOf", "sAMAccountName" }, factory.Created[0].SearchAttributes); } [Fact] public async Task Search_DeDuplicatesTheUserNameAttribute_CaseInsensitively() { var factory = new SequenceLdapConnectionFactory(Jane()); var svc = new LdapAuthService(Opts() with { DisplayNameAttribute = "SAMACCOUNTNAME" }, factory); await Login(svc, "jdoe"); Assert.Equal(new[] { "SAMACCOUNTNAME", "memberOf" }, factory.Created[0].SearchAttributes); } // ---- Auth-007 (scadaproj#40): the entry whose groups are returned must be the account that bound ---- /// /// The review's scenario: a UPN bind for jsmith is resolved by AD to Mallory (explicit UPN /// jsmith@corp.test, sAMAccountName jsmith2), while (sAMAccountName=jsmith) /// finds the admin. Before the fix this returned the admin's groups to Mallory. /// [Fact] public async Task UpnBind_ResolvedToAnotherAccount_DoesNotReturnTheSearchedEntrysGroups() { var fake = new FakeLdapConnection() .WithWhoAmI(@"u:CORP\jsmith2") .WithUserEntry("CN=Admin Smith,OU=u,DC=x", new[] { "CN=UG_Admins,OU=g,DC=x" }, "Admin Smith", new Dictionary { ["sAMAccountName"] = new[] { "jsmith" } }); var factory = new SequenceLdapConnectionFactory(fake); var result = await Login(new LdapAuthService(Opts(), factory), "jsmith", "mallorys-own-password"); Assert.False(result.Succeeded); Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure); Assert.Empty(result.Groups); Assert.Equal(new[] { "jsmith@corp.test" }, fake.BoundDns); } [Fact] public async Task WhoAmIAccountName_MatchingTheEntry_Succeeds_AndRequestsSamAccountName() { var fake = new FakeLdapConnection() .WithWhoAmI(@"u:CORP\JSmith") .WithUserEntry("CN=John Smith,OU=u,DC=x", new[] { "CN=UG_A,OU=g,DC=x" }, "John Smith", new Dictionary { ["sAMAccountName"] = new[] { "jsmith" } }); var factory = new SequenceLdapConnectionFactory(fake); var svc = new LdapAuthService(Opts() with { UserNameAttribute = "cn" }, factory); var result = await Login(svc, "John Smith"); Assert.True(result.Succeeded); Assert.Contains("sAMAccountName", fake.SearchAttributes!); } [Fact] public async Task WhoAmIAccountName_WithoutDomain_IsAccepted() { var fake = new FakeLdapConnection() .WithWhoAmI("u:jdoe") .WithUserEntry(JaneDn, new[] { "CN=UG_A,OU=g,DC=x" }, "Jane Doe", new Dictionary { ["sAMAccountName"] = new[] { "jdoe" } }); var result = await Login(new LdapAuthService(Opts(), new SequenceLdapConnectionFactory(fake)), "jdoe"); Assert.True(result.Succeeded); } [Fact] public async Task WhoAmIAccountName_EntryWithoutSamAccountName_FailsClosed() { var fake = new FakeLdapConnection() .WithWhoAmI(@"u:CORP\jdoe") .WithUserEntry(JaneDn, new[] { "CN=UG_A,OU=g,DC=x" }, "Jane Doe"); var result = await Login(new LdapAuthService(Opts(), new SequenceLdapConnectionFactory(fake)), "jdoe"); Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure); } // ---- scadaproj#50: an AD forest — the same sAMAccountName in two domains ---------------------- // A Global Catalog search base spans every domain in the forest. CORP\jsmith binds; the search // (here on mail, so the filter is not sAMAccountName) finds OTHER\jsmith, whose sAMAccountName is // also "jsmith". 0.4.4 compared sAMAccountName alone and returned OTHER\jsmith's groups. private const string OtherDn = "CN=J Smith,OU=u,DC=other,DC=forest"; private const string CorpDn = "CN=John Smith,OU=u,DC=corp,DC=forest"; private static FakeLdapConnection ForestSearchFinding(string dn) => new FakeLdapConnection() .WithWhoAmI(@"u:CORP\jsmith") .WithUserEntry(dn, new[] { "CN=UG_Admins,OU=g,DC=other,DC=forest" }, "J Smith", new Dictionary { ["sAMAccountName"] = new[] { "jsmith" }, ["mail"] = new[] { "jsmith" } }); private static LdapOptions ForestOpts() => Opts() with { UserNameAttribute = "mail", SearchBase = "DC=forest" }; [Fact] public async Task Forest_PrincipalNameInAnotherDomain_FailsClosed() { var fake = ForestSearchFinding(OtherDn).WithPrincipalName(@"OTHER\jsmith"); var result = await Login(new LdapAuthService(ForestOpts(), new SequenceLdapConnectionFactory(fake)), "jsmith"); Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure); Assert.Empty(result.Groups); Assert.Contains("read:" + OtherDn, fake.Calls); } [Fact] public async Task Forest_PrincipalNameInTheBoundDomain_Succeeds() { var fake = ForestSearchFinding(CorpDn).WithPrincipalName(@"corp\JSMITH"); var result = await Login(new LdapAuthService(ForestOpts(), new SequenceLdapConnectionFactory(fake)), "jsmith"); Assert.True(result.Succeeded); } [Fact] public async Task Forest_NoPrincipalName_AndTheNameIsHeldInTwoDomains_FailsClosed() { // The directory offers no msDS-PrincipalName, so which jsmith bound cannot be proven; with two // holders of the name in scope the sign-in is refused rather than guessed. var fake = ForestSearchFinding(OtherDn) .WithSearchResult("(sAMAccountName=jsmith)", (CorpDn, "jsmith"), (OtherDn, "jsmith")); var result = await Login(new LdapAuthService(ForestOpts(), new SequenceLdapConnectionFactory(fake)), "jsmith"); Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure); } [Fact] public async Task Forest_NoPrincipalName_AndTheReadFails_StillFallsBackToTheUniquenessCheck() { var fake = ForestSearchFinding(OtherDn).ThrowOnRead() .WithSearchResult("(sAMAccountName=jsmith)", (CorpDn, "jsmith"), (OtherDn, "jsmith")); var result = await Login(new LdapAuthService(ForestOpts(), new SequenceLdapConnectionFactory(fake)), "jsmith"); Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure); } [Fact] public async Task SingleDomain_NoPrincipalName_UniqueName_Succeeds() { // Compatibility: a single-domain directory without msDS-PrincipalName still signs in — the // name has exactly one holder in scope, and it is the entry found. var fake = ForestSearchFinding(CorpDn).WithSearchResult("(sAMAccountName=jsmith)", (CorpDn, "jsmith")); var result = await Login(new LdapAuthService(ForestOpts(), new SequenceLdapConnectionFactory(fake)), "jsmith"); Assert.True(result.Succeeded); } [Fact] public async Task SamAccountNameSearch_ForTheBoundName_NeedsNoSecondSearch() { // When the user search itself was (sAMAccountName=) and found exactly one // entry, the name is already proven unique in scope. var fake = new FakeLdapConnection() .WithWhoAmI(@"u:CORP\jdoe") .WithUserEntry(JaneDn, new[] { "CN=UG_A,OU=g,DC=x" }, "Jane Doe", new Dictionary { ["sAMAccountName"] = new[] { "jdoe" } }); var result = await Login(new LdapAuthService(Opts(), new SequenceLdapConnectionFactory(fake)), "jdoe"); Assert.True(result.Succeeded); Assert.Single(fake.Calls, c => c.StartsWith("search:", StringComparison.Ordinal)); } [Fact] public async Task WhoAmIDn_NamingAnotherEntry_FailsClosed() { var fake = new FakeLdapConnection() .WithWhoAmI("dn:CN=Mallory,OU=u,DC=x") .WithUserEntry("CN=Admin Smith,OU=u,DC=x", new[] { "CN=UG_Admins,OU=g,DC=x" }, "Admin Smith"); var result = await Login(new LdapAuthService(Opts(), new SequenceLdapConnectionFactory(fake)), "jsmith"); Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure); Assert.Empty(result.Groups); } [Fact] public async Task WhoAmIDn_IsComparedAsADn_NotAsAString() { var fake = new FakeLdapConnection() .WithWhoAmI("dn:cn=jane doe, ou=U, dc=X") .WithUserEntry(JaneDn, new[] { "CN=UG_A,OU=g,DC=x" }, "Jane Doe"); var result = await Login(new LdapAuthService(Opts(), new SequenceLdapConnectionFactory(fake)), "jdoe"); Assert.True(result.Succeeded); } [Theory] [InlineData(null)] // server does not support Who-am-I (GLAuth: protocol error 2) [InlineData("")] // anonymous / empty answer [InlineData("x:garbage")] public async Task UpnTemplate_WithoutAWhoAmIAnswer_FailsClosed_WithoutSearching(string? whoAmI) { var fake = new FakeLdapConnection() .WithUserEntry(JaneDn, new[] { "CN=UG_A,OU=g,DC=x" }, "Jane Doe"); if (whoAmI is not null) fake.WithWhoAmI(whoAmI); var result = await Login(new LdapAuthService(Opts(), new SequenceLdapConnectionFactory(fake)), "jdoe"); Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure); Assert.DoesNotContain(fake.Calls, c => c.StartsWith("search:", StringComparison.Ordinal)); } [Fact] public async Task DownLevelTemplate_WithoutAWhoAmIAnswer_FailsClosed() { var fake = new FakeLdapConnection() .WithUserEntry(JaneDn, new[] { "CN=UG_A,OU=g,DC=x" }, "Jane Doe"); var svc = new LdapAuthService(Opts() with { UserBindTemplate = @"CORP\{0}" }, new SequenceLdapConnectionFactory(fake)); var result = await Login(svc, "jdoe"); Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure); } [Fact] public async Task DnTemplate_WithoutWhoAmI_AcceptsTheEntryAtTheBoundDn() { var fake = new FakeLdapConnection() .WithUserEntry("cn=jdoe,ou=Users,dc=x", new[] { "cn=UG_A,ou=g,dc=x" }, "Jane Doe"); var svc = new LdapAuthService( Opts() with { UserBindTemplate = "cn={0},ou=users,dc=x", UserNameAttribute = "cn" }, new SequenceLdapConnectionFactory(fake)); var result = await Login(svc, "jdoe"); Assert.True(result.Succeeded); } [Fact] public async Task DnTemplate_WithoutWhoAmI_RefusesAnEntryAtAnotherDn() { // Mallory binds as cn=mallory with her own password; (mail=mallory) finds the admin. var fake = new FakeLdapConnection() .WithUserEntry("cn=admin,ou=Users,dc=x", new[] { "cn=UG_Admins,ou=g,dc=x" }, "Admin"); var svc = new LdapAuthService( Opts() with { UserBindTemplate = "cn={0},ou=users,dc=x", UserNameAttribute = "mail" }, new SequenceLdapConnectionFactory(fake)); var result = await Login(svc, "mallory"); Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure); Assert.Equal(new[] { "cn=mallory,ou=users,dc=x" }, fake.BoundDns); } [Fact] public async Task DnTemplate_EscapesTheName_SoItCannotRewriteTheBindDn() { var fake = new FakeLdapConnection() .WithUserEntry("cn=admin,ou=Admins,dc=x", new[] { "cn=UG_Admins,ou=g,dc=x" }, "Admin"); var svc = new LdapAuthService( Opts() with { UserBindTemplate = "cn={0},ou=users,dc=x", UserNameAttribute = "cn" }, new SequenceLdapConnectionFactory(fake)); var result = await Login(svc, "admin,ou=Admins"); Assert.Equal(new[] { @"cn=admin\,ou=Admins,ou=users,dc=x" }, fake.BoundDns); Assert.False(result.Succeeded); } }