fix(auth): canonical username from the entry; refuse empty passwords in both bind modes

- Request UserNameAttribute in the search (de-duplicated, case-insensitive) and return its
  first value as LdapAuthResult.Username, falling back to the typed name (I2).
- The empty-password guard now applies in ServiceAccount mode too: AD treats an empty-password
  simple bind as an unauthenticated bind and reports success (I3).
- Restore the dropped explanatory comments in CompleteWithServiceAccount / SearchUser.
This commit is contained in:
Joseph Doherty
2026-10-01 01:27:27 -04:00
parent 177881c423
commit e3d146aa21
5 changed files with 162 additions and 24 deletions
@@ -34,6 +34,9 @@ internal sealed class FakeLdapConnection : ILdapConnection
/// </summary>
public int BindAttempts { get; private set; }
/// <summary>The attribute list passed to the most recent <see cref="Search"/> call (the fake still returns every scripted attribute).</summary>
public IReadOnlyList<string>? SearchAttributes { get; private set; }
/// <summary>Every call in order: <c>connect</c>, <c>bind:&lt;dn&gt;</c>, <c>search:&lt;filter&gt;</c>.</summary>
public List<string> Calls { get; } = new();
@@ -41,9 +44,14 @@ internal sealed class FakeLdapConnection : ILdapConnection
/// <summary>
/// Scripts a user entry that will be returned by the next <see cref="Search"/> call.
/// Builds a minimal attribute bag with <c>memberOf</c> and optional <c>displayName</c>.
/// Builds a minimal attribute bag with <c>memberOf</c>, optional <c>displayName</c> and any
/// <paramref name="extraAttributes"/> (e.g. <c>sAMAccountName</c>) the test needs.
/// </summary>
public FakeLdapConnection WithUserEntry(string dn, string[] memberOf, string? displayName = null)
public FakeLdapConnection WithUserEntry(
string dn,
string[] memberOf,
string? displayName = null,
IReadOnlyDictionary<string, string[]>? extraAttributes = null)
{
var attrs = new Dictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase)
{
@@ -51,6 +59,11 @@ internal sealed class FakeLdapConnection : ILdapConnection
};
if (displayName is not null)
attrs["displayName"] = new[] { displayName };
if (extraAttributes is not null)
{
foreach (var (name, values) in extraAttributes)
attrs[name] = values;
}
_scriptedEntries.Add(new LdapSearchEntry(dn, attrs));
return this;
@@ -177,6 +190,7 @@ internal sealed class FakeLdapConnection : ILdapConnection
IReadOnlyList<string> attributes)
{
Calls.Add($"search:{filter}");
SearchAttributes = attributes;
if (_throwOnSearch)
throw new Novell.Directory.Ldap.LdapException(
"Search failed", Novell.Directory.Ldap.LdapException.Unavailable, searchBase);
@@ -147,4 +147,45 @@ public class LdapAuthServiceTests
Assert.True(r.Succeeded);
Assert.Equal(new[] { "Eng,ineers", "A,B" }, r.Groups);
}
[Fact]
public async Task Username_IsCanonicalisedFromTheEntry_InServiceAccountMode()
{
var fake = new FakeLdapConnection().WithUserEntry(
"cn=alice,dc=x", new[] { "cn=g,dc=x" }, "Alice",
new Dictionary<string, string[]> { ["cn"] = new[] { "alice" } });
var svc = new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake));
var r = await svc.AuthenticateAsync("ALICE", "pw", default);
Assert.True(r.Succeeded);
Assert.Equal("alice", r.Username);
}
[Fact]
public async Task Username_FallsBackToTheTypedName_InServiceAccountMode_WhenAttributeAbsent()
{
var fake = new FakeLdapConnection().WithUserEntry("cn=alice,dc=x", new[] { "cn=g,dc=x" }, "Alice");
var svc = new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake));
var r = await svc.AuthenticateAsync("ALICE", "pw", default);
Assert.True(r.Succeeded);
Assert.Equal("ALICE", r.Username);
}
[Theory]
[InlineData("")]
[InlineData(null)]
public async Task EmptyPassword_IsRefused_InServiceAccountMode_WithoutContactingTheDirectory(string? password)
{
var factory = new SequenceLdapConnectionFactory(
new FakeLdapConnection().WithUserEntry("cn=alice,dc=x", new[] { "cn=g,dc=x" }, "Alice"));
var svc = new LdapAuthService(Opts(), factory);
var r = await svc.AuthenticateAsync("alice", password!, default);
Assert.Equal(LdapAuthFailure.BadCredentials, r.Failure);
Assert.Empty(factory.Created);
}
}
@@ -178,4 +178,45 @@ public class LdapAuthServiceUserBindTests
Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
}
[Fact]
public async Task Username_IsCanonicalisedFromTheEntry_NotTheTypedCase()
{
var factory = new SequenceLdapConnectionFactory(new FakeLdapConnection().WithUserEntry(
"CN=Jane Doe,OU=u,DC=x", new[] { "CN=UG_A,OU=g,DC=x" }, "Jane Doe",
new Dictionary<string, string[]> { ["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);
}
}