Initial AD bulk tool prototype
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user