refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)
Solution + 23 src projects + 26 test projects renamed; folders, csproj, namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated. ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated. SQL roles/logins, LDAP domains, CLI command name, and CLI config dir (~/.scadalink → ~/.scadabridge) also renamed. Build green; 5 Host.Tests fail awaiting SQL login rename in next commit. Pre-existing StaleTagMonitor timing flakes unchanged. Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Centralised authorization policy names + the role→permission mapping
|
||||
/// that defines them.
|
||||
///
|
||||
/// <para>
|
||||
/// The codebase uses a thin role-claim model: each policy expresses a
|
||||
/// permission, satisfied when the principal carries any role claim
|
||||
/// (<see cref="JwtTokenService.RoleClaimType"/>) that maps to that
|
||||
/// permission. Role names are free strings configured via
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Entities.Security.LdapGroupMapping"/> rows
|
||||
/// (see <see cref="RoleMapper"/>) — there is no permission claim, just a
|
||||
/// fan-out from role to allowed policies.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Default role → permission mapping (#23 M7-T15 / Bundle G):
|
||||
/// <list type="table">
|
||||
/// <listheader>
|
||||
/// <term>Role</term>
|
||||
/// <description>Policies granted</description>
|
||||
/// </listheader>
|
||||
/// <item>
|
||||
/// <term><c>Admin</c></term>
|
||||
/// <description><see cref="RequireAdmin"/>,
|
||||
/// <see cref="OperationalAudit"/>, <see cref="AuditExport"/> — admins hold
|
||||
/// every permission by convention so an Admin-only user never loses
|
||||
/// access to a new surface.</description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <term><c>Design</c></term>
|
||||
/// <description><see cref="RequireDesign"/></description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <term><c>Deployment</c></term>
|
||||
/// <description><see cref="RequireDeployment"/></description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <term><c>Audit</c></term>
|
||||
/// <description><see cref="OperationalAudit"/>,
|
||||
/// <see cref="AuditExport"/> — the full audit surface (read + bulk
|
||||
/// export) per <c>Component-AuditLog.md</c> §"Authorization".</description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <term><c>AuditReadOnly</c></term>
|
||||
/// <description><see cref="OperationalAudit"/> only — operators who
|
||||
/// should see the Audit Log + drill in to incidents but not pull bulk
|
||||
/// CSV exports. Use this when delegating triage without granting
|
||||
/// forensic-export capability.</description>
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// LDAP group → role mapping is configured via the central UI Admin → LDAP
|
||||
/// Mappings page (rows in <c>LdapGroupMappings</c>); the same code path
|
||||
/// reads them whether the role is one of the four built-ins above or any
|
||||
/// future addition. Adding a role here means adding the LDAP mapping row in
|
||||
/// the deployment; no schema migration is needed.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class AuthorizationPolicies
|
||||
{
|
||||
public const string RequireAdmin = "RequireAdmin";
|
||||
public const string RequireDesign = "RequireDesign";
|
||||
public const string RequireDeployment = "RequireDeployment";
|
||||
|
||||
/// <summary>
|
||||
/// Read access to the Audit Log #23 surface (Audit Log page,
|
||||
/// Configuration Audit Log page, Audit nav group). Granted to the
|
||||
/// <c>Audit</c> role, the <c>AuditReadOnly</c> role, and the
|
||||
/// <c>Admin</c> role.
|
||||
/// </summary>
|
||||
public const string OperationalAudit = "OperationalAudit";
|
||||
|
||||
/// <summary>
|
||||
/// Permission to pull a bulk CSV export of the Audit Log. Separate from
|
||||
/// <see cref="OperationalAudit"/> so a triage operator can read the
|
||||
/// table without being able to exfiltrate it in bulk. Granted to the
|
||||
/// <c>Audit</c> role and the <c>Admin</c> role.
|
||||
/// </summary>
|
||||
public const string AuditExport = "AuditExport";
|
||||
|
||||
/// <summary>
|
||||
/// Roles that satisfy <see cref="OperationalAudit"/>. Held in one place
|
||||
/// so the seed/docs and the policy stay in lockstep.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Public so the ManagementService HTTP API (#23 M8) — which gates the
|
||||
/// <c>/api/audit/*</c> routes with a manual Basic-Auth + LDAP role check
|
||||
/// rather than the ASP.NET authorization-policy pipeline — can reuse the
|
||||
/// exact same role set the <see cref="OperationalAudit"/> policy enforces.
|
||||
/// </remarks>
|
||||
public static readonly string[] OperationalAuditRoles = { Roles.Admin, Roles.Audit, Roles.AuditReadOnly };
|
||||
|
||||
/// <summary>
|
||||
/// Roles that satisfy <see cref="AuditExport"/>. A strict subset of
|
||||
/// <see cref="OperationalAuditRoles"/> — read access does NOT imply
|
||||
/// export permission.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Public for the same reason as <see cref="OperationalAuditRoles"/> —
|
||||
/// the ManagementService <c>/api/audit/export</c> route checks roles
|
||||
/// against this set directly.
|
||||
/// </remarks>
|
||||
public static readonly string[] AuditExportRoles = { Roles.Admin, Roles.Audit };
|
||||
|
||||
/// <summary>
|
||||
/// Registers the ScadaBridge authorization policies (Admin, Design, Deployment, OperationalAudit, AuditExport).
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection to register into.</param>
|
||||
public static IServiceCollection AddScadaBridgeAuthorization(this IServiceCollection services)
|
||||
{
|
||||
services.AddAuthorization(options =>
|
||||
{
|
||||
options.AddPolicy(RequireAdmin, policy =>
|
||||
policy.RequireClaim(JwtTokenService.RoleClaimType, Roles.Admin));
|
||||
|
||||
options.AddPolicy(RequireDesign, policy =>
|
||||
policy.RequireClaim(JwtTokenService.RoleClaimType, Roles.Design));
|
||||
|
||||
options.AddPolicy(RequireDeployment, policy =>
|
||||
policy.RequireClaim(JwtTokenService.RoleClaimType, Roles.Deployment));
|
||||
|
||||
// Multi-role permission policies — the policy succeeds when the
|
||||
// principal holds ANY of the mapped roles. RequireClaim with
|
||||
// multiple allowed values is the right primitive: it checks
|
||||
// whether *any* role claim's value is in the allowed set, so a
|
||||
// user with role=Admin (and nothing else) satisfies the
|
||||
// OperationalAudit policy without needing a separate Audit
|
||||
// role claim.
|
||||
options.AddPolicy(OperationalAudit, policy =>
|
||||
policy.RequireClaim(JwtTokenService.RoleClaimType, OperationalAuditRoles));
|
||||
|
||||
options.AddPolicy(AuditExport, policy =>
|
||||
policy.RequireClaim(JwtTokenService.RoleClaimType, AuditExportRoles));
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Security;
|
||||
|
||||
public class JwtTokenService
|
||||
{
|
||||
private readonly SecurityOptions _options;
|
||||
private readonly ILogger<JwtTokenService> _logger;
|
||||
|
||||
public const string DisplayNameClaimType = "DisplayName";
|
||||
public const string UsernameClaimType = "Username";
|
||||
public const string RoleClaimType = "Role";
|
||||
public const string SiteIdClaimType = "SiteId";
|
||||
public const string LastActivityClaimType = "LastActivity";
|
||||
|
||||
/// <summary>
|
||||
/// Fixed issuer bound into every token and required on validation. Binding
|
||||
/// issuer/audience is defence-in-depth: even though the HMAC key is shared only
|
||||
/// between the two central nodes, accidental reuse of the same secret for an
|
||||
/// unrelated internal token would otherwise be silently exploitable.
|
||||
/// </summary>
|
||||
public const string TokenIssuer = "scadabridge-central";
|
||||
|
||||
/// <summary>Fixed audience bound into every token and required on validation.</summary>
|
||||
public const string TokenAudience = "scadabridge-central";
|
||||
|
||||
/// <summary>Initializes the service and validates the JWT signing key meets the minimum length requirement.</summary>
|
||||
/// <param name="options">Security options containing the JWT signing key, expiry, and timeout settings.</param>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
public JwtTokenService(IOptions<SecurityOptions> options, ILogger<JwtTokenService> logger)
|
||||
{
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
// Fail fast: a missing or short signing key produces trivially forgeable tokens.
|
||||
// HMAC-SHA256 requires a key of at least 256 bits (32 bytes).
|
||||
var keyByteLength = string.IsNullOrEmpty(_options.JwtSigningKey)
|
||||
? 0
|
||||
: Encoding.UTF8.GetByteCount(_options.JwtSigningKey);
|
||||
if (keyByteLength < SecurityOptions.MinJwtSigningKeyBytes)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"SecurityOptions.JwtSigningKey must be at least {SecurityOptions.MinJwtSigningKeyBytes} bytes " +
|
||||
$"(256 bits) for HMAC-SHA256; the configured key is {keyByteLength} byte(s). " +
|
||||
"Configure a strong signing key before starting the service.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Issues a fresh JWT. <paramref name="lastActivity"/> sets the idle-timeout
|
||||
/// anchor; when omitted (a brand-new login) it defaults to now. On a token
|
||||
/// refresh the caller MUST pass the existing anchor forward so the idle window
|
||||
/// continues to be measured from the user's last genuine activity rather than
|
||||
/// from token issuance time.
|
||||
/// </summary>
|
||||
/// <param name="displayName">Human-readable display name embedded as a claim.</param>
|
||||
/// <param name="username">Authenticated username embedded as a claim.</param>
|
||||
/// <param name="roles">Role names to embed as <see cref="RoleClaimType"/> claims.</param>
|
||||
/// <param name="permittedSiteIds">Site identifiers the user may deploy to; null for system-wide access.</param>
|
||||
/// <param name="lastActivity">Idle-timeout anchor; defaults to now when null (fresh login).</param>
|
||||
public string GenerateToken(
|
||||
string displayName,
|
||||
string username,
|
||||
IReadOnlyList<string> roles,
|
||||
IReadOnlyList<string>? permittedSiteIds,
|
||||
DateTimeOffset? lastActivity = null)
|
||||
{
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.JwtSigningKey));
|
||||
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(DisplayNameClaimType, displayName),
|
||||
new(UsernameClaimType, username),
|
||||
new(LastActivityClaimType, (lastActivity ?? DateTimeOffset.UtcNow).ToString("o"))
|
||||
};
|
||||
|
||||
foreach (var role in roles)
|
||||
{
|
||||
claims.Add(new Claim(RoleClaimType, role));
|
||||
}
|
||||
|
||||
if (permittedSiteIds != null)
|
||||
{
|
||||
foreach (var siteId in permittedSiteIds)
|
||||
{
|
||||
claims.Add(new Claim(SiteIdClaimType, siteId));
|
||||
}
|
||||
}
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: TokenIssuer,
|
||||
audience: TokenAudience,
|
||||
claims: claims,
|
||||
expires: DateTime.UtcNow.AddMinutes(_options.JwtExpiryMinutes),
|
||||
signingCredentials: credentials);
|
||||
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
|
||||
/// <summary>Validates a JWT string and returns the decoded <see cref="ClaimsPrincipal"/>, or null if the token is invalid or expired.</summary>
|
||||
/// <param name="token">The JWT string to validate.</param>
|
||||
/// <returns>The decoded principal, or <c>null</c> if validation fails.</returns>
|
||||
public ClaimsPrincipal? ValidateToken(string token)
|
||||
{
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.JwtSigningKey));
|
||||
var validationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = TokenIssuer,
|
||||
ValidateAudience = true,
|
||||
ValidAudience = TokenAudience,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = key,
|
||||
ClockSkew = TimeSpan.Zero
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
var principal = handler.ValidateToken(token, validationParameters, out _);
|
||||
return principal;
|
||||
}
|
||||
catch (Exception ex) when (ex is SecurityTokenException or ArgumentException)
|
||||
{
|
||||
_logger.LogDebug(ex, "Token validation failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Returns true if the token carried by <paramref name="principal"/> is within the sliding-refresh threshold and should be reissued.</summary>
|
||||
/// <param name="principal">The current authenticated principal.</param>
|
||||
/// <returns><c>true</c> when the remaining token lifetime falls below <see cref="SecurityOptions.JwtRefreshThresholdMinutes"/>.</returns>
|
||||
public bool ShouldRefresh(ClaimsPrincipal principal)
|
||||
{
|
||||
var expClaim = principal.FindFirst("exp");
|
||||
if (expClaim == null || !long.TryParse(expClaim.Value, out var expUnix))
|
||||
return false;
|
||||
|
||||
var expiry = DateTimeOffset.FromUnixTimeSeconds(expUnix);
|
||||
var remaining = expiry - DateTimeOffset.UtcNow;
|
||||
|
||||
return remaining.TotalMinutes < _options.JwtRefreshThresholdMinutes;
|
||||
}
|
||||
|
||||
/// <summary>Returns true if the session has exceeded the idle timeout based on the <see cref="LastActivityClaimType"/> claim.</summary>
|
||||
/// <param name="principal">The current authenticated principal.</param>
|
||||
/// <returns><c>true</c> when the elapsed time since last activity exceeds <see cref="SecurityOptions.IdleTimeoutMinutes"/>.</returns>
|
||||
public bool IsIdleTimedOut(ClaimsPrincipal principal)
|
||||
{
|
||||
var lastActivityClaim = principal.FindFirst(LastActivityClaimType);
|
||||
if (lastActivityClaim == null || !DateTimeOffset.TryParse(lastActivityClaim.Value, out var lastActivity))
|
||||
return true;
|
||||
|
||||
return (DateTimeOffset.UtcNow - lastActivity).TotalMinutes > _options.IdleTimeoutMinutes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Issues a fresh token (new expiry, re-queried roles) while <b>preserving</b> the
|
||||
/// existing <see cref="LastActivityClaimType"/> anchor. A refresh is itself
|
||||
/// triggered by a request, but it must not be treated as user activity — the
|
||||
/// idle window must keep being measured from the user's last genuine interaction,
|
||||
/// otherwise the documented 30-minute idle timeout could never fire for a client
|
||||
/// that polls in the background. Call <see cref="RecordActivity"/> to advance the
|
||||
/// anchor when handling a genuine user request.
|
||||
/// <para>
|
||||
/// A principal that is already past the idle timeout cannot be refreshed: this
|
||||
/// method returns <c>null</c> (the same "cannot refresh" signal it uses for missing
|
||||
/// claims). Enforcing the idle check here — rather than relying on the caller to
|
||||
/// invoke <see cref="IsIdleTimedOut"/> first — guarantees the documented 30-minute
|
||||
/// idle policy holds regardless of caller discipline; otherwise an idle-expired
|
||||
/// session could be kept alive indefinitely by background refreshes (Security-014).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="currentPrincipal">The current authenticated principal whose claims carry identity and the last-activity anchor.</param>
|
||||
/// <param name="currentRoles">Re-queried role list to embed in the new token.</param>
|
||||
/// <param name="permittedSiteIds">Site identifiers the user may deploy to; null for system-wide access.</param>
|
||||
/// <returns>A new token string, or <c>null</c> if the session is idle-expired or claims are missing.</returns>
|
||||
public string? RefreshToken(ClaimsPrincipal currentPrincipal, IReadOnlyList<string> currentRoles, IReadOnlyList<string>? permittedSiteIds)
|
||||
{
|
||||
var displayName = currentPrincipal.FindFirst(DisplayNameClaimType)?.Value;
|
||||
var username = currentPrincipal.FindFirst(UsernameClaimType)?.Value;
|
||||
|
||||
if (displayName == null || username == null)
|
||||
{
|
||||
_logger.LogWarning("Cannot refresh token: missing DisplayName or Username claims");
|
||||
return null;
|
||||
}
|
||||
|
||||
// An idle-expired session must not be renewed — the user must re-login.
|
||||
if (IsIdleTimedOut(currentPrincipal))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Cannot refresh token for {Username}: session is past the idle timeout", username);
|
||||
return null;
|
||||
}
|
||||
|
||||
return GenerateToken(displayName, username, currentRoles, permittedSiteIds,
|
||||
ReadLastActivity(currentPrincipal));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Issues a fresh token whose <see cref="LastActivityClaimType"/> anchor is
|
||||
/// advanced to now. This is the explicit "user did something" path — distinct
|
||||
/// from <see cref="RefreshToken"/> — to be called by the request pipeline when
|
||||
/// handling a genuine user interaction.
|
||||
/// </summary>
|
||||
/// <param name="currentPrincipal">The current authenticated principal.</param>
|
||||
/// <param name="currentRoles">Re-queried role list to embed in the new token.</param>
|
||||
/// <param name="permittedSiteIds">Site identifiers the user may deploy to; null for system-wide access.</param>
|
||||
/// <returns>A new token string with an updated last-activity anchor, or <c>null</c> if claims are missing.</returns>
|
||||
public string? RecordActivity(ClaimsPrincipal currentPrincipal, IReadOnlyList<string> currentRoles, IReadOnlyList<string>? permittedSiteIds)
|
||||
{
|
||||
var displayName = currentPrincipal.FindFirst(DisplayNameClaimType)?.Value;
|
||||
var username = currentPrincipal.FindFirst(UsernameClaimType)?.Value;
|
||||
|
||||
if (displayName == null || username == null)
|
||||
{
|
||||
_logger.LogWarning("Cannot record activity: missing DisplayName or Username claims");
|
||||
return null;
|
||||
}
|
||||
|
||||
return GenerateToken(displayName, username, currentRoles, permittedSiteIds,
|
||||
DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ReadLastActivity(ClaimsPrincipal principal)
|
||||
{
|
||||
var claim = principal.FindFirst(LastActivityClaimType);
|
||||
return claim != null && DateTimeOffset.TryParse(claim.Value, out var value)
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Security;
|
||||
|
||||
public record LdapAuthResult(
|
||||
bool Success,
|
||||
string? DisplayName,
|
||||
string? Username,
|
||||
IReadOnlyList<string>? Groups,
|
||||
string? ErrorMessage);
|
||||
@@ -0,0 +1,417 @@
|
||||
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>, + " \ < > ;</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');
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Security;
|
||||
|
||||
public class RoleMapper
|
||||
{
|
||||
private readonly ISecurityRepository _securityRepository;
|
||||
|
||||
/// <summary>Initializes the mapper with the security repository.</summary>
|
||||
/// <param name="securityRepository">Repository used to retrieve LDAP group-to-role mappings and scope rules.</param>
|
||||
public RoleMapper(ISecurityRepository securityRepository)
|
||||
{
|
||||
_securityRepository = securityRepository ?? throw new ArgumentNullException(nameof(securityRepository));
|
||||
}
|
||||
|
||||
// virtual: a test seam so HTTP-pipeline tests (e.g. the #23 M8 audit
|
||||
// endpoints) can substitute the LDAP-group→role resolution.
|
||||
/// <summary>Maps a list of LDAP group names to ScadaBridge roles and computes site-scope permissions.</summary>
|
||||
/// <param name="ldapGroups">LDAP group names from the authenticated user's directory entry.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>A <see cref="RoleMappingResult"/> containing matched roles, permitted site IDs, and the system-wide flag.</returns>
|
||||
public virtual async Task<RoleMappingResult> MapGroupsToRolesAsync(
|
||||
IReadOnlyList<string> ldapGroups,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var allMappings = await _securityRepository.GetAllMappingsAsync(ct);
|
||||
|
||||
var matchedRoles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var permittedSiteIds = new HashSet<string>();
|
||||
var hasDeploymentRole = false;
|
||||
var hasScopedDeploymentMapping = false;
|
||||
var hasUnscopedDeploymentMapping = false;
|
||||
|
||||
foreach (var mapping in allMappings)
|
||||
{
|
||||
// Match LDAP group names (case-insensitive)
|
||||
if (!ldapGroups.Any(g => g.Equals(mapping.LdapGroupName, StringComparison.OrdinalIgnoreCase)))
|
||||
continue;
|
||||
|
||||
matchedRoles.Add(mapping.Role);
|
||||
|
||||
if (mapping.Role.Equals(Roles.Deployment, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
hasDeploymentRole = true;
|
||||
|
||||
var scopeRules = await _securityRepository.GetScopeRulesForMappingAsync(mapping.Id, ct);
|
||||
if (scopeRules.Count > 0)
|
||||
{
|
||||
hasScopedDeploymentMapping = true;
|
||||
foreach (var rule in scopeRules)
|
||||
{
|
||||
permittedSiteIds.Add(rule.SiteId.ToString());
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
hasUnscopedDeploymentMapping = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Union semantics (Security-016): a Deployment user is system-wide iff
|
||||
// *any* matched Deployment mapping has no scope rules. A user in both
|
||||
// SCADA-Deploy-All (unscoped) and SCADA-Deploy-SiteA (scoped to Site A)
|
||||
// gets the broader grant, not the narrower one — matching the design's
|
||||
// "roles are independent — there is no implied hierarchy" rule.
|
||||
var isSystemWide = hasUnscopedDeploymentMapping
|
||||
|| (hasDeploymentRole && !hasScopedDeploymentMapping);
|
||||
|
||||
// When system-wide, drop any accumulated scope ids — the empty
|
||||
// permitted set is the system-wide signal downstream consumers
|
||||
// (SiteScopeService, ManagementActor) already use.
|
||||
if (isSystemWide)
|
||||
{
|
||||
permittedSiteIds.Clear();
|
||||
}
|
||||
|
||||
return new RoleMappingResult(
|
||||
matchedRoles.ToList(),
|
||||
permittedSiteIds.ToList(),
|
||||
isSystemWide);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Security;
|
||||
|
||||
public record RoleMappingResult(
|
||||
IReadOnlyList<string> Roles,
|
||||
IReadOnlyList<string> PermittedSiteIds,
|
||||
bool IsSystemWideDeployment);
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Single source of truth for role-name string literals used across the
|
||||
/// Security module and downstream authorization checks.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Role names appear in three independent contexts: <see cref="RoleMapper"/>
|
||||
/// (LDAP-group → role resolution), <see cref="AuthorizationPolicies"/>
|
||||
/// (policy <c>RequireClaim</c> values + the audit role arrays), and at LDAP
|
||||
/// mapping rows configured by an operator. Holding the literals here means a
|
||||
/// rename either succeeds everywhere or fails to compile, eliminating the
|
||||
/// "string drift" class that Security-018 documented.
|
||||
/// </remarks>
|
||||
public static class Roles
|
||||
{
|
||||
public const string Admin = "Admin";
|
||||
public const string Design = "Design";
|
||||
public const string Deployment = "Deployment";
|
||||
public const string Audit = "Audit";
|
||||
public const string AuditReadOnly = "AuditReadOnly";
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Security;
|
||||
|
||||
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 => LDAPS, false => 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.
|
||||
/// </summary>
|
||||
public string JwtSigningKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum signing-key length in bytes required for HMAC-SHA256 (256 bits).
|
||||
/// </summary>
|
||||
public const int MinJwtSigningKeyBytes = 32;
|
||||
/// <summary>Cookie-embedded JWT lifetime in minutes before it must be refreshed.</summary>
|
||||
public int JwtExpiryMinutes { get; set; } = 15;
|
||||
/// <summary>Session idle timeout in minutes; sessions inactive beyond this are expired.</summary>
|
||||
public int IdleTimeoutMinutes { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Minutes before token expiry to trigger refresh.
|
||||
/// </summary>
|
||||
public int JwtRefreshThresholdMinutes { get; set; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// When true (default) the authentication cookie is always marked
|
||||
/// <c>Secure</c> (sent only over HTTPS) — the correct production setting,
|
||||
/// since the cookie carries the embedded JWT bearer credential. Set false
|
||||
/// for an HTTP-only deployment such as the local Docker dev cluster: the
|
||||
/// cookie then uses <c>SameAsRequest</c>, so it is still <c>Secure</c> on
|
||||
/// any HTTPS request but is usable over plain HTTP.
|
||||
/// </summary>
|
||||
public bool RequireHttpsCookie { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
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 : IValidateOptions<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 />
|
||||
public ValidateOptionsResult Validate(string? name, SecurityOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var failures = new List<string>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.LdapServer))
|
||||
{
|
||||
failures.Add(
|
||||
$"{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\").");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.LdapSearchBase))
|
||||
{
|
||||
failures.Add(
|
||||
$"{ConfigSectionName}:{nameof(SecurityOptions.LdapSearchBase)} is required " +
|
||||
"but was empty or whitespace — set it to the search-base DN " +
|
||||
"(e.g. \"dc=example,dc=com\").");
|
||||
}
|
||||
|
||||
return failures.Count == 0
|
||||
? ValidateOptionsResult.Success
|
||||
: ValidateOptionsResult.Fail(failures);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Security;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers LDAP authentication, 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)
|
||||
{
|
||||
services.AddScoped<LdapAuthService>();
|
||||
services.AddScoped<JwtTokenService>();
|
||||
services.AddScoped<RoleMapper>();
|
||||
|
||||
// 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>());
|
||||
|
||||
// Register ASP.NET Core authentication with cookie scheme
|
||||
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
.AddCookie(options =>
|
||||
{
|
||||
options.LoginPath = "/login";
|
||||
options.LogoutPath = "/auth/logout";
|
||||
options.Cookie.Name = "ZB.MOM.WW.ScadaBridge.Auth";
|
||||
options.Cookie.HttpOnly = true;
|
||||
options.Cookie.SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Strict;
|
||||
// Cookie.SecurePolicy is set in the PostConfigure block below so it
|
||||
// can honour SecurityOptions.RequireHttpsCookie.
|
||||
});
|
||||
|
||||
// CentralUI-005: configure the cookie session as a sliding window so the
|
||||
// code matches the documented policy ("sliding refresh, 30-minute idle
|
||||
// timeout"). ASP.NET cookie auth exposes a single ExpireTimeSpan plus a
|
||||
// SlidingExpiration flag — it cannot natively model a 15-minute sliding
|
||||
// token AND a separate 30-minute absolute idle cap. The faithful
|
||||
// interpretation: the cookie window IS the idle timeout
|
||||
// (SecurityOptions.IdleTimeoutMinutes, default 30) and SlidingExpiration
|
||||
// renews it on activity (the middleware re-issues the cookie once past
|
||||
// the halfway mark of the window). An active user is therefore kept
|
||||
// signed in; an idle user is signed out after the idle timeout. The
|
||||
// 15-minute JwtExpiryMinutes governs the lifetime of the embedded JWT
|
||||
// itself (see JwtTokenService) — a separate layer from the cookie
|
||||
// session window. Bound here via PostConfigure so SecurityOptions
|
||||
// (configured by the Host after AddSecurity) is honoured.
|
||||
services.AddOptions<CookieAuthenticationOptions>(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
.Configure<IOptions<SecurityOptions>, ILoggerFactory>((cookieOptions, securityOptions, loggerFactory) =>
|
||||
{
|
||||
var idleMinutes = securityOptions.Value.IdleTimeoutMinutes;
|
||||
cookieOptions.ExpireTimeSpan = TimeSpan.FromMinutes(idleMinutes);
|
||||
cookieOptions.SlidingExpiration = true;
|
||||
|
||||
// The cookie carries the embedded JWT bearer credential. Production
|
||||
// keeps it HTTPS-only (Always); an HTTP-only deployment (e.g. the
|
||||
// local Docker dev cluster) opts out via RequireHttpsCookie=false and
|
||||
// uses SameAsRequest — still Secure on any HTTPS request.
|
||||
cookieOptions.Cookie.SecurePolicy = securityOptions.Value.RequireHttpsCookie
|
||||
? Microsoft.AspNetCore.Http.CookieSecurePolicy.Always
|
||||
: Microsoft.AspNetCore.Http.CookieSecurePolicy.SameAsRequest;
|
||||
|
||||
// Security-021: when the operator opts out of HTTPS-only cookies,
|
||||
// log a Warning so an HTTP-only deployment is at least audible in
|
||||
// the startup log. The cookie carries the embedded JWT bearer
|
||||
// credential — over plain HTTP that travels in cleartext on every
|
||||
// request. The default is true; this branch fires only on an
|
||||
// explicit opt-out (typically the dev Docker cluster).
|
||||
if (!securityOptions.Value.RequireHttpsCookie)
|
||||
{
|
||||
loggerFactory.CreateLogger("ZB.MOM.WW.ScadaBridge.Security").LogWarning(
|
||||
"SecurityOptions:RequireHttpsCookie is DISABLED — auth cookie SecurePolicy is SameAsRequest. The cookie-embedded JWT will be transmitted in cleartext over plain HTTP. This setting is intended for local dev only — set SecurityOptions:RequireHttpsCookie=true in production.");
|
||||
}
|
||||
});
|
||||
|
||||
services.AddScadaBridgeAuthorization();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers security-related Akka actors (placeholder for future actor registrations).
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection to register into.</param>
|
||||
public static IServiceCollection AddSecurityActors(this IServiceCollection services)
|
||||
{
|
||||
// Phase 0: placeholder for Akka actor registration
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authorization" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" />
|
||||
<PackageReference Include="Novell.Directory.Ldap.NETStandard" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user