Files

333 lines
11 KiB
C#

using ADBulkTool.Models;
using Novell.Directory.Ldap;
using System.Globalization;
using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Text;
namespace ADBulkTool.Services;
public sealed class AdService : IDisposable
{
private const int AccountDisableFlag = 0x0002;
private readonly AdConfig _config;
private readonly LdapConnection _connection;
private bool _connected;
public AdService(AdConfig config)
{
_config = config;
_connection = new LdapConnection
{
SecureSocketLayer = config.UseLdaps,
ConnectionTimeout = 10000
};
if (config.IgnoreCertificateErrors)
{
#pragma warning disable CS0618
_connection.UserDefinedServerCertValidationDelegate += (_, _, _, _) => true;
#pragma warning restore CS0618
}
}
public async Task ConnectAsync(CancellationToken cancellationToken = default)
{
if (_connected)
{
return;
}
var protocol = _config.UseLdaps ? "LDAPS" : "LDAP";
try
{
await _connection.ConnectAsync(_config.Host, _config.Port, cancellationToken);
}
catch (Exception ex)
{
var hint = _config.UseLdaps
? "Check TCP 636, the domain controller LDAPS certificate, and whether this Linux machine trusts the issuing CA."
: "Check TCP 389, DNS, VPN/internal network, and firewall rules.";
throw new InvalidOperationException($"{protocol} connect failed to {_config.Host}:{_config.Port}. {hint}", ex);
}
try
{
await _connection.BindAsync(_config.BindUsername, _config.BindPassword, cancellationToken);
}
catch (Exception ex)
{
throw new InvalidOperationException($"{protocol} bind failed for {_config.BindUsername} on {_config.Host}:{_config.Port}. Check credentials and account permissions.", ex);
}
_connected = true;
}
public async Task<UserLookupResult?> FindUserAsync(string csvValue, CancellationToken cancellationToken = default)
{
await EnsureConnectedAsync(cancellationToken);
var identity = NormalizeCsvIdentity(csvValue);
if (string.IsNullOrWhiteSpace(identity))
{
return null;
}
var escaped = EscapeLdapFilter(identity);
var filter = $"(&(objectClass=user)(objectCategory=person)(|(sAMAccountName={escaped})(userPrincipalName={escaped})))";
var attrs = new[] { "distinguishedName", "sAMAccountName", "userAccountControl" };
var results = await _connection.SearchAsync(
_config.SearchBaseDn,
LdapConnection.ScopeSub,
filter,
attrs,
false,
cancellationToken);
await using var enumerator = results.GetAsyncEnumerator();
if (!await enumerator.MoveNextAsync())
{
return null;
}
var entry = enumerator.Current;
var dn = GetString(entry, "distinguishedName") ?? entry.Dn;
var sam = GetString(entry, "sAMAccountName") ?? identity;
var uacText = GetString(entry, "userAccountControl") ?? "512";
var uac = int.TryParse(uacText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedUac)
? parsedUac
: 512;
return new UserLookupResult(dn, sam, uac);
}
public async Task<string?> FindGroupDnAsync(string groupNameOrDn, CancellationToken cancellationToken = default)
{
await EnsureConnectedAsync(cancellationToken);
var value = groupNameOrDn.Trim();
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
if (LooksLikeDistinguishedName(value))
{
return value;
}
var escaped = EscapeLdapFilter(value);
var filter = $"(&(objectClass=group)(|(cn={escaped})(sAMAccountName={escaped})(name={escaped})))";
var attrs = new[] { "distinguishedName" };
var results = await _connection.SearchAsync(
_config.SearchBaseDn,
LdapConnection.ScopeSub,
filter,
attrs,
false,
cancellationToken);
await using var enumerator = results.GetAsyncEnumerator();
if (!await enumerator.MoveNextAsync())
{
return null;
}
return GetString(enumerator.Current, "distinguishedName") ?? enumerator.Current.Dn;
}
public async Task DisableUserAsync(UserLookupResult user, CancellationToken cancellationToken = default)
{
var newValue = user.UserAccountControl | AccountDisableFlag;
await ReplaceAttributeAsync(user.DistinguishedName, "userAccountControl", newValue.ToString(CultureInfo.InvariantCulture), cancellationToken);
}
public async Task EnableUserAsync(UserLookupResult user, CancellationToken cancellationToken = default)
{
var newValue = user.UserAccountControl & ~AccountDisableFlag;
await ReplaceAttributeAsync(user.DistinguishedName, "userAccountControl", newValue.ToString(CultureInfo.InvariantCulture), cancellationToken);
}
public async Task AddUserToGroupAsync(string userDn, string groupDn, CancellationToken cancellationToken = default)
{
var mod = new LdapModification(LdapModification.Add, new LdapAttribute("member", userDn));
await _connection.ModifyAsync(groupDn, mod, cancellationToken);
}
public async Task RemoveUserFromGroupAsync(string userDn, string groupDn, CancellationToken cancellationToken = default)
{
var mod = new LdapModification(LdapModification.Delete, new LdapAttribute("member", userDn));
await _connection.ModifyAsync(groupDn, mod, cancellationToken);
}
public async Task ForcePasswordChangeAtNextLogonAsync(string userDn, CancellationToken cancellationToken = default)
{
await ReplaceAttributeAsync(userDn, "pwdLastSet", "0", cancellationToken);
}
public async Task SetPasswordAsync(string userDn, string newPassword, CancellationToken cancellationToken = default)
{
if (!_config.UseLdaps)
{
throw new InvalidOperationException("Password reset requires LDAPS. Click 'Use LDAPS 636', test it, then run this action again.");
}
var quotedPassword = $"\"{newPassword}\"";
var passwordBytes = Encoding.Unicode.GetBytes(quotedPassword);
var mod = new LdapModification(LdapModification.Replace, new LdapAttribute("unicodePwd", passwordBytes));
await _connection.ModifyAsync(userDn, mod, cancellationToken);
}
public static async Task<string> ResolveHostAsync(string host, TimeSpan timeout, CancellationToken cancellationToken = default)
{
var addresses = await Dns.GetHostAddressesAsync(host).WaitAsync(timeout, cancellationToken);
if (addresses.Length == 0)
{
throw new InvalidOperationException("No DNS addresses returned.");
}
return string.Join(", ", addresses.Select(a => a.ToString()));
}
public static async Task<(bool IsOpen, string Message)> TestTcpPortAsync(string host, int port, TimeSpan timeout, CancellationToken cancellationToken = default)
{
try
{
using var client = new TcpClient();
await client.ConnectAsync(host, port).WaitAsync(timeout, cancellationToken);
return (true, "open");
}
catch (TimeoutException)
{
return (false, "timeout");
}
catch (SocketException ex)
{
return (false, ex.Message);
}
catch (Exception ex)
{
return (false, ex.Message);
}
}
public static string GenerateRandomPassword(int length = 20)
{
const string upper = "ABCDEFGHJKLMNPQRSTUVWXYZ";
const string lower = "abcdefghijkmnopqrstuvwxyz";
const string digits = "23456789";
const string symbols = "!#$%+-_?@";
var all = upper + lower + digits + symbols;
var chars = new List<char>
{
Pick(upper),
Pick(lower),
Pick(digits),
Pick(symbols)
};
while (chars.Count < length)
{
chars.Add(Pick(all));
}
Shuffle(chars);
return new string(chars.ToArray());
}
public static string NormalizeCsvIdentity(string raw)
{
var value = (raw ?? string.Empty).Trim();
var slashIndex = value.LastIndexOf('\\');
if (slashIndex >= 0 && slashIndex < value.Length - 1)
{
return value[(slashIndex + 1)..].Trim();
}
return value;
}
private async Task ReplaceAttributeAsync(string dn, string attributeName, string value, CancellationToken cancellationToken)
{
await EnsureConnectedAsync(cancellationToken);
var mod = new LdapModification(LdapModification.Replace, new LdapAttribute(attributeName, value));
await _connection.ModifyAsync(dn, mod, cancellationToken);
}
private async Task EnsureConnectedAsync(CancellationToken cancellationToken)
{
if (!_connected)
{
await ConnectAsync(cancellationToken);
}
}
private static bool LooksLikeDistinguishedName(string value)
{
return value.Contains("=", StringComparison.Ordinal) && value.Contains(",", StringComparison.Ordinal);
}
private static string? GetString(LdapEntry entry, string attributeName)
{
try
{
return entry.GetStringValueOrDefault(attributeName);
}
catch
{
return null;
}
}
private static string EscapeLdapFilter(string value)
{
var sb = new StringBuilder(value.Length);
foreach (var c in value)
{
switch (c)
{
case '\\': sb.Append(@"\5c"); break;
case '*': sb.Append(@"\2a"); break;
case '(' : sb.Append(@"\28"); break;
case ')' : sb.Append(@"\29"); break;
case '\0': sb.Append(@"\00"); break;
default: sb.Append(c); break;
}
}
return sb.ToString();
}
private static char Pick(string source)
{
var index = RandomNumberGenerator.GetInt32(source.Length);
return source[index];
}
private static void Shuffle(IList<char> chars)
{
for (var i = chars.Count - 1; i > 0; i--)
{
var j = RandomNumberGenerator.GetInt32(i + 1);
(chars[i], chars[j]) = (chars[j], chars[i]);
}
}
public void Dispose()
{
if (_connection.Connected)
{
_connection.Disconnect();
}
_connection.Dispose();
}
}