333 lines
11 KiB
C#
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();
|
|
}
|
|
}
|