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:
@@ -2,8 +2,11 @@ namespace ZB.MOM.WW.Auth.Ldap.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Reduces a typed login to the bare account name used by <see cref="Abstractions.Ldap.LdapBindMode.User"/>:
|
||||
/// one spelling flows into the bind template, the search filter and the returned username, so audit
|
||||
/// rows never carry <c>DOMAIN\jdoe</c> for one sign-in and <c>jdoe</c> for the next.
|
||||
/// the qualifier is stripped before the name reaches the bind template and the search filter, so audit
|
||||
/// rows never carry <c>DOMAIN\jdoe</c> for one sign-in and <c>jdoe</c> for the next. Case is NOT
|
||||
/// normalised here: the returned username is canonicalised from the directory entry's
|
||||
/// <c>UserNameAttribute</c> value, so <c>JDoe</c> and <c>jdoe</c> also yield one identity; this name is
|
||||
/// only the fallback when the entry has no such value.
|
||||
/// </summary>
|
||||
internal static class LdapUserName
|
||||
{
|
||||
|
||||
@@ -145,16 +145,19 @@ 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)
|
||||
// 3b. An empty password must never reach the directory, in EITHER bind mode: AD answers a
|
||||
// simple bind with an empty password as an UNAUTHENTICATED bind and reports success —
|
||||
// the user bind (or the ServiceAccount-mode re-bind as the user's DN) would admit anyone.
|
||||
// Refused here, before any network call, so no consumer depends on its own edge check.
|
||||
if (string.IsNullOrEmpty(password))
|
||||
return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
|
||||
|
||||
// 3c. User-bind mode: reduce the name to the bare account name the bind template and the
|
||||
// search filter share. (The returned username is canonicalised from the entry later.)
|
||||
if (_options.BindMode == LdapBindMode.User
|
||||
&& !LdapUserName.TryNormalize(username, _options.AcceptDomainQualifiedNames, out username))
|
||||
{
|
||||
if (string.IsNullOrEmpty(password))
|
||||
return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
|
||||
if (!LdapUserName.TryNormalize(username, _options.AcceptDomainQualifiedNames, out username))
|
||||
return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
|
||||
return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
|
||||
}
|
||||
|
||||
// 4. Walk the endpoint list, preferred one first. A single-endpoint configuration
|
||||
@@ -278,7 +281,8 @@ public sealed class LdapAuthService : ILdapAuthService
|
||||
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.
|
||||
// service-account misconfiguration — DISTINCT from a user-credential failure — so it
|
||||
// maps to ServiceAccountBindFailed.
|
||||
try
|
||||
{
|
||||
conn.Bind(_options.ServiceAccountDn, _options.ServiceAccountPassword);
|
||||
@@ -288,7 +292,9 @@ public sealed class LdapAuthService : ILdapAuthService
|
||||
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "service-bind");
|
||||
}
|
||||
|
||||
// 7–8. Exactly one match. Zero -> UserNotFound; two or more -> AmbiguousUser.
|
||||
// 7–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.
|
||||
var entries = SearchUser(conn, username);
|
||||
if (entries.Count == 0)
|
||||
return LdapAuthResult.Fail(LdapAuthFailure.UserNotFound);
|
||||
@@ -297,7 +303,9 @@ public sealed class LdapAuthService : ILdapAuthService
|
||||
|
||||
var entry = entries[0];
|
||||
|
||||
// 9. User bind verifies the password; a failure is the user's bad credentials and final.
|
||||
// 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);
|
||||
@@ -344,6 +352,10 @@ public sealed class LdapAuthService : ILdapAuthService
|
||||
/// <summary>Step 7: search for the user entry; a search fault is system-side.</summary>
|
||||
private IReadOnlyList<LdapSearchEntry> SearchUser(ZB.MOM.WW.Auth.Ldap.Internal.ILdapConnection conn, string username)
|
||||
{
|
||||
// A search failure is infrastructure (directory unreachable / unhealthy) — system-side.
|
||||
// NOTE: the LdapAuthFailure enum has no DirectoryUnavailable value, so a failed search maps
|
||||
// to ServiceAccountBindFailed, the closest system-side bucket (same limitation as
|
||||
// Connect), until a DirectoryUnavailable value exists.
|
||||
try
|
||||
{
|
||||
var filter = $"({_options.UserNameAttribute}={LdapEscaping.Filter(username)})";
|
||||
@@ -355,7 +367,12 @@ public sealed class LdapAuthService : ILdapAuthService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Steps 10–11: fail-closed group extraction, then success.</summary>
|
||||
/// <summary>
|
||||
/// Steps 10–11: fail-closed group extraction, then success. The returned username is the
|
||||
/// entry's own <see cref="LdapOptions.UserNameAttribute"/> value (the directory's spelling), so
|
||||
/// <c>JDoe</c> and <c>jdoe</c> sign in as one identity; the typed (normalised) name is the
|
||||
/// fallback when the entry carries no such value.
|
||||
/// </summary>
|
||||
private LdapAuthResult SucceedFromEntry(LdapSearchEntry entry, string username)
|
||||
{
|
||||
IReadOnlyList<string> groups;
|
||||
@@ -371,7 +388,8 @@ public sealed class LdapAuthService : ILdapAuthService
|
||||
if (groups.Count == 0)
|
||||
return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed);
|
||||
|
||||
return LdapAuthResult.Success(username, ExtractDisplayName(entry, username), groups);
|
||||
var canonical = ExtractUserName(entry, username);
|
||||
return LdapAuthResult.Success(canonical, ExtractDisplayName(entry, canonical), groups);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -393,16 +411,37 @@ public sealed class LdapAuthService : ILdapAuthService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the distinct attribute list requested from the directory. The display-name
|
||||
/// and group attributes are de-duplicated so we never request the same attribute twice
|
||||
/// when an operator configures them to the same value.
|
||||
/// Builds the distinct attribute list requested from the directory: display name, groups and
|
||||
/// the username attribute (read back as the canonical username). They are de-duplicated,
|
||||
/// case-insensitively, so we never request the same attribute twice when an operator
|
||||
/// configures two of them to the same value.
|
||||
/// </summary>
|
||||
private IReadOnlyList<string> BuildSearchAttributes()
|
||||
{
|
||||
if (string.Equals(_options.DisplayNameAttribute, _options.GroupAttribute, StringComparison.OrdinalIgnoreCase))
|
||||
return new[] { _options.DisplayNameAttribute };
|
||||
var attributes = new List<string>(3);
|
||||
foreach (var name in new[] { _options.DisplayNameAttribute, _options.GroupAttribute, _options.UserNameAttribute })
|
||||
{
|
||||
if (!attributes.Contains(name, StringComparer.OrdinalIgnoreCase))
|
||||
attributes.Add(name);
|
||||
}
|
||||
|
||||
return new[] { _options.DisplayNameAttribute, _options.GroupAttribute };
|
||||
return attributes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the first non-blank value of the configured username attribute — the directory's
|
||||
/// canonical spelling — falling back to the (already normalised) typed username.
|
||||
/// </summary>
|
||||
private string ExtractUserName(LdapSearchEntry entry, string username)
|
||||
{
|
||||
if (entry.Attributes.TryGetValue(_options.UserNameAttribute, out var values)
|
||||
&& values.Count > 0
|
||||
&& !string.IsNullOrWhiteSpace(values[0]))
|
||||
{
|
||||
return values[0];
|
||||
}
|
||||
|
||||
return username;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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:<dn></c>, <c>search:<filter></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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user