feat(auth): LdapAuthService user-bind flow (bind as the user, read own groups on the same connection)

This commit is contained in:
Joseph Doherty
2026-10-01 00:55:04 -04:00
parent fa0ca66a05
commit 302d1245cd
3 changed files with 336 additions and 70 deletions
@@ -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.
/// </para>
/// <para>
/// With <c>BindMode = User</c> the service-account bind and the user re-bind are replaced by a
/// single bind as the user (<see cref="LdapOptions.UserBindTemplate"/>); see <see cref="CompleteAsUser"/>.
/// </para>
/// </remarks>
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<LdapSearchEntry> 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<string> 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
}
}
/// <summary>Steps 6–11 of the 0.3.0 flow: service-account bind, search, user re-bind, groups.</summary>
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);
}
/// <summary>
/// <see cref="LdapBindMode.User"/>: 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.
/// </summary>
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);
}
/// <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)
{
try
{
var filter = $"({_options.UserNameAttribute}={LdapEscaping.Filter(username)})";
return conn.Search(_options.SearchBase, filter, BuildSearchAttributes());
}
catch (LdapException)
{
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "search");
}
}
/// <summary>Steps 10–11: fail-closed group extraction, then success.</summary>
private LdapAuthResult SucceedFromEntry(LdapSearchEntry entry, string username)
{
IReadOnlyList<string> 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);
}
/// <summary>
/// Internal control-flow exception that carries an already-mapped <see cref="LdapAuthFailure"/>
/// 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; }
/// <summary>Which stage faulted (<c>connect</c> / <c>service-bind</c> / <c>search</c>), for diagnostics.</summary>
/// <summary>Which stage faulted (<c>connect</c> / <c>service-bind</c> / <c>user-bind</c> / <c>search</c>), for diagnostics.</summary>
public string Stage { get; }
}