feat(auth): cut ScadaBridge over to ZB.MOM.WW.Auth.Ldap; nest+rename Ldap config; roles+sitescope via IGroupRoleMapper (Task 1.2/1.4)

This commit is contained in:
Joseph Doherty
2026-06-02 01:04:34 -04:00
parent 9230afa25f
commit ac34dac479
31 changed files with 647 additions and 1132 deletions
@@ -5,6 +5,8 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.Auth.Abstractions.Roles;
using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
@@ -31,20 +33,25 @@ public static class AuthEndpoints
return;
}
var ldapAuth = context.RequestServices.GetRequiredService<LdapAuthService>();
var ldapAuth = context.RequestServices.GetRequiredService<ILdapAuthService>();
var jwtService = context.RequestServices.GetRequiredService<JwtTokenService>();
var roleMapper = context.RequestServices.GetRequiredService<RoleMapper>();
var roleMapper = context.RequestServices.GetRequiredService<IGroupRoleMapper<string>>();
var authResult = await ldapAuth.AuthenticateAsync(username, password);
if (!authResult.Success)
var authResult = await ldapAuth.AuthenticateAsync(username, password, context.RequestAborted);
if (!authResult.Succeeded)
{
var errorMsg = Uri.EscapeDataString(authResult.ErrorMessage ?? "Authentication failed.");
var errorMsg = Uri.EscapeDataString(LdapAuthFailureMessages.ToMessage(authResult.Failure));
context.Response.Redirect($"/login?error={errorMsg}");
return;
}
// Map LDAP groups to roles
var roleMappingResult = await roleMapper.MapGroupsToRolesAsync(authResult.Groups ?? []);
// Map LDAP groups to roles via the shared IGroupRoleMapper<string> seam
// (Task 1.1 ScadaBridgeGroupRoleMapper, wrapping the DB-backed RoleMapper).
// The full RoleMappingResult — including PermittedSiteIds and the
// system-wide flag — is carried in the mapping's opaque Scope so the
// site-scope→SiteId claims below are built exactly as before.
var roleMapping = await roleMapper.MapAsync(authResult.Groups, context.RequestAborted);
var scope = (RoleMappingResult)roleMapping.Scope!;
// Build claims from LDAP auth + role mapping.
// CentralUI-005: no fixed "expires_at" absolute-cap claim is stamped
@@ -52,21 +59,23 @@ public static class AuthEndpoints
// (ZB.MOM.WW.ScadaBridge.Security AddCookie: ExpireTimeSpan = idle timeout,
// SlidingExpiration = true). A frozen absolute claim would contradict
// the documented sliding-refresh policy.
var displayName = string.IsNullOrEmpty(authResult.DisplayName) ? username : authResult.DisplayName;
var resolvedUsername = string.IsNullOrEmpty(authResult.Username) ? username : authResult.Username;
var claims = new List<Claim>
{
new(ClaimTypes.Name, authResult.Username ?? username),
new(JwtTokenService.DisplayNameClaimType, authResult.DisplayName ?? username),
new(JwtTokenService.UsernameClaimType, authResult.Username ?? username),
new(ClaimTypes.Name, resolvedUsername),
new(JwtTokenService.DisplayNameClaimType, displayName),
new(JwtTokenService.UsernameClaimType, resolvedUsername),
};
foreach (var role in roleMappingResult.Roles)
foreach (var role in roleMapping.Roles)
{
claims.Add(new Claim(JwtTokenService.RoleClaimType, role));
}
if (!roleMappingResult.IsSystemWideDeployment)
if (!scope.IsSystemWideDeployment)
{
foreach (var siteId in roleMappingResult.PermittedSiteIds)
foreach (var siteId in scope.PermittedSiteIds)
{
claims.Add(new Claim(JwtTokenService.SiteIdClaimType, siteId));
}
@@ -94,33 +103,37 @@ public static class AuthEndpoints
return Results.Json(new { error = "Username and password are required." }, statusCode: 400);
}
var ldapAuth = context.RequestServices.GetRequiredService<LdapAuthService>();
var ldapAuth = context.RequestServices.GetRequiredService<ILdapAuthService>();
var jwtService = context.RequestServices.GetRequiredService<JwtTokenService>();
var roleMapper = context.RequestServices.GetRequiredService<RoleMapper>();
var roleMapper = context.RequestServices.GetRequiredService<IGroupRoleMapper<string>>();
var authResult = await ldapAuth.AuthenticateAsync(username, password);
if (!authResult.Success)
var authResult = await ldapAuth.AuthenticateAsync(username, password, context.RequestAborted);
if (!authResult.Succeeded)
{
return Results.Json(
new { error = authResult.ErrorMessage ?? "Authentication failed." },
new { error = LdapAuthFailureMessages.ToMessage(authResult.Failure) },
statusCode: 401);
}
var roleMappingResult = await roleMapper.MapGroupsToRolesAsync(authResult.Groups ?? []);
var roleMapping = await roleMapper.MapAsync(authResult.Groups, context.RequestAborted);
var scope = (RoleMappingResult)roleMapping.Scope!;
var displayName = string.IsNullOrEmpty(authResult.DisplayName) ? username : authResult.DisplayName;
var resolvedUsername = string.IsNullOrEmpty(authResult.Username) ? username : authResult.Username;
var token = jwtService.GenerateToken(
authResult.DisplayName ?? username,
authResult.Username ?? username,
roleMappingResult.Roles,
roleMappingResult.IsSystemWideDeployment ? null : roleMappingResult.PermittedSiteIds);
displayName,
resolvedUsername,
roleMapping.Roles,
scope.IsSystemWideDeployment ? null : scope.PermittedSiteIds);
return Results.Json(new
{
access_token = token,
token_type = "Bearer",
username = authResult.Username ?? username,
display_name = authResult.DisplayName ?? username,
roles = roleMappingResult.Roles,
username = resolvedUsername,
display_name = displayName,
roles = roleMapping.Roles,
});
}).DisableAntiforgery();
+1 -1
View File
@@ -103,7 +103,7 @@ try
builder.Services.AddSiteCallAudit();
builder.Services.AddTemplateEngine();
builder.Services.AddDeploymentManager();
builder.Services.AddSecurity();
builder.Services.AddSecurity(builder.Configuration);
builder.Services.AddCentralUI();
builder.Services.AddInboundAPI();
builder.Services.AddManagementService();
@@ -60,8 +60,14 @@ public static class StartupValidator
.Require("ScadaBridge:Database:ConfigurationDb",
_ => !string.IsNullOrEmpty(configuration.GetSection("ScadaBridge:Database")["ConfigurationDb"]),
"connection string required for Central")
.Require("ScadaBridge:Security:LdapServer",
_ => !string.IsNullOrEmpty(configuration.GetSection("ScadaBridge:Security")["LdapServer"]),
// Task 1.4: the LDAP server key moved into the nested Security:Ldap
// sub-section (bound to the shared LdapOptions). Validate the nested key so
// the pre-host preflight still fails fast on a missing LDAP server for
// Central. The full LDAP option set (SearchBase / ServiceAccountDn /
// transport) is additionally validated post-host by the shared
// LdapOptionsValidator (registered with ValidateOnStart by AddZbLdapAuth).
.Require("ScadaBridge:Security:Ldap:Server",
_ => !string.IsNullOrEmpty(configuration.GetSection("ScadaBridge:Security:Ldap")["Server"]),
"required for Central")
.Require("ScadaBridge:Security:JwtSigningKey",
_ => !string.IsNullOrEmpty(configuration.GetSection("ScadaBridge:Security")["JwtSigningKey"]),
@@ -18,18 +18,20 @@
"FailureDetectionThreshold": "00:00:10",
"MinNrOfMembers": 1
},
"_secrets": "Host-003: Secrets are NOT committed in this file. Supply them via environment variables, which the Host's configuration builder (AddEnvironmentVariables) overlays over this file. Required: ScadaBridge__Database__ConfigurationDb, ScadaBridge__Security__LdapServiceAccountPassword, ScadaBridge__Security__JwtSigningKey. The ${...} placeholders below are intentionally non-functional and must be overridden per environment.",
"_secrets": "Host-003: Secrets are NOT committed in this file. Supply them via environment variables, which the Host's configuration builder (AddEnvironmentVariables) overlays over this file. Required: ScadaBridge__Database__ConfigurationDb, ScadaBridge__Security__Ldap__ServiceAccountPassword, ScadaBridge__Security__JwtSigningKey. The ${...} placeholders below are intentionally non-functional and must be overridden per environment. NOTE (Task 1.4): the LDAP settings moved into the nested Security:Ldap sub-section (bound to the shared ZB.MOM.WW.Auth LdapOptions) — the service-account-password env var is now ScadaBridge__Security__Ldap__ServiceAccountPassword (was ScadaBridge__Security__LdapServiceAccountPassword).",
"Database": {
"ConfigurationDb": "${SCADABRIDGE_CONFIGURATIONDB_CONNECTION_STRING}"
},
"Security": {
"LdapServer": "localhost",
"LdapPort": 3893,
"LdapUseTls": false,
"AllowInsecureLdap": true,
"LdapSearchBase": "dc=scadabridge,dc=local",
"LdapServiceAccountDn": "cn=admin,dc=scadabridge,dc=local",
"LdapServiceAccountPassword": "${SCADABRIDGE_LDAP_SERVICE_ACCOUNT_PASSWORD}",
"Ldap": {
"Server": "localhost",
"Port": 3893,
"Transport": "None",
"AllowInsecure": true,
"SearchBase": "dc=scadabridge,dc=local",
"ServiceAccountDn": "cn=admin,dc=scadabridge,dc=local",
"ServiceAccountPassword": "${SCADABRIDGE_LDAP_SERVICE_ACCOUNT_PASSWORD}"
},
"JwtSigningKey": "${SCADABRIDGE_JWT_SIGNING_KEY}",
"JwtExpiryMinutes": 15,
"IdleTimeoutMinutes": 30
@@ -11,6 +11,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.ManagementService;
@@ -355,25 +356,24 @@ public static class AuditEndpoints
new { error = "Username and password are required.", code = "AUTH_FAILED" }, statusCode: 401));
}
var ldapAuth = context.RequestServices.GetRequiredService<LdapAuthService>();
var authResult = await ldapAuth.AuthenticateAsync(username, password);
if (!authResult.Success)
var ldapAuth = context.RequestServices.GetRequiredService<ILdapAuthService>();
var authResult = await ldapAuth.AuthenticateAsync(username, password, context.RequestAborted);
if (!authResult.Succeeded)
{
return new AuthOutcome(null, Results.Json(
new { error = authResult.ErrorMessage ?? "Authentication failed.", code = "AUTH_FAILED" }, statusCode: 401));
new { error = LdapAuthFailureMessages.ToMessage(authResult.Failure), code = "AUTH_FAILED" }, statusCode: 401));
}
var roleMapper = context.RequestServices.GetRequiredService<RoleMapper>();
var mappingResult = await roleMapper.MapGroupsToRolesAsync(
authResult.Groups ?? (IReadOnlyList<string>)Array.Empty<string>());
var mappingResult = await roleMapper.MapGroupsToRolesAsync(authResult.Groups, context.RequestAborted);
var permittedSiteIds = mappingResult.IsSystemWideDeployment
? Array.Empty<string>()
: mappingResult.PermittedSiteIds.ToArray();
var user = new AuthenticatedUser(
authResult.Username!,
authResult.DisplayName!,
authResult.Username,
authResult.DisplayName,
mappingResult.Roles.ToArray(),
permittedSiteIds);
@@ -6,6 +6,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.ManagementService;
@@ -99,9 +100,9 @@ public class DebugStreamHub : Hub
}
// LDAP authentication
var ldapAuth = httpContext.RequestServices.GetRequiredService<LdapAuthService>();
var authResult = await ldapAuth.AuthenticateAsync(username, password);
if (!authResult.Success)
var ldapAuth = httpContext.RequestServices.GetRequiredService<ILdapAuthService>();
var authResult = await ldapAuth.AuthenticateAsync(username, password, Context.ConnectionAborted);
if (!authResult.Succeeded)
{
_logger.LogWarning("DebugStreamHub connection rejected: LDAP auth failed for {Username}", username);
Context.Abort();
@@ -110,8 +111,7 @@ public class DebugStreamHub : Hub
// Role check — Deployment role required
var roleMapper = httpContext.RequestServices.GetRequiredService<RoleMapper>();
var mappingResult = await roleMapper.MapGroupsToRolesAsync(
authResult.Groups ?? (IReadOnlyList<string>)Array.Empty<string>());
var mappingResult = await roleMapper.MapGroupsToRolesAsync(authResult.Groups, Context.ConnectionAborted);
if (!mappingResult.Roles.Contains("Deployment"))
{
@@ -8,6 +8,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.ManagementService;
@@ -85,27 +86,26 @@ public static class ManagementEndpoints
}
// 2. LDAP authentication
var ldapAuth = context.RequestServices.GetRequiredService<LdapAuthService>();
var authResult = await ldapAuth.AuthenticateAsync(username, password);
if (!authResult.Success)
var ldapAuth = context.RequestServices.GetRequiredService<ILdapAuthService>();
var authResult = await ldapAuth.AuthenticateAsync(username, password, context.RequestAborted);
if (!authResult.Succeeded)
{
return Results.Json(
new { error = authResult.ErrorMessage ?? "Authentication failed.", code = "AUTH_FAILED" },
new { error = LdapAuthFailureMessages.ToMessage(authResult.Failure), code = "AUTH_FAILED" },
statusCode: 401);
}
// 3. Role resolution
var roleMapper = context.RequestServices.GetRequiredService<RoleMapper>();
var mappingResult = await roleMapper.MapGroupsToRolesAsync(
authResult.Groups ?? (IReadOnlyList<string>)Array.Empty<string>());
var mappingResult = await roleMapper.MapGroupsToRolesAsync(authResult.Groups, context.RequestAborted);
var permittedSiteIds = mappingResult.IsSystemWideDeployment
? Array.Empty<string>()
: mappingResult.PermittedSiteIds.ToArray();
var authenticatedUser = new AuthenticatedUser(
authResult.Username!,
authResult.DisplayName!,
authResult.Username,
authResult.DisplayName,
mappingResult.Roles.ToArray(),
permittedSiteIds);
@@ -0,0 +1,87 @@
using ZB.MOM.WW.Auth.Abstractions.Ldap;
namespace ZB.MOM.WW.ScadaBridge.Security;
/// <summary>
/// Translates the shared <see cref="LdapAuthFailure"/> enum returned by
/// <c>ZB.MOM.WW.Auth.Ldap</c>'s <see cref="ILdapAuthService"/> into the
/// user-facing error strings ScadaBridge surfaced from its bespoke
/// <c>LdapAuthService</c> before the Task 1.2 cutover.
/// </summary>
/// <remarks>
/// <para>
/// The cutover replaced ScadaBridge's hand-rolled LDAP client (which returned a
/// pre-formatted <c>ErrorMessage</c> string) with the shared library service
/// (which returns a structured <see cref="LdapAuthFailure"/> code). This single
/// adapter keeps the externally observable error text stable across the login
/// (<c>/auth/login</c>, <c>/auth/token</c>) and Basic-Auth (ManagementService)
/// surfaces so the user-visible behaviour does not regress.
/// </para>
/// <para>
/// Message-mapping rationale, preserving the donor's deliberate security framing:
/// <list type="bullet">
/// <item><see cref="LdapAuthFailure.BadCredentials"/> and
/// <see cref="LdapAuthFailure.UserNotFound"/> both map to the same generic
/// "Invalid username or password." — a username-enumeration guard
/// (Security): a "user not found" message must be indistinguishable from a
/// "wrong password" message.</item>
/// <item><see cref="LdapAuthFailure.AmbiguousUser"/> (the directory returned
/// two or more entries for the username) is a directory-data fault, not a
/// user-credential one. The donor never attempted an ambiguous bind; the
/// library rejects it outright. Surfaced as the misconfiguration message so
/// the operator — not the user — is pointed at the cause.</item>
/// <item><see cref="LdapAuthFailure.ServiceAccountBindFailed"/> keeps the
/// donor's distinct "service is misconfigured" wording (Security-019) so a
/// system-side fault is not blamed on user input. NOTE: the library also maps
/// connect/search infrastructure failures (directory unreachable) into this
/// bucket, so this message now covers "directory unavailable at connect/search
/// time" as well — see <see cref="LdapAuthFailure.GroupLookupFailed"/> for the
/// post-bind directory-outage case.</item>
/// <item><see cref="LdapAuthFailure.GroupLookupFailed"/> keeps the donor's
/// "directory is temporarily unavailable" wording (Security-012): a post-bind
/// group-lookup failure means the directory is partially unavailable and the
/// login is failed closed rather than admitting a roleless session. NOTE: the
/// library additionally treats a successful-but-empty group set as
/// <see cref="LdapAuthFailure.GroupLookupFailed"/>, whereas the donor admitted
/// an empty-group user as a successful (roleless) login — a documented
/// behavioural deviation of the cutover.</item>
/// <item><see cref="LdapAuthFailure.Disabled"/> (the provider is turned off via
/// <c>Enabled = false</c>) maps to a neutral "not available" message.</item>
/// </list>
/// </para>
/// </remarks>
public static class LdapAuthFailureMessages
{
/// <summary>The generic, enumeration-safe message for a bad-credentials / user-not-found failure.</summary>
public const string InvalidCredentials = "Invalid username or password.";
/// <summary>The system-misconfiguration message (service-account bind / ambiguous user / unreachable directory).</summary>
public const string Misconfigured = "Authentication service is misconfigured. Contact an administrator.";
/// <summary>The transient directory-outage message for a post-bind group-lookup failure.</summary>
public const string DirectoryUnavailable = "The directory is temporarily unavailable. Please try again.";
/// <summary>The provider-disabled message.</summary>
public const string Disabled = "Authentication is not available.";
/// <summary>The fallback message for an unrecognised failure code.</summary>
public const string Generic = "Authentication failed.";
/// <summary>
/// Maps a <see cref="LdapAuthFailure"/> to its user-facing message. A
/// <see langword="null"/> failure (which should not occur on a failed result)
/// falls back to <see cref="Generic"/>.
/// </summary>
/// <param name="failure">The structured failure code from <see cref="LdapAuthResult.Failure"/>.</param>
/// <returns>The user-facing error string to surface.</returns>
public static string ToMessage(LdapAuthFailure? failure) => failure switch
{
LdapAuthFailure.BadCredentials => InvalidCredentials,
LdapAuthFailure.UserNotFound => InvalidCredentials,
LdapAuthFailure.AmbiguousUser => Misconfigured,
LdapAuthFailure.ServiceAccountBindFailed => Misconfigured,
LdapAuthFailure.GroupLookupFailed => DirectoryUnavailable,
LdapAuthFailure.Disabled => Disabled,
_ => Generic,
};
}
@@ -1,8 +0,0 @@
namespace ZB.MOM.WW.ScadaBridge.Security;
public record LdapAuthResult(
bool Success,
string? DisplayName,
string? Username,
IReadOnlyList<string>? Groups,
string? ErrorMessage);
@@ -1,417 +0,0 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Novell.Directory.Ldap;
namespace ZB.MOM.WW.ScadaBridge.Security;
public class LdapAuthService
{
private readonly SecurityOptions _options;
private readonly ILogger<LdapAuthService> _logger;
/// <summary>Initializes a new instance of <see cref="LdapAuthService"/> with the given options and logger.</summary>
/// <param name="options">Security configuration options including LDAP server settings.</param>
/// <param name="logger">Logger for authentication diagnostics.</param>
public LdapAuthService(IOptions<SecurityOptions> options, ILogger<LdapAuthService> logger)
{
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
// virtual: a test seam so HTTP-pipeline tests (e.g. the #23 M8 audit
// endpoints) can substitute the LDAP bind without standing up a directory.
/// <summary>Authenticates a user against the configured LDAP directory and returns an auth result with roles.</summary>
/// <param name="username">The plain-text username to authenticate.</param>
/// <param name="password">The plain-text password to bind with.</param>
/// <param name="ct">Cancellation token.</param>
public virtual async Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(username))
return new LdapAuthResult(false, null, null, null, "Username is required.");
if (string.IsNullOrWhiteSpace(password))
return new LdapAuthResult(false, null, null, null, "Password is required.");
// Trim once, up front: a username with leading/trailing whitespace (copy-paste
// artefacts, mobile keyboards) is otherwise passed verbatim into the LDAP filter,
// the fallback bind DN, and — most consequentially — the JWT Username claim and
// audit trail, producing two distinct identities for the same person
// (Security-015). The IsNullOrWhiteSpace guard above already rejects an
// all-whitespace value, so the trimmed result here is always non-empty.
username = NormalizeUsername(username);
// Enforce TLS unless explicitly allowed for dev/test
if (_options.LdapTransport == LdapTransport.None && !_options.AllowInsecureLdap)
{
return new LdapAuthResult(false, null, null, null,
"Insecure LDAP connections are not allowed. Enable TLS or set AllowInsecureLdap for dev/test.");
}
try
{
using var connection = new LdapConnection();
// Bound how long a hung LDAP server can pin a thread-pool thread. The
// `ct` passed to Task.Run below only prevents the work item from starting;
// it cannot interrupt an in-progress blocking Connect/Bind/Search. This
// timeout is the real safeguard (Security-009).
ApplyConnectionTimeout(connection);
// LDAPS: TLS negotiated at connection time. StartTLS: connect plaintext,
// then upgrade the session before any credentials are sent.
if (_options.LdapTransport == LdapTransport.Ldaps)
{
connection.SecureSocketLayer = true;
}
await Task.Run(() => connection.Connect(_options.LdapServer, _options.LdapPort), ct);
if (_options.LdapTransport == LdapTransport.StartTls)
{
await Task.Run(() => connection.StartTls(), ct);
if (!connection.Tls)
{
return new LdapAuthResult(false, null, null, null,
"StartTLS upgrade did not produce an encrypted session.");
}
}
// Resolve the user's actual DN, then bind with their credentials
var bindDn = await ResolveUserDnAsync(connection, username, ct);
await Task.Run(() => connection.Bind(bindDn, password), ct);
// Re-bind as service account for attribute/group lookup (user may lack search rights).
// A failure here is the SYSTEM's misconfiguration (wrong service-account credentials,
// disabled/locked account) — not the user's credential problem. The user bind on the
// line above already succeeded, so masking this as "Invalid username or password" would
// route operators down the wrong incident path (Security-019).
if (!string.IsNullOrWhiteSpace(_options.LdapServiceAccountDn))
{
await BindServiceAccountAsync(connection, ct);
}
// Query for user attributes and group memberships
var displayName = username;
var groups = new List<string>();
var groupLookupSucceeded = true;
try
{
var searchFilter = $"({_options.LdapUserIdAttribute}={EscapeLdapFilter(username)})";
var searchResults = await Task.Run(() =>
connection.Search(
_options.LdapSearchBase,
LdapConnection.ScopeSub,
searchFilter,
new[] { _options.LdapDisplayNameAttribute, _options.LdapGroupAttribute },
false), ct);
// `HasMore()` is the loop guard for end-of-results; it returns false
// when the enumeration is exhausted. An LdapException thrown by
// `Next()` inside a HasMore()-guarded loop is therefore NOT a benign
// "no more results" sentinel — it is a genuine error (referral failure,
// server-side limit, transport drop mid-enumeration). The previous
// `catch (LdapException) { break; }` silently truncated the group list
// and masked a partial outage (Security-012); such an exception now
// propagates to the outer catch and fails the login.
while (searchResults.HasMore())
{
var entry = searchResults.Next();
var dnAttr = entry.GetAttribute(_options.LdapDisplayNameAttribute);
if (dnAttr != null)
displayName = dnAttr.StringValue;
var groupAttr = entry.GetAttribute(_options.LdapGroupAttribute);
if (groupAttr != null)
{
foreach (var groupDn in groupAttr.StringValueArray)
{
groups.Add(ExtractFirstRdnValue(groupDn));
}
}
}
}
catch (LdapException ex)
{
// A failed group/attribute lookup on initial login means the directory
// is partially unavailable. The design's LDAP-failure rule requires new
// logins to FAIL when LDAP is unavailable — admitting the user here
// would yield an authenticated session with zero roles (Security-012).
_logger.LogWarning(ex, "LDAP group/attribute lookup failed for user {Username}; failing the login per the LDAP-failure rule", username);
groupLookupSucceeded = false;
}
connection.Disconnect();
return BuildAuthResultFromGroupLookup(username, displayName, groups, groupLookupSucceeded);
}
catch (ServiceAccountBindException ex)
{
// Distinct from the user-credential catch below so the operator
// sees the *system* misconfiguration rather than blaming user input
// (Security-019). The inner exception was already logged at Error
// by BindServiceAccountAsync; nothing further to log here.
_ = ex;
return new LdapAuthResult(false, null, username, null,
"Authentication service is misconfigured. Contact an administrator.");
}
catch (LdapException ex)
{
_logger.LogWarning(ex, "LDAP authentication failed for user {Username}", username);
return new LdapAuthResult(false, null, username, null, "Invalid username or password.");
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Unexpected error during LDAP authentication for user {Username}", username);
return new LdapAuthResult(false, null, username, null, "An unexpected error occurred during authentication.");
}
}
/// <summary>
/// Binds the supplied connection as the configured service account. A failure here is
/// a system-misconfiguration condition (Security-019) — wrong service-account DN /
/// password, locked or disabled account, server-side ACL change — not a user-credential
/// problem. The underlying <see cref="LdapException"/> is logged at Error and rethrown
/// as <see cref="ServiceAccountBindException"/> so callers can distinguish it from a
/// user-bind failure.
/// </summary>
private async Task BindServiceAccountAsync(LdapConnection connection, CancellationToken ct)
{
try
{
await Task.Run(() =>
connection.Bind(_options.LdapServiceAccountDn, _options.LdapServiceAccountPassword), ct);
}
catch (LdapException ex)
{
_logger.LogError(ex,
"Service-account rebind failed; check LdapServiceAccountDn / LdapServiceAccountPassword configuration");
throw new ServiceAccountBindException(ex);
}
}
/// <summary>
/// Applies <see cref="SecurityOptions.LdapConnectionTimeoutMs"/> to both the socket
/// connect timeout and the per-operation (bind/search) time limit, so a hung or
/// unresponsive LDAP server cannot pin a thread-pool thread indefinitely. The
/// <c>CancellationToken</c> handed to the <c>Task.Run</c> wrappers only guards
/// work-item scheduling and cannot interrupt an in-progress blocking call.
/// </summary>
private void ApplyConnectionTimeout(LdapConnection connection)
{
var timeoutMs = _options.LdapConnectionTimeoutMs;
if (timeoutMs <= 0)
return;
connection.ConnectionTimeout = timeoutMs;
// LdapConstraints.TimeLimit is the server-side operation time limit in ms.
var constraints = connection.Constraints;
constraints.TimeLimit = timeoutMs;
connection.Constraints = constraints;
}
/// <summary>
/// Resolves the user's full DN. When a service account is configured, performs a
/// search-then-bind lookup. Otherwise falls back to constructing the DN directly.
/// </summary>
private async Task<string> ResolveUserDnAsync(LdapConnection connection, string username, CancellationToken ct)
{
// If a service account is configured, search for the user's actual DN.
// The service-account bind is routed through BindServiceAccountAsync so a
// misconfiguration surfaces distinctly rather than masking as
// "Invalid username or password" (Security-019).
if (!string.IsNullOrWhiteSpace(_options.LdapServiceAccountDn))
{
await BindServiceAccountAsync(connection, ct);
var searchFilter = $"({_options.LdapUserIdAttribute}={EscapeLdapFilter(username)})";
var searchResults = await Task.Run(() =>
connection.Search(
_options.LdapSearchBase,
LdapConnection.ScopeSub,
searchFilter,
new[] { "dn" },
false), ct);
if (searchResults.HasMore())
{
var entry = searchResults.Next();
return entry.Dn;
}
throw new LdapException("User not found", LdapException.NoSuchObject,
$"No entry found for {_options.LdapUserIdAttribute}={username}");
}
// Fallback: construct the bind DN directly from the configured user-id
// attribute. The username is RFC 4514 DN-escaped so it cannot alter the
// DN structure (Security-005). The previous Contains('=') shortcut that
// accepted a raw caller-supplied DN has been removed — accepting an
// arbitrary DN from untrusted input let a client choose the bind identity.
return BuildFallbackUserDn(username, _options.LdapSearchBase, _options.LdapUserIdAttribute);
}
/// <summary>
/// Builds the no-service-account fallback bind DN as
/// <c>{userIdAttribute}={escaped-username}[,{searchBase}]</c>. The username is
/// escaped per RFC 4514 so DN metacharacters in untrusted input cannot inject
/// additional RDN components or change the bind identity.
/// </summary>
/// <param name="username">The username to embed in the DN value.</param>
/// <param name="searchBase">The LDAP search base to append after the RDN, if any.</param>
/// <param name="userIdAttribute">The attribute name (e.g. <c>uid</c> or <c>sAMAccountName</c>) used as the RDN type.</param>
public static string BuildFallbackUserDn(string username, string searchBase, string userIdAttribute)
{
var rdn = $"{userIdAttribute}={EscapeLdapDn(username)}";
return string.IsNullOrWhiteSpace(searchBase) ? rdn : $"{rdn},{searchBase}";
}
/// <summary>
/// Escapes a string for use as an RFC 4514 DN attribute value: the special
/// characters <c>, + " \ &lt; &gt; ;</c> are backslash-escaped, as are a leading
/// or trailing space and a leading <c>#</c>.
/// </summary>
/// <param name="input">The raw string to escape.</param>
public static string EscapeLdapDn(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];
var isEdgeSpace = c == ' ' && (i == 0 || i == input.Length - 1);
var isLeadingHash = c == '#' && i == 0;
switch (c)
{
case ',':
case '+':
case '"':
case '\\':
case '<':
case '>':
case ';':
sb.Append('\\').Append(c);
break;
case '\0':
sb.Append("\\00");
break;
default:
if (isEdgeSpace || isLeadingHash)
sb.Append('\\');
sb.Append(c);
break;
}
}
return sb.ToString();
}
private static string EscapeLdapFilter(string input)
{
return input
.Replace("\\", "\\5c")
.Replace("*", "\\2a")
.Replace("(", "\\28")
.Replace(")", "\\29")
.Replace("\0", "\\00");
}
/// <summary>
/// Normalises a username by trimming leading and trailing whitespace. Applied once
/// at the top of <see cref="AuthenticateAsync"/> so the same canonical value flows
/// into the LDAP filter, the fallback bind DN, and the JWT <c>Username</c> claim —
/// avoiding two distinct identities for the same person (Security-015).
/// </summary>
/// <param name="username">The raw username input to normalise.</param>
public static string NormalizeUsername(string username)
=> username?.Trim() ?? string.Empty;
/// <summary>
/// Builds the final <see cref="LdapAuthResult"/> for a login attempt once the user
/// bind has succeeded. When the group/attribute lookup failed
/// (<paramref name="groupLookupSucceeded"/> is false) the directory is partially
/// unavailable, so the login is FAILED per the design's LDAP-failure rule rather
/// than returning an authenticated session with zero roles (Security-012). When the
/// lookup succeeded, an empty <paramref name="groups"/> list is a genuine
/// "no mapped groups" outcome and the login succeeds.
/// </summary>
/// <param name="username">The normalised username that was authenticated.</param>
/// <param name="displayName">The display name resolved from the directory.</param>
/// <param name="groups">The list of group names resolved from the directory.</param>
/// <param name="groupLookupSucceeded">Whether the group/attribute lookup completed without error.</param>
public static LdapAuthResult BuildAuthResultFromGroupLookup(
string username,
string displayName,
IReadOnlyList<string> groups,
bool groupLookupSucceeded)
{
if (!groupLookupSucceeded)
{
return new LdapAuthResult(false, null, username, null,
"The directory is temporarily unavailable. Please try again.");
}
return new LdapAuthResult(true, displayName, username, groups, null);
}
/// <summary>
/// Extracts the value of the first RDN from a DN, e.g.
/// <c>ou=SCADA-Admins,ou=groups,dc=...</c> → <c>SCADA-Admins</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 are unescaped, so a group CN
/// that legitimately contains a comma is returned intact (Security-013).
/// </summary>
/// <param name="dn">The distinguished name string to parse.</param>
public static string ExtractFirstRdnValue(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');
}
@@ -1,26 +0,0 @@
namespace ZB.MOM.WW.ScadaBridge.Security;
/// <summary>
/// Transport security mode for the LDAP connection. The design requires either
/// LDAPS or StartTLS in production; <see cref="None"/> is for dev/test only and
/// must be paired with <see cref="SecurityOptions.AllowInsecureLdap"/>.
/// </summary>
public enum LdapTransport
{
/// <summary>
/// LDAPS — TLS negotiated at connection time (typically port 636).
/// </summary>
Ldaps,
/// <summary>
/// StartTLS — connect in plaintext (typically port 389), then upgrade the
/// session to TLS before binding.
/// </summary>
StartTls,
/// <summary>
/// No transport security. Dev/test only — requires
/// <see cref="SecurityOptions.AllowInsecureLdap"/> to be true.
/// </summary>
None
}
@@ -1,82 +1,20 @@
namespace ZB.MOM.WW.ScadaBridge.Security;
/// <summary>
/// Non-LDAP security configuration: the cookie-embedded JWT signing/lifetime
/// settings and the session idle-timeout / cookie-security policy.
/// </summary>
/// <remarks>
/// Task 1.2/1.4 cutover: the LDAP connection settings that used to live here as
/// flat <c>Ldap*</c> keys (server, port, transport, search base, service account,
/// attributes, timeout) moved into a nested <c>ScadaBridge:Security:Ldap</c>
/// sub-section bound to the shared <c>ZB.MOM.WW.Auth.Abstractions.Ldap.LdapOptions</c>
/// and registered via <c>AddZbLdapAuth</c>. This is a BREAKING config-key change —
/// see CHANGELOG. The non-LDAP fields below are unchanged and still bound from
/// <c>ScadaBridge:Security</c>.
/// </remarks>
public class SecurityOptions
{
/// <summary>Hostname or IP address of the LDAP server.</summary>
public string LdapServer { get; set; } = string.Empty;
/// <summary>TCP port for the LDAP connection (default 389; 636 for LDAPS).</summary>
public int LdapPort { get; set; } = 389;
/// <summary>
/// Transport security mode for the LDAP connection. Defaults to LDAPS.
/// Use <see cref="LdapTransport.StartTls"/> to connect on the plaintext port
/// and upgrade the session before binding.
/// </summary>
public LdapTransport LdapTransport { get; set; } = LdapTransport.Ldaps;
/// <summary>
/// True when the configured transport provides encryption (LDAPS or StartTLS).
/// Retained for backward compatibility: assigning a value maps onto
/// <see cref="LdapTransport"/> (true =&gt; LDAPS, false =&gt; None).
/// </summary>
public bool LdapUseTls
{
get => LdapTransport != LdapTransport.None;
set => LdapTransport = value ? LdapTransport.Ldaps : LdapTransport.None;
}
/// <summary>
/// Allow insecure (non-TLS) LDAP connections. ONLY for dev/test with GLAuth.
/// Must be false in production.
/// </summary>
public bool AllowInsecureLdap { get; set; } = false;
/// <summary>
/// Base DN for LDAP searches (e.g., "dc=example,dc=com").
/// </summary>
public string LdapSearchBase { get; set; } = string.Empty;
/// <summary>
/// Service account DN for LDAP user searches (e.g., "cn=admin,dc=example,dc=com").
/// Required for search-then-bind authentication. If empty, direct bind with
/// {LdapUserIdAttribute}={username},{LdapSearchBase} is attempted instead.
/// </summary>
public string LdapServiceAccountDn { get; set; } = string.Empty;
/// <summary>
/// LDAP attribute that identifies a user. Used both for the search-then-bind
/// filter (<c>({LdapUserIdAttribute}={username})</c>) and for constructing the
/// fallback bind DN when no service account is configured, so the two
/// authentication modes are interchangeable. Common values: <c>uid</c> (OpenLDAP),
/// <c>sAMAccountName</c> (Active Directory).
/// </summary>
public string LdapUserIdAttribute { get; set; } = "uid";
/// <summary>
/// Service account password for LDAP user searches.
/// </summary>
public string LdapServiceAccountPassword { get; set; } = string.Empty;
/// <summary>
/// LDAP attribute that contains the user's display name.
/// </summary>
public string LdapDisplayNameAttribute { get; set; } = "cn";
/// <summary>
/// LDAP attribute that contains group membership.
/// </summary>
public string LdapGroupAttribute { get; set; } = "memberOf";
/// <summary>
/// Network timeout, in milliseconds, applied to the LDAP socket connect and to
/// LDAP operations (bind/search). The synchronous Novell LDAP calls are wrapped
/// in <c>Task.Run</c>, where the <c>CancellationToken</c> only guards work-item
/// scheduling — it cannot interrupt an in-progress blocking call. This timeout is
/// the real safeguard: it bounds how long a hung LDAP server can pin a thread-pool
/// thread (Security-009). Default 10 seconds.
/// </summary>
public int LdapConnectionTimeoutMs { get; set; } = 10_000;
/// <summary>
/// Symmetric HMAC-SHA256 signing key for cookie-embedded JWTs. Must be at least
/// 32 bytes (256 bits) — validated at <see cref="JwtTokenService"/> construction.
@@ -1,56 +0,0 @@
using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.ScadaBridge.Security;
/// <summary>
/// Security-020: validates <see cref="SecurityOptions"/> at startup so a
/// missing or empty required LDAP field fails fast at boot with a clear,
/// key-naming message — rather than surfacing minutes or hours later as a
/// generic "An unexpected error occurred during authentication" on the first
/// real login attempt.
///
/// <para>
/// The LDAP-side required fields validated here are <see cref="SecurityOptions.LdapServer"/>
/// (no sane default — the host must be specified) and
/// <see cref="SecurityOptions.LdapSearchBase"/> (the DN root every directory
/// search runs against). A typo in the appsettings section name, a missing
/// environment-variable substitution, or a misconfigured Docker compose file
/// leaves both defaulted to <c>string.Empty</c> — without this validator the
/// process would start cleanly and only fail on the first login when
/// <c>LdapConnection.Connect("")</c> throws a low-level exception that does
/// not name the offending config key.
/// </para>
///
/// <para>
/// <see cref="SecurityOptions.JwtSigningKey"/> is intentionally NOT validated
/// here — it already fails fast at <see cref="JwtTokenService"/> construction
/// (Security-003 fix), with a length-aware error message. Centralising it
/// here would duplicate that guard; leaving it on the constructor keeps the
/// minimum-byte length contract co-located with the type that enforces it.
/// </para>
/// </summary>
public sealed class SecurityOptionsValidator : OptionsValidatorBase<SecurityOptions>
{
/// <summary>
/// The configuration section name <see cref="SecurityOptions"/> is bound
/// to (matches the Host's <c>builder.Configuration.GetSection("Security")</c>
/// call). Exposed so validation messages can name the full
/// <c>Security:Field</c> key the operator would edit, not just the field
/// name.
/// </summary>
public const string ConfigSectionName = "Security";
/// <inheritdoc />
protected override void Validate(ValidationBuilder builder, SecurityOptions options)
{
builder.RequireThat(!string.IsNullOrWhiteSpace(options.LdapServer),
$"{ConfigSectionName}:{nameof(SecurityOptions.LdapServer)} is required " +
"but was empty or whitespace — set it to the LDAP server hostname or IP " +
"(e.g. \"ldap.example.com\").");
builder.RequireThat(!string.IsNullOrWhiteSpace(options.LdapSearchBase),
$"{ConfigSectionName}:{nameof(SecurityOptions.LdapSearchBase)} is required " +
"but was empty or whitespace — set it to the search-base DN " +
"(e.g. \"dc=example,dc=com\").");
}
}
@@ -1,15 +0,0 @@
namespace ZB.MOM.WW.ScadaBridge.Security;
/// <summary>
/// Thrown by <see cref="LdapAuthService"/> when the configured LDAP service-account
/// rebind fails. Distinct from a user-bind <c>LdapException</c> so the outer login
/// pipeline can surface "Authentication service is misconfigured" instead of
/// masking the system fault as "Invalid username or password" (Security-019).
/// </summary>
public sealed class ServiceAccountBindException : Exception
{
public ServiceAccountBindException(Exception innerException)
: base("LDAP service-account rebind failed", innerException)
{
}
}
@@ -1,21 +1,44 @@
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.Abstractions.Roles;
using ZB.MOM.WW.Auth.AspNetCore;
namespace ZB.MOM.WW.ScadaBridge.Security;
public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers LDAP authentication, JWT token service, role mapper, cookie authentication, and authorization policies.
/// The configuration section bound to the shared LDAP <c>LdapOptions</c>. Nested
/// under the existing <c>ScadaBridge:Security</c> section as a <c>Ldap</c> sub-section
/// (Task 1.4 config rename) so the non-LDAP <see cref="SecurityOptions"/> fields stay
/// where they are while the LDAP connection settings bind to the shared library.
/// </summary>
public const string LdapSectionPath = "ScadaBridge:Security:Ldap";
/// <summary>
/// Registers LDAP authentication (shared <c>ZB.MOM.WW.Auth.Ldap</c>), JWT token service,
/// role mapper, cookie authentication, and authorization policies.
/// </summary>
/// <param name="services">The service collection to register into.</param>
public static IServiceCollection AddSecurity(this IServiceCollection services)
/// <param name="configuration">
/// Application configuration, read for the nested <see cref="LdapSectionPath"/> LDAP
/// options bound + validated by <c>AddZbLdapAuth</c>.
/// </param>
public static IServiceCollection AddSecurity(this IServiceCollection services, IConfiguration configuration)
{
services.AddScoped<LdapAuthService>();
// Task 1.2 cutover: replace ScadaBridge's bespoke LdapAuthService with the shared
// ZB.MOM.WW.Auth.Ldap implementation (ScadaBridge was the donor for its hardened
// bind-then-search / escaping / fail-closed semantics, so this is a behaviour-
// equivalent re-point). AddZbLdapAuth binds LdapOptions from the nested Ldap
// sub-section, registers IValidateOptions<LdapOptions> with ValidateOnStart (so a
// misconfigured directory fails fast at boot — superseding the old
// SecurityOptionsValidator LDAP checks), and registers ILdapAuthService as a
// stateless singleton.
services.AddZbLdapAuth(configuration, LdapSectionPath);
services.AddScoped<JwtTokenService>();
services.AddScoped<RoleMapper>();
@@ -27,16 +50,11 @@ public static class ServiceCollectionExtensions
// to consume this seam in a later task.
services.AddScoped<IGroupRoleMapper<string>, ScadaBridgeGroupRoleMapper>();
// Security-020: register the IValidateOptions<SecurityOptions> so a
// missing/empty LdapServer or LdapSearchBase fails fast at startup
// with a clear, key-naming message rather than a generic LDAP error
// on the first real login. ValidateOnStart() forces the validation to
// run during host startup rather than lazily on the first
// IOptions<SecurityOptions> resolve. TryAddEnumerable so multiple
// AddSecurity calls (or future additional validators) don't pile up.
services.AddOptions<SecurityOptions>().ValidateOnStart();
services.TryAddEnumerable(
ServiceDescriptor.Singleton<IValidateOptions<SecurityOptions>, SecurityOptionsValidator>());
// Note: the old SecurityOptionsValidator (which fail-fast-validated LdapServer +
// LdapSearchBase) is gone — those keys moved into the shared LdapOptions, whose
// LdapOptionsValidator (registered with ValidateOnStart by AddZbLdapAuth above)
// now enforces Server + SearchBase + ServiceAccountDn + transport at startup. The
// JWT signing key continues to fail-fast at JwtTokenService construction.
// Register ASP.NET Core authentication with cookie scheme
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
@@ -15,6 +15,8 @@
<PackageReference Include="System.IdentityModel.Tokens.Jwt" />
<PackageReference Include="Novell.Directory.Ldap.NETStandard" />
<PackageReference Include="ZB.MOM.WW.Auth.Abstractions" />
<PackageReference Include="ZB.MOM.WW.Auth.Ldap" />
<PackageReference Include="ZB.MOM.WW.Auth.AspNetCore" />
<PackageReference Include="ZB.MOM.WW.Configuration" />
</ItemGroup>