363 lines
15 KiB
C#
363 lines
15 KiB
C#
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using Novell.Directory.Ldap;
|
|
|
|
namespace ScadaLink.Security;
|
|
|
|
public class LdapAuthService
|
|
{
|
|
private readonly SecurityOptions _options;
|
|
private readonly ILogger<LdapAuthService> _logger;
|
|
|
|
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.
|
|
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)
|
|
if (!string.IsNullOrWhiteSpace(_options.LdapServiceAccountDn))
|
|
{
|
|
await Task.Run(() =>
|
|
connection.Bind(_options.LdapServiceAccountDn, _options.LdapServiceAccountPassword), 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 (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>
|
|
/// 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
|
|
if (!string.IsNullOrWhiteSpace(_options.LdapServiceAccountDn))
|
|
{
|
|
await Task.Run(() =>
|
|
connection.Bind(_options.LdapServiceAccountDn, _options.LdapServiceAccountPassword), 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>
|
|
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>
|
|
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>
|
|
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>
|
|
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>
|
|
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');
|
|
}
|