Files
ScadaBridge/src/ScadaLink.Security/LdapAuthService.cs
T

271 lines
11 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));
}
public 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.");
// 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>();
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);
while (searchResults.HasMore())
{
try
{
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)
{
// No more results
break;
}
}
}
catch (LdapException ex)
{
_logger.LogWarning(ex, "Failed to query LDAP attributes for user {Username}; authentication succeeded but group lookup failed", username);
// Auth succeeded even if attribute lookup failed
}
connection.Disconnect();
return new LdapAuthResult(true, displayName, username, groups, null);
}
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>, + " \ &lt; &gt; ;</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");
}
private static string ExtractFirstRdnValue(string dn)
{
// Extract the value of the first RDN from a DN.
// Handles cn=, ou=, or any attribute: "ou=SCADA-Admins,ou=groups,dc=..." → "SCADA-Admins"
var equalsIndex = dn.IndexOf('=');
if (equalsIndex < 0)
return dn;
var valueStart = equalsIndex + 1;
var commaIndex = dn.IndexOf(',', valueStart);
return commaIndex > valueStart ? dn[valueStart..commaIndex] : dn[valueStart..];
}
}