mirror of
https://github.com/freedbygrace/ActiveDirectoryManager.git
synced 2026-07-26 11:59:14 +00:00
1330 lines
42 KiB
TypeScript
1330 lines
42 KiB
TypeScript
import { EventEmitter } from 'events';
|
|
import ldap from 'ldapjs';
|
|
import { LdapConnection } from '@shared/schema';
|
|
import { storage } from './storage';
|
|
|
|
// DN escaping utilities
|
|
const DN_SPECIAL_CHARS = /[,=+<>#;\\"\n]/;
|
|
const RDN_SPECIAL_CHARS = /[,=+<>#;\\"\n/]/;
|
|
|
|
// Escape a string for use in a DN
|
|
function escapeDN(value: string): string {
|
|
if (!value) return '';
|
|
|
|
// If the value doesn't contain special characters, return it as is
|
|
if (!DN_SPECIAL_CHARS.test(value)) return value;
|
|
|
|
// Otherwise, escape each special character
|
|
return value.replace(/([\\\,\=\+\<\>\#\;\"\n])/g, '\\$1');
|
|
}
|
|
|
|
// Escape a string for use in an RDN
|
|
function escapeRDN(value: string): string {
|
|
if (!value) return '';
|
|
|
|
// If the value doesn't contain special characters, return it as is
|
|
if (!RDN_SPECIAL_CHARS.test(value)) return value;
|
|
|
|
// Otherwise, escape each special character
|
|
return value.replace(/([\\\,\=\+\<\>\#\;\"\n\/])/g, '\\$1');
|
|
}
|
|
|
|
// Parse a DN into its component parts
|
|
function parseDN(dn: string): { attribute: string, value: string }[] {
|
|
if (!dn) return [];
|
|
|
|
const parts = [];
|
|
let inEscape = false;
|
|
let currentPart = '';
|
|
|
|
for (let i = 0; i < dn.length; i++) {
|
|
const char = dn[i];
|
|
|
|
if (inEscape) {
|
|
currentPart += char;
|
|
inEscape = false;
|
|
} else if (char === '\\') {
|
|
inEscape = true;
|
|
} else if (char === ',' && !inEscape) {
|
|
if (currentPart) {
|
|
const [attribute, value] = currentPart.split('=', 2);
|
|
parts.push({ attribute: attribute.trim(), value: value.trim() });
|
|
currentPart = '';
|
|
}
|
|
} else {
|
|
currentPart += char;
|
|
}
|
|
}
|
|
|
|
if (currentPart) {
|
|
const [attribute, value] = currentPart.split('=', 2);
|
|
parts.push({ attribute: attribute.trim(), value: value.trim() });
|
|
}
|
|
|
|
return parts;
|
|
}
|
|
|
|
// Build a DN from component parts
|
|
function buildDN(parts: { attribute: string, value: string }[]): string {
|
|
return parts.map(part => `${part.attribute}=${escapeDN(part.value)}`).join(',');
|
|
}
|
|
|
|
// Connection pool configuration
|
|
interface ConnectionPoolConfig {
|
|
maxConnections: number;
|
|
idleTimeout: number; // in milliseconds
|
|
acquireTimeout: number; // in milliseconds
|
|
}
|
|
|
|
// Default connection pool configuration
|
|
const DEFAULT_POOL_CONFIG: ConnectionPoolConfig = {
|
|
maxConnections: 10,
|
|
idleTimeout: 60000, // 1 minute
|
|
acquireTimeout: 10000 // 10 seconds
|
|
};
|
|
|
|
// Connection pool entry
|
|
interface PoolEntry {
|
|
client: ldap.Client;
|
|
lastUsed: number;
|
|
inUse: boolean;
|
|
}
|
|
|
|
class LdapClient extends EventEmitter {
|
|
private readonly clients: Map<number, ldap.Client> = new Map();
|
|
private readonly isConnected: Map<number, boolean> = new Map();
|
|
private readonly connectionPools: Map<number, PoolEntry[]> = new Map();
|
|
private readonly poolConfig: ConnectionPoolConfig;
|
|
|
|
constructor(poolConfig?: Partial<ConnectionPoolConfig>) {
|
|
super();
|
|
this.poolConfig = { ...DEFAULT_POOL_CONFIG, ...poolConfig };
|
|
|
|
// Start the idle connection cleanup timer
|
|
setInterval(() => this.cleanupIdleConnections(), this.poolConfig.idleTimeout);
|
|
}
|
|
|
|
// Clean up idle connections
|
|
private cleanupIdleConnections(): void {
|
|
const now = Date.now();
|
|
|
|
for (const [connectionId, pool] of this.connectionPools.entries()) {
|
|
// Filter out idle connections that exceed the idle timeout
|
|
const activeConnections = pool.filter(entry => {
|
|
const isIdle = !entry.inUse && (now - entry.lastUsed > this.poolConfig.idleTimeout);
|
|
|
|
if (isIdle) {
|
|
try {
|
|
// Unbind the idle connection
|
|
entry.client.unbind();
|
|
console.log(`Closed idle LDAP connection for connection ID ${connectionId}`);
|
|
} catch (error) {
|
|
console.error(`Error closing idle LDAP connection: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
// Update the pool with only active connections
|
|
this.connectionPools.set(connectionId, activeConnections);
|
|
|
|
// If all connections are gone, update the connection status
|
|
if (activeConnections.length === 0 && this.isConnected.get(connectionId)) {
|
|
this.isConnected.set(connectionId, false);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Get a connection from the pool or create a new one
|
|
private async getPooledConnection(connection: LdapConnection): Promise<ldap.Client> {
|
|
const connectionId = connection.id;
|
|
let pool = this.connectionPools.get(connectionId) || [];
|
|
|
|
// Look for an available connection in the pool
|
|
const availableEntry = pool.find(entry => !entry.inUse);
|
|
|
|
if (availableEntry) {
|
|
// Mark the connection as in use
|
|
availableEntry.inUse = true;
|
|
availableEntry.lastUsed = Date.now();
|
|
return availableEntry.client;
|
|
}
|
|
|
|
// Check if we've reached the maximum number of connections
|
|
if (pool.length >= this.poolConfig.maxConnections) {
|
|
// Wait for a connection to become available
|
|
return new Promise((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
reject(new Error(`Timed out waiting for an available LDAP connection after ${this.poolConfig.acquireTimeout}ms`));
|
|
}, this.poolConfig.acquireTimeout);
|
|
|
|
const checkForAvailableConnection = () => {
|
|
const pool = this.connectionPools.get(connectionId) || [];
|
|
const availableEntry = pool.find(entry => !entry.inUse);
|
|
|
|
if (availableEntry) {
|
|
clearTimeout(timeout);
|
|
availableEntry.inUse = true;
|
|
availableEntry.lastUsed = Date.now();
|
|
resolve(availableEntry.client);
|
|
} else {
|
|
// Check again in 100ms
|
|
setTimeout(checkForAvailableConnection, 100);
|
|
}
|
|
};
|
|
|
|
checkForAvailableConnection();
|
|
});
|
|
}
|
|
|
|
// Create a new connection
|
|
const clientOptions: ldap.ClientOptions = {
|
|
url: `${connection.useTLS ? 'ldaps' : 'ldap'}://${connection.server}:${connection.port}`,
|
|
reconnect: {
|
|
initialDelay: 1000,
|
|
maxDelay: 10000,
|
|
failAfter: 10
|
|
},
|
|
timeout: 5000,
|
|
connectTimeout: 10000
|
|
};
|
|
|
|
const client = ldap.createClient(clientOptions);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
reject(new Error(`Connection timeout after ${this.poolConfig.acquireTimeout}ms`));
|
|
}, this.poolConfig.acquireTimeout);
|
|
|
|
client.on('error', async (err) => {
|
|
console.error(`LDAP connection error for ${connection.name}:`, err);
|
|
|
|
// Remove this client from the pool if it exists
|
|
const pool = this.connectionPools.get(connectionId) || [];
|
|
const updatedPool = pool.filter(entry => entry.client !== client);
|
|
this.connectionPools.set(connectionId, updatedPool);
|
|
|
|
if (updatedPool.length === 0) {
|
|
this.isConnected.set(connectionId, false);
|
|
await storage.updateLdapConnection(connectionId, { status: 'disconnected' });
|
|
this.emit('status', {
|
|
connectionId,
|
|
status: 'disconnected',
|
|
error: err.message
|
|
});
|
|
}
|
|
});
|
|
|
|
client.bind(connection.username, connection.password, (err) => {
|
|
clearTimeout(timeout);
|
|
|
|
if (err) {
|
|
console.error(`LDAP bind error for ${connection.name}:`, err);
|
|
this.isConnected.set(connectionId, false);
|
|
|
|
// Update connection status in database
|
|
storage.updateLdapConnection(connectionId, { status: 'disconnected' })
|
|
.catch(dbErr => console.error(`Error updating LDAP connection status: ${dbErr}`));
|
|
|
|
this.emit('status', {
|
|
connectionId,
|
|
status: 'disconnected',
|
|
error: err.message
|
|
});
|
|
|
|
reject(err);
|
|
return;
|
|
}
|
|
|
|
// Add the new connection to the pool
|
|
const newEntry: PoolEntry = {
|
|
client,
|
|
lastUsed: Date.now(),
|
|
inUse: true
|
|
};
|
|
|
|
pool.push(newEntry);
|
|
this.connectionPools.set(connectionId, pool);
|
|
this.isConnected.set(connectionId, true);
|
|
|
|
// Update connection status in database
|
|
storage.updateLdapConnection(connectionId, {
|
|
status: 'connected',
|
|
lastConnected: new Date()
|
|
}).catch(dbErr => console.error(`Error updating LDAP connection status: ${dbErr}`));
|
|
|
|
this.emit('status', {
|
|
connectionId,
|
|
status: 'connected'
|
|
});
|
|
|
|
resolve(client);
|
|
});
|
|
});
|
|
}
|
|
|
|
// Release a connection back to the pool
|
|
private releaseConnection(connectionId: number, client: ldap.Client): void {
|
|
const pool = this.connectionPools.get(connectionId);
|
|
if (!pool) return;
|
|
|
|
const entry = pool.find(e => e.client === client);
|
|
if (entry) {
|
|
entry.inUse = false;
|
|
entry.lastUsed = Date.now();
|
|
}
|
|
}
|
|
|
|
async connect(connection: LdapConnection): Promise<boolean> {
|
|
try {
|
|
await this.getPooledConnection(connection);
|
|
return true;
|
|
} catch (error) {
|
|
console.error(`LDAP connection error for ${connection.name}:`, error);
|
|
this.isConnected.set(connection.id, false);
|
|
await storage.updateLdapConnection(connection.id, { status: 'disconnected' });
|
|
this.emit('status', {
|
|
connectionId: connection.id,
|
|
status: 'disconnected',
|
|
error: error instanceof Error ? error.message : String(error)
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async disconnect(connectionId: number): Promise<void> {
|
|
// Close all connections in the pool
|
|
const pool = this.connectionPools.get(connectionId) || [];
|
|
|
|
for (const entry of pool) {
|
|
try {
|
|
entry.client.unbind();
|
|
} catch (error) {
|
|
console.error(`Error unbinding LDAP connection: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
|
|
// Clear the pool
|
|
this.connectionPools.delete(connectionId);
|
|
this.isConnected.set(connectionId, false);
|
|
|
|
// Also handle legacy client if it exists
|
|
const client = this.clients.get(connectionId);
|
|
if (client) {
|
|
try {
|
|
client.unbind();
|
|
this.clients.delete(connectionId);
|
|
} catch (error) {
|
|
console.error(`Error unbinding legacy LDAP connection: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
async getClient(connectionId: number): Promise<ldap.Client | undefined> {
|
|
// First check if we have a connection in the pool
|
|
const pool = this.connectionPools.get(connectionId) || [];
|
|
const availableEntry = pool.find(entry => !entry.inUse);
|
|
|
|
if (availableEntry) {
|
|
availableEntry.inUse = true;
|
|
availableEntry.lastUsed = Date.now();
|
|
return availableEntry.client;
|
|
}
|
|
|
|
// Fall back to legacy client
|
|
return this.clients.get(connectionId);
|
|
}
|
|
|
|
isConnectionActive(connectionId: number): boolean {
|
|
// Check if we have any active connections in the pool
|
|
const pool = this.connectionPools.get(connectionId) || [];
|
|
if (pool.length > 0) return true;
|
|
|
|
// Fall back to legacy connection status
|
|
return this.isConnected.get(connectionId) || false;
|
|
}
|
|
|
|
// LDAP CRUD operations with paging support
|
|
async searchWithPaging(
|
|
connectionId: number,
|
|
baseDN: string,
|
|
filter: string,
|
|
attributes: string[],
|
|
pageSize = 1000
|
|
): Promise<any[]> {
|
|
const client = await this.getClient(connectionId);
|
|
if (!client) throw new Error('LDAP connection not established');
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const allResults: any[] = [];
|
|
let finished = false;
|
|
|
|
// Create a paged search control
|
|
const controls = [
|
|
new ldap.PagedResultsControl({ value: { size: pageSize } })
|
|
];
|
|
|
|
const processPage = (err: any, res: any) => {
|
|
if (err) {
|
|
reject(new Error(`LDAP search error: ${err.message}`));
|
|
return;
|
|
}
|
|
|
|
let cookie: Buffer | undefined;
|
|
|
|
res.on('searchEntry', (entry: any) => {
|
|
allResults.push(entry.object);
|
|
});
|
|
|
|
res.on('error', (err: any) => {
|
|
reject(new Error(`LDAP search error: ${err.message}`));
|
|
});
|
|
|
|
res.on('end', (result: any) => {
|
|
if (finished) return;
|
|
|
|
// Check if we need to get more pages
|
|
if (result && result.controls) {
|
|
const control = result.controls.find(
|
|
(c: any) => c instanceof ldap.PagedResultsControl
|
|
);
|
|
|
|
if (control && control.value.cookie && control.value.cookie.length > 0) {
|
|
cookie = control.value.cookie;
|
|
|
|
// Get the next page
|
|
const nextControls = [
|
|
new ldap.PagedResultsControl({ value: { size: pageSize, cookie } })
|
|
];
|
|
|
|
client.search(baseDN, {
|
|
filter,
|
|
scope: 'sub',
|
|
attributes,
|
|
controls: nextControls
|
|
}, processPage);
|
|
} else {
|
|
// No more pages, we're done
|
|
finished = true;
|
|
resolve(allResults);
|
|
}
|
|
} else {
|
|
// No paging control in response, we're done
|
|
finished = true;
|
|
resolve(allResults);
|
|
}
|
|
});
|
|
};
|
|
|
|
// Start the paged search
|
|
client.search(baseDN, {
|
|
filter,
|
|
scope: 'sub',
|
|
attributes,
|
|
controls
|
|
}, processPage);
|
|
});
|
|
}
|
|
|
|
async searchUsers(connectionId: number, filter = '(objectClass=user)', attributes?: string[], pageSize = 1000): Promise<any[]> {
|
|
const connection = await storage.getLdapConnection(connectionId);
|
|
if (!connection) throw new Error('LDAP connection not found');
|
|
|
|
const baseDN = connection.baseDN || '';
|
|
const defaultAttributes = ['cn', 'sAMAccountName', 'mail', 'distinguishedName', 'objectGUID', 'userAccountControl'];
|
|
const searchAttributes = attributes?.length ? attributes : defaultAttributes;
|
|
|
|
try {
|
|
return await this.searchWithPaging(connectionId, baseDN, filter, searchAttributes, pageSize);
|
|
} catch (error) {
|
|
throw new Error(`Failed to search users: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
|
|
async searchGroups(connectionId: number, filter = '(objectClass=group)', attributes?: string[], pageSize = 1000): Promise<any[]> {
|
|
const connection = await storage.getLdapConnection(connectionId);
|
|
if (!connection) throw new Error('LDAP connection not found');
|
|
|
|
const baseDN = connection.baseDN || '';
|
|
const defaultAttributes = ['cn', 'distinguishedName', 'member', 'objectGUID', 'sAMAccountName'];
|
|
const searchAttributes = attributes?.length ? attributes : defaultAttributes;
|
|
|
|
try {
|
|
return await this.searchWithPaging(connectionId, baseDN, filter, searchAttributes, pageSize);
|
|
} catch (error) {
|
|
throw new Error(`Failed to search groups: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
|
|
async searchOUs(connectionId: number, filter = '(objectClass=organizationalUnit)', attributes?: string[], pageSize = 1000): Promise<any[]> {
|
|
const connection = await storage.getLdapConnection(connectionId);
|
|
if (!connection) throw new Error('LDAP connection not found');
|
|
|
|
const baseDN = connection.baseDN || '';
|
|
const defaultAttributes = ['ou', 'distinguishedName', 'objectGUID'];
|
|
const searchAttributes = attributes?.length ? attributes : defaultAttributes;
|
|
|
|
try {
|
|
return await this.searchWithPaging(connectionId, baseDN, filter, searchAttributes, pageSize);
|
|
} catch (error) {
|
|
throw new Error(`Failed to search OUs: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
|
|
async searchComputers(connectionId: number, filter = '(objectClass=computer)', attributes?: string[], pageSize = 1000): Promise<any[]> {
|
|
const connection = await storage.getLdapConnection(connectionId);
|
|
if (!connection) throw new Error('LDAP connection not found');
|
|
|
|
const baseDN = connection.baseDN || '';
|
|
const defaultAttributes = ['cn', 'distinguishedName', 'dNSHostName', 'operatingSystem', 'objectGUID', 'userAccountControl'];
|
|
const searchAttributes = attributes?.length ? attributes : defaultAttributes;
|
|
|
|
try {
|
|
return await this.searchWithPaging(connectionId, baseDN, filter, searchAttributes, pageSize);
|
|
} catch (error) {
|
|
throw new Error(`Failed to search computers: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
|
|
async searchSites(connectionId: number, filter = '(objectClass=site)', attributes?: string[], pageSize = 1000): Promise<any[]> {
|
|
const defaultAttributes = ['cn', 'distinguishedName', 'description', 'location', 'managedBy', 'objectGUID'];
|
|
|
|
const connection = await storage.getLdapConnection(connectionId);
|
|
if (!connection) throw new Error('LDAP connection not found');
|
|
|
|
// Sites are located in the Configuration container
|
|
const baseDN = 'CN=Sites,CN=Configuration,' + this.getDomainDN(connection);
|
|
const searchAttributes = attributes?.length ? attributes : defaultAttributes;
|
|
|
|
try {
|
|
return await this.searchWithPaging(connectionId, baseDN, filter, searchAttributes, pageSize);
|
|
} catch (error) {
|
|
throw new Error(`Failed to search sites: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
|
|
async searchSubnets(connectionId: number, filter = '(objectClass=subnet)', attributes?: string[], pageSize = 1000): Promise<any[]> {
|
|
const defaultAttributes = ['cn', 'distinguishedName', 'description', 'location', 'siteObject', 'managedBy', 'objectGUID'];
|
|
|
|
const connection = await storage.getLdapConnection(connectionId);
|
|
if (!connection) throw new Error('LDAP connection not found');
|
|
|
|
// Subnets are located in the Configuration container
|
|
const baseDN = 'CN=Subnets,CN=Sites,CN=Configuration,' + this.getDomainDN(connection);
|
|
const searchAttributes = attributes?.length ? attributes : defaultAttributes;
|
|
|
|
try {
|
|
return await this.searchWithPaging(connectionId, baseDN, filter, searchAttributes, pageSize);
|
|
} catch (error) {
|
|
throw new Error(`Failed to search subnets: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
|
|
// Helper function to extract domain DN from connection
|
|
getDomainDN(connection: LdapConnection): string {
|
|
const domainParts = connection.domain.split('.');
|
|
return domainParts.map(part => `DC=${part}`).join(',');
|
|
}
|
|
|
|
async createEntry(connectionId: number, dn: string, attributes: any): Promise<boolean> {
|
|
const client = this.getClient(connectionId);
|
|
if (!client) throw new Error('LDAP connection not established');
|
|
|
|
return new Promise((resolve, reject) => {
|
|
client.add(dn, attributes, (err) => {
|
|
if (err) {
|
|
reject(err);
|
|
return;
|
|
}
|
|
resolve(true);
|
|
});
|
|
});
|
|
}
|
|
|
|
async updateEntry(connectionId: number, dn: string, changes: any[]): Promise<boolean> {
|
|
const client = this.getClient(connectionId);
|
|
if (!client) throw new Error('LDAP connection not established');
|
|
|
|
return new Promise((resolve, reject) => {
|
|
client.modify(dn, changes, (err) => {
|
|
if (err) {
|
|
reject(err);
|
|
return;
|
|
}
|
|
resolve(true);
|
|
});
|
|
});
|
|
}
|
|
|
|
// Update a single attribute of an LDAP entry
|
|
async updateAttribute(
|
|
connectionId: number,
|
|
dn: string,
|
|
attributeName: string,
|
|
attributeValue: string | string[] | null,
|
|
operation: 'add' | 'replace' | 'delete' = 'replace'
|
|
): Promise<boolean> {
|
|
const client = this.getClient(connectionId);
|
|
if (!client) throw new Error('LDAP connection not established');
|
|
|
|
// Ensure DN is properly escaped
|
|
const escapedDN = buildDN(parseDN(dn));
|
|
|
|
let change: any;
|
|
|
|
if (attributeValue === null) {
|
|
// If value is null, we're removing the attribute
|
|
change = new ldap.Change({
|
|
operation: 'delete',
|
|
modification: {
|
|
[attributeName]: []
|
|
}
|
|
});
|
|
} else {
|
|
// Otherwise, we're adding or replacing the attribute
|
|
change = new ldap.Change({
|
|
operation,
|
|
modification: {
|
|
[attributeName]: Array.isArray(attributeValue) ? attributeValue : [attributeValue]
|
|
}
|
|
});
|
|
}
|
|
|
|
try {
|
|
return await this.updateEntry(connectionId, escapedDN, [change]);
|
|
} catch (error) {
|
|
throw new Error(`Failed to update attribute ${attributeName}: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
|
|
// Update multiple attributes of an LDAP entry in a single operation
|
|
async updateAttributes(
|
|
connectionId: number,
|
|
dn: string,
|
|
attributes: Record<string, string | string[] | null>,
|
|
operation: 'add' | 'replace' | 'delete' = 'replace'
|
|
): Promise<boolean> {
|
|
const client = this.getClient(connectionId);
|
|
if (!client) throw new Error('LDAP connection not established');
|
|
|
|
// Ensure DN is properly escaped
|
|
const escapedDN = buildDN(parseDN(dn));
|
|
|
|
const changes: any[] = [];
|
|
|
|
// Create a change for each attribute
|
|
for (const [attributeName, attributeValue] of Object.entries(attributes)) {
|
|
if (attributeValue === null) {
|
|
// If value is null, we're removing the attribute
|
|
changes.push(new ldap.Change({
|
|
operation: 'delete',
|
|
modification: {
|
|
[attributeName]: []
|
|
}
|
|
}));
|
|
} else {
|
|
// Otherwise, we're adding or replacing the attribute
|
|
changes.push(new ldap.Change({
|
|
operation,
|
|
modification: {
|
|
[attributeName]: Array.isArray(attributeValue) ? attributeValue : [attributeValue]
|
|
}
|
|
}));
|
|
}
|
|
}
|
|
|
|
try {
|
|
return await this.updateEntry(connectionId, escapedDN, changes);
|
|
} catch (error) {
|
|
throw new Error(`Failed to update attributes: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
|
|
async deleteEntry(connectionId: number, dn: string): Promise<boolean> {
|
|
const client = this.getClient(connectionId);
|
|
if (!client) throw new Error('LDAP connection not established');
|
|
|
|
return new Promise((resolve, reject) => {
|
|
client.del(dn, (err) => {
|
|
if (err) {
|
|
reject(err);
|
|
return;
|
|
}
|
|
resolve(true);
|
|
});
|
|
});
|
|
}
|
|
|
|
// Get the managed by attribute for an object
|
|
async getManagedBy(connectionId: number, dn: string): Promise<string | null> {
|
|
const client = this.getClient(connectionId);
|
|
if (!client) throw new Error('LDAP connection not established');
|
|
|
|
return new Promise((resolve, reject) => {
|
|
client.search(dn, {
|
|
scope: 'base',
|
|
attributes: ['managedBy']
|
|
}, (err, res) => {
|
|
if (err) {
|
|
reject(err);
|
|
return;
|
|
}
|
|
|
|
let managedBy: string | null = null;
|
|
|
|
res.on('searchEntry', (entry) => {
|
|
const attrs = entry.attributes;
|
|
for (const attr of attrs) {
|
|
if (attr.type === 'managedBy' && attr.vals && attr.vals.length > 0) {
|
|
managedBy = attr.vals[0].toString();
|
|
}
|
|
}
|
|
});
|
|
|
|
res.on('error', (err) => {
|
|
reject(err);
|
|
});
|
|
|
|
res.on('end', (result) => {
|
|
if (result.status !== 0) {
|
|
reject(new Error(`LDAP search error: ${result.errorMessage}`));
|
|
return;
|
|
}
|
|
resolve(managedBy);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
// Set the managed by attribute for an object
|
|
async setManagedBy(connectionId: number, dn: string, managerDn: string | null): Promise<boolean> {
|
|
const client = this.getClient(connectionId);
|
|
if (!client) throw new Error('LDAP connection not established');
|
|
|
|
const changes = [];
|
|
|
|
if (managerDn === null) {
|
|
// Remove the managedBy attribute
|
|
changes.push(new ldap.Change({
|
|
operation: 'delete',
|
|
modification: {
|
|
managedBy: []
|
|
}
|
|
}));
|
|
} else {
|
|
// Add or replace the managedBy attribute
|
|
changes.push(new ldap.Change({
|
|
operation: 'replace',
|
|
modification: {
|
|
managedBy: managerDn
|
|
}
|
|
}));
|
|
}
|
|
|
|
return this.updateEntry(connectionId, dn, changes);
|
|
}
|
|
|
|
// Validate password against common AD password policies
|
|
private validatePassword(password: string): { valid: boolean; message?: string } {
|
|
if (!password) {
|
|
return { valid: false, message: 'Password cannot be empty' };
|
|
}
|
|
|
|
if (password.length < 8) {
|
|
return { valid: false, message: 'Password must be at least 8 characters long' };
|
|
}
|
|
|
|
// Check for complexity requirements (at least 3 of 4 character types)
|
|
let complexity = 0;
|
|
if (/[a-z]/.test(password)) complexity++; // lowercase
|
|
if (/[A-Z]/.test(password)) complexity++; // uppercase
|
|
if (/[0-9]/.test(password)) complexity++; // digits
|
|
if (/[^a-zA-Z0-9]/.test(password)) complexity++; // special chars
|
|
|
|
if (complexity < 3) {
|
|
return {
|
|
valid: false,
|
|
message: 'Password must contain at least 3 of the following: lowercase letters, uppercase letters, digits, and special characters'
|
|
};
|
|
}
|
|
|
|
// Check for common patterns
|
|
if (/(.)\1{2,}/.test(password)) {
|
|
return { valid: false, message: 'Password cannot contain repeated character sequences' };
|
|
}
|
|
|
|
return { valid: true };
|
|
}
|
|
|
|
// Reset a user's password
|
|
async resetUserPassword(connectionId: number, userDN: string, newPassword: string, skipValidation = false): Promise<boolean> {
|
|
const client = this.getClient(connectionId);
|
|
if (!client) throw new Error('LDAP connection not established');
|
|
|
|
// Validate the password against common AD policies
|
|
if (!skipValidation) {
|
|
const validation = this.validatePassword(newPassword);
|
|
if (!validation.valid) {
|
|
throw new Error(`Password validation failed: ${validation.message}`);
|
|
}
|
|
}
|
|
|
|
// Ensure the DN is properly escaped
|
|
const escapedUserDN = buildDN(parseDN(userDN));
|
|
|
|
// Unicode password format for Active Directory
|
|
const unicodePassword = Buffer.from(`"${newPassword}"`, 'utf16le');
|
|
|
|
const changes = [
|
|
new ldap.Change({
|
|
operation: 'replace',
|
|
modification: {
|
|
unicodePwd: unicodePassword
|
|
}
|
|
})
|
|
];
|
|
|
|
try {
|
|
return await this.updateEntry(connectionId, escapedUserDN, changes);
|
|
} catch (error) {
|
|
throw new Error(`Failed to reset password: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
|
|
// Enable or disable a user account
|
|
async setUserAccountStatus(connectionId: number, userDN: string, enabled: boolean): Promise<boolean> {
|
|
const client = this.getClient(connectionId);
|
|
if (!client) throw new Error('LDAP connection not established');
|
|
|
|
// Ensure the DN is properly escaped
|
|
const escapedUserDN = buildDN(parseDN(userDN));
|
|
|
|
// First, get the current userAccountControl value
|
|
const users = await this.searchUsers(connectionId, `(distinguishedName=${escapedUserDN})`, ['userAccountControl']);
|
|
if (!users || users.length === 0) {
|
|
throw new Error('User not found');
|
|
}
|
|
|
|
let userAccountControl = parseInt(users[0].userAccountControl ?? '0');
|
|
|
|
// The ACCOUNTDISABLE flag is 0x2 (2)
|
|
if (enabled) {
|
|
// Remove the ACCOUNTDISABLE flag
|
|
userAccountControl &= ~2;
|
|
} else {
|
|
// Add the ACCOUNTDISABLE flag
|
|
userAccountControl |= 2;
|
|
}
|
|
|
|
const changes = [
|
|
new ldap.Change({
|
|
operation: 'replace',
|
|
modification: {
|
|
userAccountControl: userAccountControl.toString()
|
|
}
|
|
})
|
|
];
|
|
|
|
try {
|
|
return await this.updateEntry(connectionId, escapedUserDN, changes);
|
|
} catch (error) {
|
|
throw new Error(`Failed to ${enabled ? 'enable' : 'disable'} user account: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
|
|
// Move an object to a different OU
|
|
async moveObject(connectionId: number, objectDN: string, targetOU: string): Promise<boolean> {
|
|
const client = this.getClient(connectionId);
|
|
if (!client) throw new Error('LDAP connection not established');
|
|
|
|
// Extract the RDN (Relative Distinguished Name) from the DN
|
|
const dnParts = parseDN(objectDN);
|
|
if (!dnParts.length) throw new Error('Invalid distinguished name format');
|
|
|
|
// The first part is the RDN
|
|
const rdn = `${dnParts[0].attribute}=${escapeDN(dnParts[0].value)}`;
|
|
|
|
// Ensure the target OU is properly escaped
|
|
const escapedTargetOU = targetOU.split(',').map(part => {
|
|
const [attr, val] = part.split('=', 2);
|
|
return `${attr}=${escapeDN(val.trim())}`;
|
|
}).join(',');
|
|
|
|
return new Promise((resolve, reject) => {
|
|
client.modifyDN(objectDN, {
|
|
newRdn: rdn,
|
|
deleteOldRdn: true,
|
|
newSuperior: escapedTargetOU
|
|
}, (err) => {
|
|
if (err) {
|
|
reject(new Error(`Failed to move object: ${err.message}`));
|
|
return;
|
|
}
|
|
resolve(true);
|
|
});
|
|
});
|
|
}
|
|
|
|
// Check if a user is a member of a group (direct or nested)
|
|
async isUserMemberOfGroup(connectionId: number, userDN: string, groupDN: string, maxDepth = 10): Promise<boolean> {
|
|
const client = this.getClient(connectionId);
|
|
if (!client) throw new Error('LDAP connection not established');
|
|
|
|
// Ensure DNs are properly escaped
|
|
const escapedUserDN = buildDN(parseDN(userDN));
|
|
const escapedGroupDN = buildDN(parseDN(groupDN));
|
|
|
|
// First check direct membership
|
|
try {
|
|
const groups = await this.searchGroups(
|
|
connectionId,
|
|
`(&(distinguishedName=${escapedGroupDN})(member=${escapedUserDN}))`,
|
|
['distinguishedName']
|
|
);
|
|
|
|
if (groups && groups.length > 0) {
|
|
// User is directly in the group
|
|
return true;
|
|
}
|
|
} catch (error) {
|
|
console.warn(`Error checking direct group membership: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
|
|
// If not a direct member, check nested groups
|
|
return this.checkNestedGroupMembership(connectionId, userDN, groupDN, 0, maxDepth);
|
|
}
|
|
|
|
// Recursively check nested group membership
|
|
private async checkNestedGroupMembership(
|
|
connectionId: number,
|
|
memberDN: string,
|
|
groupDN: string,
|
|
currentDepth: number,
|
|
maxDepth: number,
|
|
visitedGroups: Set<string> = new Set()
|
|
): Promise<boolean> {
|
|
// Prevent infinite recursion
|
|
if (currentDepth >= maxDepth) return false;
|
|
|
|
// Prevent cycles
|
|
if (visitedGroups.has(groupDN)) return false;
|
|
visitedGroups.add(groupDN);
|
|
|
|
// Ensure DNs are properly escaped
|
|
const escapedMemberDN = buildDN(parseDN(memberDN));
|
|
const escapedGroupDN = buildDN(parseDN(groupDN));
|
|
|
|
try {
|
|
// Get all groups that the member is directly a member of
|
|
const memberGroups = await this.searchGroups(
|
|
connectionId,
|
|
`(member=${escapedMemberDN})`,
|
|
['distinguishedName']
|
|
);
|
|
|
|
// For each of these groups, check if they are members of the target group
|
|
for (const memberGroup of memberGroups) {
|
|
// Check if this group is a direct member of the target group
|
|
const isDirectMember = await this.searchGroups(
|
|
connectionId,
|
|
`(&(distinguishedName=${escapedGroupDN})(member=${memberGroup.distinguishedName}))`,
|
|
['distinguishedName']
|
|
);
|
|
|
|
if (isDirectMember && isDirectMember.length > 0) {
|
|
return true;
|
|
}
|
|
|
|
// Recursively check if this group is a nested member of the target group
|
|
const isNestedMember = await this.checkNestedGroupMembership(
|
|
connectionId,
|
|
memberGroup.distinguishedName,
|
|
groupDN,
|
|
currentDepth + 1,
|
|
maxDepth,
|
|
visitedGroups
|
|
);
|
|
|
|
if (isNestedMember) {
|
|
return true;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.warn(`Error checking nested group membership: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// Get all groups a user is a member of (direct and nested)
|
|
async getUserGroups(connectionId: number, userDN: string, maxDepth = 10): Promise<any[]> {
|
|
const client = this.getClient(connectionId);
|
|
if (!client) throw new Error('LDAP connection not established');
|
|
|
|
// Ensure DN is properly escaped
|
|
const escapedUserDN = buildDN(parseDN(userDN));
|
|
|
|
// Get direct group memberships
|
|
const directGroups = await this.searchGroups(
|
|
connectionId,
|
|
`(member=${escapedUserDN})`,
|
|
['distinguishedName', 'cn', 'objectGUID', 'sAMAccountName']
|
|
);
|
|
|
|
// Set to track all groups to avoid duplicates
|
|
const allGroupDNs = new Set<string>();
|
|
const allGroups: any[] = [];
|
|
|
|
// Add direct groups to the result
|
|
for (const group of directGroups) {
|
|
if (!allGroupDNs.has(group.distinguishedName)) {
|
|
allGroupDNs.add(group.distinguishedName);
|
|
allGroups.push({
|
|
...group,
|
|
membershipType: 'direct'
|
|
});
|
|
}
|
|
}
|
|
|
|
// Get nested groups
|
|
const nestedGroups = await this.getNestedGroups(
|
|
connectionId,
|
|
directGroups.map(g => g.distinguishedName),
|
|
1,
|
|
maxDepth,
|
|
allGroupDNs
|
|
);
|
|
|
|
// Add nested groups to the result
|
|
for (const group of nestedGroups) {
|
|
allGroups.push({
|
|
...group,
|
|
membershipType: 'nested'
|
|
});
|
|
}
|
|
|
|
return allGroups;
|
|
}
|
|
|
|
// Recursively get nested groups
|
|
private async getNestedGroups(
|
|
connectionId: number,
|
|
groupDNs: string[],
|
|
currentDepth: number,
|
|
maxDepth: number,
|
|
visitedGroups: Set<string>
|
|
): Promise<any[]> {
|
|
if (currentDepth >= maxDepth || groupDNs.length === 0) return [];
|
|
|
|
const nestedGroups: any[] = [];
|
|
|
|
for (const groupDN of groupDNs) {
|
|
// Ensure DN is properly escaped
|
|
const escapedGroupDN = buildDN(parseDN(groupDN));
|
|
|
|
// Get groups that this group is a member of
|
|
const parentGroups = await this.searchGroups(
|
|
connectionId,
|
|
`(member=${escapedGroupDN})`,
|
|
['distinguishedName', 'cn', 'objectGUID', 'sAMAccountName']
|
|
);
|
|
|
|
// Filter out groups we've already seen
|
|
const newGroups = parentGroups.filter(g => !visitedGroups.has(g.distinguishedName));
|
|
|
|
// Add new groups to the result and visited set
|
|
for (const group of newGroups) {
|
|
visitedGroups.add(group.distinguishedName);
|
|
nestedGroups.push(group);
|
|
}
|
|
|
|
// Recursively get the next level of nested groups
|
|
if (newGroups.length > 0) {
|
|
const nextLevelGroups = await this.getNestedGroups(
|
|
connectionId,
|
|
newGroups.map(g => g.distinguishedName),
|
|
currentDepth + 1,
|
|
maxDepth,
|
|
visitedGroups
|
|
);
|
|
|
|
nestedGroups.push(...nextLevelGroups);
|
|
}
|
|
}
|
|
|
|
return nestedGroups;
|
|
}
|
|
|
|
// Add a member to a group
|
|
async addToGroup(connectionId: number, groupDN: string, memberDN: string): Promise<boolean> {
|
|
const client = this.getClient(connectionId);
|
|
if (!client) throw new Error('LDAP connection not established');
|
|
|
|
// Ensure DNs are properly escaped
|
|
const escapedGroupDN = buildDN(parseDN(groupDN));
|
|
const escapedMemberDN = buildDN(parseDN(memberDN));
|
|
|
|
// First check if the member is already in the group to avoid errors
|
|
try {
|
|
const groups = await this.searchGroups(
|
|
connectionId,
|
|
`(&(distinguishedName=${escapedGroupDN})(member=${escapedMemberDN}))`,
|
|
['distinguishedName']
|
|
);
|
|
|
|
if (groups && groups.length > 0) {
|
|
// Member is already in the group
|
|
return true;
|
|
}
|
|
|
|
// Also check for nested membership to warn about it
|
|
const isNestedMember = await this.isUserMemberOfGroup(connectionId, memberDN, groupDN);
|
|
if (isNestedMember) {
|
|
console.warn(`Member ${memberDN} is already a nested member of group ${groupDN}`);
|
|
// We still add the direct membership even if there's a nested membership
|
|
}
|
|
} catch (error) {
|
|
// Continue with the add operation even if the check fails
|
|
console.warn(`Error checking group membership: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
|
|
const changes = [
|
|
new ldap.Change({
|
|
operation: 'add',
|
|
modification: {
|
|
member: escapedMemberDN
|
|
}
|
|
})
|
|
];
|
|
|
|
try {
|
|
return await this.updateEntry(connectionId, escapedGroupDN, changes);
|
|
} catch (error) {
|
|
throw new Error(`Failed to add member to group: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
|
|
// Remove a member from a group
|
|
async removeFromGroup(connectionId: number, groupDN: string, memberDN: string): Promise<boolean> {
|
|
const client = this.getClient(connectionId);
|
|
if (!client) throw new Error('LDAP connection not established');
|
|
|
|
// Ensure DNs are properly escaped
|
|
const escapedGroupDN = buildDN(parseDN(groupDN));
|
|
const escapedMemberDN = buildDN(parseDN(memberDN));
|
|
|
|
// First check if the member is in the group to avoid errors
|
|
try {
|
|
const groups = await this.searchGroups(
|
|
connectionId,
|
|
`(&(distinguishedName=${escapedGroupDN})(member=${escapedMemberDN}))`,
|
|
['distinguishedName']
|
|
);
|
|
|
|
if (!groups || groups.length === 0) {
|
|
// Member is not directly in the group
|
|
|
|
// Check if it's a nested member
|
|
const isNestedMember = await this.isUserMemberOfGroup(connectionId, memberDN, groupDN);
|
|
if (isNestedMember) {
|
|
throw new Error(`Cannot remove nested group membership. Member ${memberDN} is a nested member of group ${groupDN}, not a direct member.`);
|
|
}
|
|
|
|
// Not a member at all
|
|
return true;
|
|
}
|
|
} catch (error) {
|
|
// Only continue if it's not our specific nested membership error
|
|
if (error instanceof Error && error.message.includes('Cannot remove nested group membership')) {
|
|
throw error;
|
|
}
|
|
|
|
// Continue with the remove operation for other errors
|
|
console.warn(`Error checking group membership: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
|
|
const changes = [
|
|
new ldap.Change({
|
|
operation: 'delete',
|
|
modification: {
|
|
member: escapedMemberDN
|
|
}
|
|
})
|
|
];
|
|
|
|
try {
|
|
return await this.updateEntry(connectionId, escapedGroupDN, changes);
|
|
} catch (error) {
|
|
throw new Error(`Failed to remove member from group: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
|
|
// Bulk Operations
|
|
|
|
// Move multiple objects to a target OU
|
|
async bulkMoveObjects(connectionId: number, objectDNs: string[], targetOU: string): Promise<{ success: string[]; failed: { dn: string; error: string }[] }> {
|
|
const results = {
|
|
success: [] as string[],
|
|
failed: [] as { dn: string; error: string }[]
|
|
};
|
|
|
|
for (const dn of objectDNs) {
|
|
try {
|
|
const success = await this.moveObject(connectionId, dn, targetOU);
|
|
if (success) {
|
|
results.success.push(dn);
|
|
} else {
|
|
results.failed.push({ dn, error: 'Unknown error' });
|
|
}
|
|
} catch (error) {
|
|
results.failed.push({
|
|
dn,
|
|
error: error instanceof Error ? error.message : String(error)
|
|
});
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
// Add multiple members to a group
|
|
async bulkAddToGroup(connectionId: number, groupDN: string, memberDNs: string[]): Promise<{ success: string[]; failed: { dn: string; error: string }[] }> {
|
|
const results = {
|
|
success: [] as string[],
|
|
failed: [] as { dn: string; error: string }[]
|
|
};
|
|
|
|
for (const dn of memberDNs) {
|
|
try {
|
|
const success = await this.addToGroup(connectionId, groupDN, dn);
|
|
if (success) {
|
|
results.success.push(dn);
|
|
} else {
|
|
results.failed.push({ dn, error: 'Unknown error' });
|
|
}
|
|
} catch (error) {
|
|
results.failed.push({
|
|
dn,
|
|
error: error instanceof Error ? error.message : String(error)
|
|
});
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
// Remove multiple members from a group
|
|
async bulkRemoveFromGroup(connectionId: number, groupDN: string, memberDNs: string[]): Promise<{ success: string[]; failed: { dn: string; error: string }[] }> {
|
|
const results = {
|
|
success: [] as string[],
|
|
failed: [] as { dn: string; error: string }[]
|
|
};
|
|
|
|
for (const dn of memberDNs) {
|
|
try {
|
|
const success = await this.removeFromGroup(connectionId, groupDN, dn);
|
|
if (success) {
|
|
results.success.push(dn);
|
|
} else {
|
|
results.failed.push({ dn, error: 'Unknown error' });
|
|
}
|
|
} catch (error) {
|
|
results.failed.push({
|
|
dn,
|
|
error: error instanceof Error ? error.message : String(error)
|
|
});
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
// Enable or disable multiple user accounts
|
|
async bulkSetUserAccountStatus(connectionId: number, userDNs: string[], enabled: boolean): Promise<{ success: string[]; failed: { dn: string; error: string }[] }> {
|
|
const results = {
|
|
success: [] as string[],
|
|
failed: [] as { dn: string; error: string }[]
|
|
};
|
|
|
|
for (const dn of userDNs) {
|
|
try {
|
|
const success = await this.setUserAccountStatus(connectionId, dn, enabled);
|
|
if (success) {
|
|
results.success.push(dn);
|
|
} else {
|
|
results.failed.push({ dn, error: 'Unknown error' });
|
|
}
|
|
} catch (error) {
|
|
results.failed.push({
|
|
dn,
|
|
error: error instanceof Error ? error.message : String(error)
|
|
});
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
// Update the same attribute for multiple objects
|
|
async bulkUpdateAttribute(
|
|
connectionId: number,
|
|
dns: string[],
|
|
attributeName: string,
|
|
attributeValue: string | string[] | null,
|
|
operation: 'add' | 'replace' | 'delete' = 'replace'
|
|
): Promise<{ success: string[]; failed: { dn: string; error: string }[] }> {
|
|
const results = {
|
|
success: [] as string[],
|
|
failed: [] as { dn: string; error: string }[]
|
|
};
|
|
|
|
for (const dn of dns) {
|
|
try {
|
|
const success = await this.updateAttribute(connectionId, dn, attributeName, attributeValue, operation);
|
|
if (success) {
|
|
results.success.push(dn);
|
|
} else {
|
|
results.failed.push({ dn, error: 'Unknown error' });
|
|
}
|
|
} catch (error) {
|
|
results.failed.push({
|
|
dn,
|
|
error: error instanceof Error ? error.message : String(error)
|
|
});
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
// Helper method to handle LDAP errors with more detailed information
|
|
private handleLdapError(err: any): Error {
|
|
// Map common LDAP error codes to more user-friendly messages
|
|
const errorMap: Record<string, string> = {
|
|
'32': 'No such object exists in the directory',
|
|
'49': 'Invalid credentials',
|
|
'50': 'Insufficient access rights',
|
|
'53': 'Unable to perform operation, directory server unwilling to process request',
|
|
'65': 'Object class violation',
|
|
'68': 'Entry already exists',
|
|
'69': 'Object class modifications prohibited',
|
|
'71': 'Affects multiple DSAs (directory server agents)',
|
|
'80': 'Other error'
|
|
};
|
|
|
|
const code = err.code?.toString();
|
|
const message = errorMap[code] || err.message || 'Unknown LDAP error';
|
|
|
|
// Create a more detailed error
|
|
const error = new Error(`LDAP Error ${code}: ${message}`);
|
|
(error as any).ldapCode = code;
|
|
(error as any).originalError = err;
|
|
|
|
return error;
|
|
}
|
|
}
|
|
|
|
export const ldapClient = new LdapClient();
|