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:
dohertj2
2026-06-01 03:59:23 -04:00
commit 37e23cf9f2
73 changed files with 6836 additions and 0 deletions
@@ -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: , + " \ &lt; &gt; ; (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>