Initial commit: scadaproj umbrella — sister-project index, auth component normalization (design + GAPS), and the built ZB.MOM.WW.Auth shared library (0.1.0, flattened in).
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
namespace ZB.MOM.WW.Auth.Ldap.Internal;
|
||||
|
||||
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
|
||||
/// <summary>
|
||||
/// A single LDAP search result entry: its DN and a flat attribute bag.
|
||||
/// </summary>
|
||||
internal sealed record LdapSearchEntry(
|
||||
string Dn,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<string>> Attributes);
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction over a single LDAP connection. Allows unit-testing
|
||||
/// <c>LdapAuthService</c> without a live directory server.
|
||||
/// </summary>
|
||||
internal interface ILdapConnection : IDisposable
|
||||
{
|
||||
/// <summary>Opens (and optionally upgrades to TLS) a connection to the given host.</summary>
|
||||
void Connect(string host, int port, LdapTransport transport, bool allowInsecure, int timeoutMs);
|
||||
|
||||
/// <summary>Binds with the supplied DN and password. Throws <c>LdapException</c> on bad credentials.</summary>
|
||||
void Bind(string dn, string password);
|
||||
|
||||
/// <summary>Executes a subtree search and returns all matching entries.</summary>
|
||||
IReadOnlyList<LdapSearchEntry> Search(string searchBase, string filter, IReadOnlyList<string> attributes);
|
||||
}
|
||||
|
||||
/// <summary>Factory that produces <see cref="ILdapConnection"/> instances.</summary>
|
||||
internal interface ILdapConnectionFactory
|
||||
{
|
||||
ILdapConnection Create();
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
namespace ZB.MOM.WW.Auth.Ldap.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// RFC 4515 LDAP filter escaping and RFC 4514 DN attribute-value escaping utilities.
|
||||
/// </summary>
|
||||
internal static class LdapEscaping
|
||||
{
|
||||
/// <summary>
|
||||
/// Escapes a string for safe use inside an RFC 4515 LDAP search filter assertion value.
|
||||
/// Escapes (in order): backslash, asterisk, left-paren, right-paren, NUL.
|
||||
/// </summary>
|
||||
public static string Filter(string input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input))
|
||||
return input;
|
||||
|
||||
// Backslash must be escaped first so we don't double-escape subsequent replacements.
|
||||
return input
|
||||
.Replace("\\", @"\5c")
|
||||
.Replace("*", @"\2a")
|
||||
.Replace("(", @"\28")
|
||||
.Replace(")", @"\29")
|
||||
.Replace("\0", @"\00");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Escapes a string for safe use as an RFC 4514 DN attribute value.
|
||||
/// Escapes: , + " \ < > ; (with a leading backslash);
|
||||
/// also escapes a leading '#' and leading/trailing space.
|
||||
/// </summary>
|
||||
public static string Dn(string input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input))
|
||||
return input;
|
||||
|
||||
var sb = new System.Text.StringBuilder(input.Length + 8);
|
||||
for (var i = 0; i < input.Length; i++)
|
||||
{
|
||||
var c = input[i];
|
||||
switch (c)
|
||||
{
|
||||
case ',':
|
||||
case '+':
|
||||
case '"':
|
||||
case '\\':
|
||||
case '<':
|
||||
case '>':
|
||||
case ';':
|
||||
sb.Append('\\').Append(c);
|
||||
break;
|
||||
case '#' when i == 0:
|
||||
sb.Append(@"\#");
|
||||
break;
|
||||
case ' ' when i == 0 || i == input.Length - 1:
|
||||
sb.Append(@"\ ");
|
||||
break;
|
||||
default:
|
||||
sb.Append(c);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the value of the first RDN from a DN, e.g.
|
||||
/// <c>cn=Engineers,ou=g,dc=x</c> → <c>Engineers</c>. The scan is RFC 4514 escape-aware:
|
||||
/// a backslash-escaped <c>,</c> inside the RDN value does not terminate it, and recognised
|
||||
/// escape sequences — single-character (<c>\,</c> <c>\\</c> …) and two-digit hex
|
||||
/// (<c>\2c</c>) — are unescaped, so a group CN that legitimately contains a comma is
|
||||
/// returned intact (Security-013). A string with no <c>=</c> is returned unchanged.
|
||||
/// </summary>
|
||||
public static string FirstRdnValue(string dn)
|
||||
{
|
||||
if (string.IsNullOrEmpty(dn))
|
||||
return dn;
|
||||
|
||||
var equalsIndex = dn.IndexOf('=');
|
||||
if (equalsIndex < 0)
|
||||
return dn;
|
||||
|
||||
var valueStart = equalsIndex + 1;
|
||||
var sb = new System.Text.StringBuilder(dn.Length - valueStart);
|
||||
|
||||
for (var i = valueStart; i < dn.Length; i++)
|
||||
{
|
||||
var c = dn[i];
|
||||
if (c == '\\' && i + 1 < dn.Length)
|
||||
{
|
||||
var next = dn[i + 1];
|
||||
// RFC 4514 hex escape: \XX (two hex digits).
|
||||
if (i + 2 < dn.Length && IsHexDigit(next) && IsHexDigit(dn[i + 2]))
|
||||
{
|
||||
sb.Append((char)Convert.ToInt32(dn.Substring(i + 1, 2), 16));
|
||||
i += 2;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Single-character escape (e.g. \, \+ \\ \" \; etc.) — emit the
|
||||
// escaped character literally and skip the backslash.
|
||||
sb.Append(next);
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c == ',')
|
||||
{
|
||||
// Unescaped comma terminates the first RDN.
|
||||
break;
|
||||
}
|
||||
|
||||
sb.Append(c);
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static bool IsHexDigit(char c)
|
||||
=> (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
namespace ZB.MOM.WW.Auth.Ldap.Internal;
|
||||
|
||||
using Novell.Directory.Ldap;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
|
||||
/// <summary>
|
||||
/// Production <see cref="ILdapConnection"/> backed by <c>Novell.Directory.Ldap.LdapConnection</c>.
|
||||
/// Mirrors the connection/search idioms from ZB.MOM.WW.ScadaBridge.Security.LdapAuthService.
|
||||
/// </summary>
|
||||
internal sealed class NovellLdapConnection : ILdapConnection
|
||||
{
|
||||
private readonly LdapConnection _conn = new();
|
||||
private bool _disposed;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Connect(string host, int port, LdapTransport transport, bool allowInsecure, int timeoutMs)
|
||||
{
|
||||
ApplyTimeout(timeoutMs);
|
||||
|
||||
// LDAPS: TLS is negotiated at the TCP-connection level.
|
||||
if (transport == LdapTransport.Ldaps)
|
||||
_conn.SecureSocketLayer = true;
|
||||
|
||||
_conn.Connect(host, port);
|
||||
|
||||
// StartTLS: connect plaintext first, then upgrade inside the session.
|
||||
if (transport == LdapTransport.StartTls)
|
||||
{
|
||||
_conn.StartTls();
|
||||
|
||||
if (!_conn.Tls)
|
||||
throw new InvalidOperationException(
|
||||
"StartTLS upgrade did not produce an encrypted session.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Bind(string dn, string password)
|
||||
=> _conn.Bind(dn, password);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IReadOnlyList<LdapSearchEntry> Search(
|
||||
string searchBase,
|
||||
string filter,
|
||||
IReadOnlyList<string> attributes)
|
||||
{
|
||||
var results = _conn.Search(
|
||||
searchBase,
|
||||
LdapConnection.ScopeSub,
|
||||
filter,
|
||||
attributes.ToArray(),
|
||||
typesOnly: false);
|
||||
|
||||
var entries = new List<LdapSearchEntry>();
|
||||
while (results.HasMore())
|
||||
{
|
||||
var entry = results.Next();
|
||||
var attrs = new Dictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (LdapAttribute attr in entry.GetAttributeSet())
|
||||
{
|
||||
attrs[attr.Name] = attr.StringValueArray.ToList();
|
||||
}
|
||||
|
||||
entries.Add(new LdapSearchEntry(entry.Dn, attrs));
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
if (_conn.Connected)
|
||||
_conn.Disconnect();
|
||||
_conn.Dispose();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private void ApplyTimeout(int timeoutMs)
|
||||
{
|
||||
if (timeoutMs <= 0)
|
||||
return;
|
||||
|
||||
_conn.ConnectionTimeout = timeoutMs;
|
||||
|
||||
// SearchConstraints.TimeLimit is per-operation (ms). SearchConstraints getter
|
||||
// returns the live LdapSearchConstraints object (read-only property), but
|
||||
// TimeLimit is mutable in-place via the base LdapConstraints type.
|
||||
// We then assign it back through the writable Constraints property so
|
||||
// Novell picks up the change — mirrors ScadaBridge idiom.
|
||||
var constraints = _conn.SearchConstraints;
|
||||
constraints.TimeLimit = timeoutMs;
|
||||
_conn.Constraints = constraints;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Factory that produces fresh <see cref="NovellLdapConnection"/> instances.</summary>
|
||||
internal sealed class NovellLdapConnectionFactory : ILdapConnectionFactory
|
||||
{
|
||||
public ILdapConnection Create() => new NovellLdapConnection();
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
using Novell.Directory.Ldap;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
using ZB.MOM.WW.Auth.Ldap.Internal;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.Ldap;
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates a user against an LDAP directory using the bind-then-search idiom:
|
||||
/// bind as the service account, search for the user entry, then re-bind as the user
|
||||
/// to verify their password. Connection mechanics are delegated to an
|
||||
/// <see cref="ILdapConnection"/> so the logic is unit-testable without a live server.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Fully fail-closed: authentication never throws to the caller — every error, expected
|
||||
/// or unexpected, is mapped to a structured <see cref="LdapAuthResult.Fail(LdapAuthFailure)"/>.
|
||||
/// A success is only returned for a user that resolved to exactly one entry, whose password
|
||||
/// verified, AND who has at least one group (zero groups is never admitted as success).
|
||||
/// Service-account bind failures (<see cref="LdapAuthFailure.ServiceAccountBindFailed"/>) are
|
||||
/// kept distinct from end-user bind failures (<see cref="LdapAuthFailure.BadCredentials"/>) so
|
||||
/// a system misconfiguration is not mistaken for bad user input.
|
||||
/// </remarks>
|
||||
public sealed class LdapAuthService : ILdapAuthService
|
||||
{
|
||||
private readonly LdapOptions _options;
|
||||
private readonly ILdapConnectionFactory _connectionFactory;
|
||||
|
||||
/// <summary>
|
||||
/// Production constructor: binds against a live directory via the real
|
||||
/// Novell-backed connection factory.
|
||||
/// </summary>
|
||||
public LdapAuthService(LdapOptions options)
|
||||
: this(options, new NovellLdapConnectionFactory())
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test/seam constructor: accepts an injected <see cref="ILdapConnectionFactory"/>
|
||||
/// so the bind/search logic can be exercised without a live directory. Internal
|
||||
/// because the connection seam is an implementation detail.
|
||||
/// </summary>
|
||||
internal LdapAuthService(LdapOptions options, ILdapConnectionFactory connectionFactory)
|
||||
{
|
||||
_options = options;
|
||||
_connectionFactory = connectionFactory;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Fail-closed contract: this method never throws to the caller. Every stage of the
|
||||
/// bind-then-search-then-bind flow is wrapped so that any error — expected or unexpected —
|
||||
/// is mapped to a structured <see cref="LdapAuthResult.Fail(LdapAuthFailure)"/>. A
|
||||
/// <c>Succeeded == true</c> result is only ever returned when the user resolved to exactly
|
||||
/// one entry, their password verified, AND at least one group was extracted.
|
||||
/// </remarks>
|
||||
public Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct)
|
||||
{
|
||||
// The Novell calls behind ILdapConnection are synchronous and blocking, so the token
|
||||
// cannot interrupt an in-progress operation; it is only observed here at entry, before
|
||||
// any work begins.
|
||||
ct.ThrowIfCancellationRequested();
|
||||
return Task.FromResult(Authenticate(username, password));
|
||||
}
|
||||
|
||||
private LdapAuthResult Authenticate(string username, string password)
|
||||
{
|
||||
// 1. Feature gate: an explicitly disabled provider must never touch the network.
|
||||
if (!_options.Enabled)
|
||||
return LdapAuthResult.Fail(LdapAuthFailure.Disabled);
|
||||
|
||||
// 2. Reject a missing username before anything else — guarding here means a null
|
||||
// username can't NRE into the catch-all and surface as a system-side failure.
|
||||
if (string.IsNullOrWhiteSpace(username))
|
||||
return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
|
||||
|
||||
// 3. Normalise once, up front, so the same canonical value flows into the LDAP
|
||||
// filter and the returned result (avoids two identities for one person).
|
||||
username = username.Trim();
|
||||
|
||||
// The whole flow runs inside an outer fail-closed guard: a StageFailure carries an
|
||||
// already-mapped failure out of a stage, and any OTHER unexpected exception defaults to
|
||||
// the most conservative system-side bucket. Either way the caller gets a structured result.
|
||||
try
|
||||
{
|
||||
using var conn = _connectionFactory.Create();
|
||||
|
||||
// 4. Open the connection (transport/TLS handling lives in the adapter). The
|
||||
// per-operation timeout (ConnectionTimeoutMs) is applied by the adapter here.
|
||||
// A failure to connect/upgrade means the directory is unreachable — a
|
||||
// system-side fault, not the user's, so map it to ServiceAccountBindFailed.
|
||||
// NOTE: the LdapAuthFailure enum has no dedicated DirectoryUnavailable value;
|
||||
// ServiceAccountBindFailed is the closest system-side bucket. A future
|
||||
// Abstractions change could add DirectoryUnavailable to disambiguate.
|
||||
try
|
||||
{
|
||||
conn.Connect(_options.Server, _options.Port, _options.Transport, _options.AllowInsecure, _options.ConnectionTimeoutMs);
|
||||
}
|
||||
catch (LdapException)
|
||||
{
|
||||
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed);
|
||||
}
|
||||
|
||||
// 5. 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);
|
||||
}
|
||||
|
||||
// 6. 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);
|
||||
}
|
||||
|
||||
// 7. Require exactly one match. Zero -> UserNotFound; two or more -> AmbiguousUser.
|
||||
// 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];
|
||||
|
||||
// 8. User bind: re-bind as the resolved DN to verify the password. A bind failure
|
||||
// here is the end user's bad credentials.
|
||||
try
|
||||
{
|
||||
conn.Bind(entry.Dn, password);
|
||||
}
|
||||
catch (LdapException)
|
||||
{
|
||||
throw new StageFailure(LdapAuthFailure.BadCredentials);
|
||||
}
|
||||
|
||||
// 9. Group extraction. Fail closed: an empty/missing group set, or any error while
|
||||
// extracting groups, is a GroupLookupFailed — never a zero-group success.
|
||||
IReadOnlyList<string> groups;
|
||||
try
|
||||
{
|
||||
groups = ExtractGroups(entry);
|
||||
}
|
||||
catch
|
||||
{
|
||||
throw new StageFailure(LdapAuthFailure.GroupLookupFailed);
|
||||
}
|
||||
|
||||
if (groups.Count == 0)
|
||||
return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed);
|
||||
|
||||
// 10. Success — and only here, with a verified password and >= 1 group.
|
||||
var displayName = ExtractDisplayName(entry, username);
|
||||
return LdapAuthResult.Success(username, displayName, groups);
|
||||
}
|
||||
catch (StageFailure stage)
|
||||
{
|
||||
// A stage mapped its own failure; surface it as a structured result.
|
||||
return LdapAuthResult.Fail(stage.Failure);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Belt-and-braces: ANY unexpected exception fails closed to the most conservative
|
||||
// system-side bucket rather than propagating to the caller.
|
||||
return LdapAuthResult.Fail(LdapAuthFailure.ServiceAccountBindFailed);
|
||||
}
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// </summary>
|
||||
private sealed class StageFailure : Exception
|
||||
{
|
||||
public StageFailure(LdapAuthFailure failure) => Failure = failure;
|
||||
|
||||
public LdapAuthFailure Failure { get; }
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// </summary>
|
||||
private IReadOnlyList<string> BuildSearchAttributes()
|
||||
{
|
||||
if (string.Equals(_options.DisplayNameAttribute, _options.GroupAttribute, StringComparison.OrdinalIgnoreCase))
|
||||
return new[] { _options.DisplayNameAttribute };
|
||||
|
||||
return new[] { _options.DisplayNameAttribute, _options.GroupAttribute };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the first value of the configured display-name attribute, falling back to
|
||||
/// the (already normalised) username when the directory entry has no such attribute.
|
||||
/// </summary>
|
||||
private string ExtractDisplayName(LdapSearchEntry entry, string username)
|
||||
{
|
||||
if (entry.Attributes.TryGetValue(_options.DisplayNameAttribute, out var values) && values.Count > 0)
|
||||
return values[0];
|
||||
|
||||
return username;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts group short names from the configured group attribute. Each value is a
|
||||
/// group DN (e.g. <c>cn=Engineers,ou=g,dc=x</c>); the first RDN's value is returned
|
||||
/// (e.g. <c>Engineers</c>), RFC 4514 escape-aware so an escaped comma in the CN is
|
||||
/// preserved rather than truncating the name.
|
||||
/// </summary>
|
||||
private IReadOnlyList<string> ExtractGroups(LdapSearchEntry entry)
|
||||
{
|
||||
if (!entry.Attributes.TryGetValue(_options.GroupAttribute, out var values) || values.Count == 0)
|
||||
return Array.Empty<string>();
|
||||
|
||||
var groups = new List<string>(values.Count);
|
||||
foreach (var value in values)
|
||||
groups.Add(ToGroupShortName(value));
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Yields a group's short name from its DN by returning the value of the first RDN
|
||||
/// (e.g. <c>cn=Engineers,ou=g,dc=x</c> → <c>Engineers</c>). The extraction is RFC 4514
|
||||
/// escape-aware (<see cref="LdapEscaping.FirstRdnValue"/>), so a CN that legitimately
|
||||
/// contains an escaped comma — <c>cn=Eng\,ineers,...</c> or <c>cn=A\2cB,...</c> — is
|
||||
/// returned intact rather than truncated at the escaped comma. Values with no <c>=</c>
|
||||
/// are returned unchanged.
|
||||
/// </summary>
|
||||
private static string ToGroupShortName(string groupDn)
|
||||
=> LdapEscaping.FirstRdnValue(groupDn);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.Ldap;
|
||||
|
||||
/// <summary>
|
||||
/// Validates <see cref="LdapOptions"/> at startup so a misconfiguration fails fast at
|
||||
/// boot with a clear, field-naming message — rather than surfacing later as an opaque
|
||||
/// low-level error on the first real login attempt.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Four conditions are enforced:
|
||||
/// <list type="bullet">
|
||||
/// <item>plaintext transport (<see cref="LdapTransport.None"/>) is rejected unless
|
||||
/// <see cref="LdapOptions.AllowInsecure"/> is explicitly set (dev/test only);</item>
|
||||
/// <item><see cref="LdapOptions.Server"/> must be specified (no sane default host);</item>
|
||||
/// <item><see cref="LdapOptions.SearchBase"/> must be specified (the DN root every
|
||||
/// search runs against);</item>
|
||||
/// <item><see cref="LdapOptions.ServiceAccountDn"/> must be specified — an empty value
|
||||
/// would bind anonymously, defeating the search-then-bind authentication flow.</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public sealed class LdapOptionsValidator : IValidateOptions<LdapOptions>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public ValidateOptionsResult Validate(string? name, LdapOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
if (options.Transport == LdapTransport.None && !options.AllowInsecure)
|
||||
{
|
||||
return ValidateOptionsResult.Fail(
|
||||
$"{nameof(LdapOptions.Transport)} is {nameof(LdapTransport.None)} (insecure/plaintext) " +
|
||||
$"but {nameof(LdapOptions.AllowInsecure)} is false. Enable TLS " +
|
||||
$"({nameof(LdapTransport.Ldaps)} or {nameof(LdapTransport.StartTls)}) " +
|
||||
$"or set {nameof(LdapOptions.AllowInsecure)} for dev/test.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.Server))
|
||||
{
|
||||
return ValidateOptionsResult.Fail(
|
||||
$"{nameof(LdapOptions.Server)} is required but was empty or whitespace — " +
|
||||
"set it to the LDAP server hostname or IP (e.g. \"ldap.example.com\").");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.SearchBase))
|
||||
{
|
||||
return ValidateOptionsResult.Fail(
|
||||
$"{nameof(LdapOptions.SearchBase)} is required but was empty or whitespace — " +
|
||||
"set it to the search-base DN (e.g. \"dc=example,dc=com\").");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.ServiceAccountDn))
|
||||
{
|
||||
return ValidateOptionsResult.Fail(
|
||||
$"{nameof(LdapOptions.ServiceAccountDn)} is required but was empty or whitespace — " +
|
||||
"an empty value would bind anonymously. Set it to the service-account DN " +
|
||||
"(e.g. \"cn=svc,dc=example,dc=com\").");
|
||||
}
|
||||
|
||||
return ValidateOptionsResult.Success;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>true</IsPackable>
|
||||
<PackageId>ZB.MOM.WW.Auth.Ldap</PackageId>
|
||||
<Authors>ZB.MOM.WW</Authors>
|
||||
<Description>LDAP authentication service (GLAuth / Active Directory) for the ZB.MOM.WW SCADA family.</Description>
|
||||
<PackageProjectUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-auth</PackageProjectUrl>
|
||||
<RepositoryUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-auth</RepositoryUrl>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.Auth.Abstractions\ZB.MOM.WW.Auth.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Novell.Directory.Ldap.NETStandard" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.Auth.Ldap.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user