feat(auth): LdapAuthService user-bind flow (bind as the user, read own groups on the same connection)
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user