mirror of
https://github.com/freedbygrace/ActiveDirectoryManager.git
synced 2026-08-17 05:58:46 +00:00
Restored to 'd77ce2adbe81c897769f79623edb2fdd7c5cdbc7'
Replit-Restored-To: d77ce2adbe
This commit is contained in:
+196
-307
@@ -1,312 +1,201 @@
|
||||
import { LdapConnection } from "@shared/schema";
|
||||
import debugLib from "debug";
|
||||
import * as ldapjs from "ldapjs";
|
||||
import { promisify } from "util";
|
||||
import { EventEmitter } from 'events';
|
||||
import ldap from 'ldapjs';
|
||||
import { LdapConnection } from '@shared/schema';
|
||||
import { storage } from './storage';
|
||||
|
||||
const debug = debugLib("app:ldap");
|
||||
|
||||
/**
|
||||
* Connect to LDAP server and return a client
|
||||
*/
|
||||
export async function connectToLdap(connection: LdapConnection): Promise<ldapjs.Client> {
|
||||
const url = `${connection.useSSL ? "ldaps" : "ldap"}://${connection.server}:${connection.port}`;
|
||||
class LdapClient extends EventEmitter {
|
||||
private clients: Map<number, ldap.Client> = new Map();
|
||||
private isConnected: Map<number, boolean> = new Map();
|
||||
|
||||
debug(`Connecting to LDAP server at ${url}`);
|
||||
|
||||
const client = ldapjs.createClient({
|
||||
url,
|
||||
timeout: 5000,
|
||||
connectTimeout: 10000,
|
||||
idleTimeout: 30000,
|
||||
reconnect: {
|
||||
initialDelay: 100,
|
||||
maxDelay: 1000,
|
||||
failAfter: 10
|
||||
}
|
||||
});
|
||||
|
||||
// Convert bind to promise
|
||||
const bindAsync = promisify(client.bind).bind(client);
|
||||
|
||||
try {
|
||||
// Bind with credentials
|
||||
await bindAsync(connection.username, connection.password);
|
||||
debug("Successfully authenticated to LDAP server");
|
||||
return client;
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
debug("Failed to connect to LDAP server:", error);
|
||||
throw new Error(`Failed to connect to LDAP server: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
export interface LdapSearchOptions {
|
||||
base?: string;
|
||||
filter: string;
|
||||
scope?: "base" | "one" | "sub";
|
||||
attributes?: string[];
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search LDAP directory with the provided options
|
||||
*/
|
||||
export async function searchLdap(client: ldapjs.Client, options: LdapSearchOptions): Promise<Record<string, any>[]> {
|
||||
const {
|
||||
base = "",
|
||||
filter,
|
||||
scope = "sub",
|
||||
attributes,
|
||||
limit = 1000
|
||||
} = options;
|
||||
|
||||
debug(`Searching LDAP with filter: ${filter}`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const results: Record<string, any>[] = [];
|
||||
|
||||
client.search(base, {
|
||||
filter,
|
||||
scope, // ldapjs accepts 'base', 'one', 'sub' as strings
|
||||
attributes,
|
||||
sizeLimit: limit
|
||||
}, (err: ldapjs.Error | null, res: ldapjs.SearchCallbackResponse) => {
|
||||
if (err) {
|
||||
debug("LDAP search error:", err);
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
// The types for ldapjs don't fully match the actual API
|
||||
// We need to use any here because the type definitions are incomplete
|
||||
res.on("searchEntry", (entry: any) => {
|
||||
results.push(entry.object);
|
||||
});
|
||||
|
||||
res.on("error", (err: ldapjs.Error) => {
|
||||
debug("LDAP search result error:", err);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
res.on("end", (result: any) => {
|
||||
debug(`LDAP search completed with ${results.length} results`);
|
||||
if (result && result.status !== 0) {
|
||||
debug(`LDAP search ended with status: ${result.status}`);
|
||||
}
|
||||
resolve(results);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Test a connection to an LDAP server
|
||||
*/
|
||||
export async function testLdapConnection(connection: LdapConnection): Promise<boolean> {
|
||||
try {
|
||||
const client = await connectToLdap(connection);
|
||||
client.destroy();
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
debug("LDAP connection test failed:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get basic info about an LDAP domain
|
||||
*/
|
||||
export async function getLdapDomainInfo(client: ldapjs.Client): Promise<Record<string, any> | null> {
|
||||
try {
|
||||
const results = await searchLdap(client, {
|
||||
filter: "(objectClass=domain)",
|
||||
scope: "base"
|
||||
});
|
||||
|
||||
return results.length > 0 ? results[0] : null;
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
debug("Failed to get LDAP domain info:", error);
|
||||
throw new Error(`Failed to get LDAP domain info: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available attributes for a specific object type from LDAP schema
|
||||
* This function retrieves attributes that are commonly used for the specified object type
|
||||
*/
|
||||
export interface LdapAttributeMetadata {
|
||||
name: string;
|
||||
type?: string;
|
||||
description?: string;
|
||||
syntax?: string;
|
||||
isMultiValued?: boolean;
|
||||
isMandatory?: boolean;
|
||||
}
|
||||
|
||||
export async function getLdapAvailableAttributes(
|
||||
client: ldapjs.Client,
|
||||
targetObject: "users" | "groups" | "computers" | "ous"
|
||||
): Promise<LdapAttributeMetadata[]> {
|
||||
debug(`Getting available attributes for ${targetObject}`);
|
||||
|
||||
// Define common attributes for each object type
|
||||
const commonAttributes = [
|
||||
"cn", "name", "displayName", "description", "objectClass", "objectCategory",
|
||||
"distinguishedName", "whenCreated", "whenChanged", "objectGUID", "objectSid"
|
||||
];
|
||||
|
||||
// Object type specific attributes
|
||||
const specificAttributes: Record<string, string[]> = {
|
||||
users: [
|
||||
"sAMAccountName", "userPrincipalName", "givenName", "sn", "mail",
|
||||
"telephoneNumber", "mobile", "title", "department", "company",
|
||||
"manager", "employeeID", "employeeNumber", "memberOf", "userAccountControl",
|
||||
"pwdLastSet", "lastLogon", "lastLogonTimestamp", "accountExpires", "badPwdCount",
|
||||
"logonCount", "homeDirectory", "homeDrive", "scriptPath", "profilePath",
|
||||
"lockoutTime", "country", "st", "l", "streetAddress", "postalCode", "otherTelephone"
|
||||
],
|
||||
groups: [
|
||||
"sAMAccountName", "groupType", "member", "memberOf", "managedBy", "msDS-PrincipalName",
|
||||
"mail", "info", "groupCategory", "adminCount", "proxyAddresses"
|
||||
],
|
||||
computers: [
|
||||
"sAMAccountName", "operatingSystem", "operatingSystemVersion", "operatingSystemServicePack",
|
||||
"dNSHostName", "servicePrincipalName", "lastLogonTimestamp", "pwdLastSet",
|
||||
"userAccountControl", "managedBy", "location", "serialNumber", "msDS-SupportedEncryptionTypes",
|
||||
"networkAddress", "primaryGroupID"
|
||||
],
|
||||
ous: [
|
||||
"ou", "name", "description", "distinguishedName", "managedBy", "gPLink", "gPOptions",
|
||||
"msDS-Approx-Immed-Subordinates", "streetAddress", "l", "st", "postalCode", "c"
|
||||
]
|
||||
};
|
||||
|
||||
try {
|
||||
// Try to dynamically retrieve schema info for more attributes
|
||||
// This gets attributes from the schema for the specific object class
|
||||
let dynamicAttributes: string[] = [];
|
||||
let objectClass: string;
|
||||
|
||||
switch (targetObject) {
|
||||
case "users":
|
||||
objectClass = "user";
|
||||
break;
|
||||
case "groups":
|
||||
objectClass = "group";
|
||||
break;
|
||||
case "computers":
|
||||
objectClass = "computer";
|
||||
break;
|
||||
case "ous":
|
||||
objectClass = "organizationalUnit";
|
||||
break;
|
||||
}
|
||||
|
||||
async connect(connection: LdapConnection): Promise<boolean> {
|
||||
try {
|
||||
// Attempt to query the schema - we need to try different base DNs since the schema location varies
|
||||
let schemaResults: Record<string, any>[] = [];
|
||||
|
||||
// Try common locations for the schema (most AD servers will use one of these)
|
||||
const schemaLocations = [
|
||||
"CN=Schema,CN=Configuration,DC=domain,DC=com", // Generic example
|
||||
"CN=Schema,CN=Configuration,DC=ad,DC=example,DC=com",
|
||||
"CN=Schema,CN=Configuration,DC=example,DC=com",
|
||||
"CN=Schema,CN=Configuration",
|
||||
"CN=Schema",
|
||||
];
|
||||
|
||||
let schemaFound = false;
|
||||
for (const schemaLocation of schemaLocations) {
|
||||
try {
|
||||
schemaResults = await searchLdap(client, {
|
||||
base: schemaLocation,
|
||||
filter: `(&(objectClass=attributeSchema)(|(attributeSyntax=2.5.5.8)(attributeSyntax=2.5.5.9)(attributeSyntax=2.5.5.12)))`,
|
||||
scope: "sub",
|
||||
attributes: ["lDAPDisplayName", "attributeSyntax", "isSingleValued"],
|
||||
limit: 500
|
||||
});
|
||||
if (schemaResults.length > 0) {
|
||||
schemaFound = true;
|
||||
debug(`Found schema at ${schemaLocation} with ${schemaResults.length} attributes`);
|
||||
break;
|
||||
}
|
||||
} catch (locationError) {
|
||||
debug(`Schema not found at ${schemaLocation}: ${locationError instanceof Error ? locationError.message : String(locationError)}`);
|
||||
// Continue trying other locations
|
||||
}
|
||||
}
|
||||
|
||||
if (!schemaFound) {
|
||||
debug("Could not find schema in any of the standard locations");
|
||||
}
|
||||
|
||||
dynamicAttributes = schemaResults.map(attr => attr.lDAPDisplayName);
|
||||
debug(`Retrieved ${dynamicAttributes.length} attributes from schema`);
|
||||
} catch (schemaError) {
|
||||
debug("Failed to retrieve attributes from schema, using predefined list:", schemaError);
|
||||
// Continue with the predefined attributes
|
||||
}
|
||||
|
||||
// Combine common and specific attributes, then add dynamic attributes
|
||||
let allAttributeNames = [...commonAttributes, ...specificAttributes[targetObject]];
|
||||
|
||||
// Add any dynamic attributes that aren't already in our list
|
||||
dynamicAttributes.forEach(attr => {
|
||||
if (!allAttributeNames.includes(attr)) {
|
||||
allAttributeNames.push(attr);
|
||||
}
|
||||
});
|
||||
|
||||
// Convert string attributes to metadata objects and sort alphabetically by name
|
||||
const attributeMetadata: LdapAttributeMetadata[] = allAttributeNames.map((name: string) => {
|
||||
// Attribute type inference based on common patterns
|
||||
let type = "string";
|
||||
if (name.toLowerCase().includes("count") || name.toLowerCase().includes("id") ||
|
||||
name.endsWith("Type") || name.includes("ID")) {
|
||||
type = "number";
|
||||
} else if (name.toLowerCase().includes("time") || name.toLowerCase().includes("date") ||
|
||||
name.toLowerCase().includes("created") || name.toLowerCase().includes("changed") ||
|
||||
name.toLowerCase().includes("expires")) {
|
||||
type = "datetime";
|
||||
} else if (name.toLowerCase().includes("is") || name.toLowerCase().includes("has") ||
|
||||
name.toLowerCase().includes("enabled") || name.toLowerCase().includes("disabled")) {
|
||||
type = "boolean";
|
||||
}
|
||||
|
||||
// Description inference based on name
|
||||
let description = "";
|
||||
if (name === "cn") description = "Common Name";
|
||||
else if (name === "sn") description = "Surname";
|
||||
else if (name === "givenName") description = "First Name";
|
||||
else if (name === "sAMAccountName") description = "Login Name";
|
||||
else if (name === "userPrincipalName") description = "User Principal Name";
|
||||
else if (name === "mail") description = "Email Address";
|
||||
else if (name === "memberOf") description = "Group Memberships";
|
||||
else if (name === "member") description = "Members";
|
||||
else if (name === "pwdLastSet") description = "Password Last Set";
|
||||
else if (name === "lastLogon") description = "Last Login Time";
|
||||
|
||||
return {
|
||||
name,
|
||||
type,
|
||||
description: description || undefined,
|
||||
// For multi-valued attributes we could infer based on name, but would be more accurate
|
||||
// to use the schema information which we don't fully process here yet
|
||||
isMultiValued: name === "memberOf" || name === "member" || name === "proxyAddresses" ||
|
||||
name === "objectClass" || name === "servicePrincipalName"
|
||||
const clientOptions: ldap.ClientOptions = {
|
||||
url: `${connection.useTLS ? 'ldaps' : 'ldap'}://${connection.server}:${connection.port}`,
|
||||
reconnect: {
|
||||
initialDelay: 1000,
|
||||
maxDelay: 10000,
|
||||
failAfter: 10
|
||||
},
|
||||
timeout: 5000,
|
||||
connectTimeout: 10000
|
||||
};
|
||||
});
|
||||
|
||||
// Sort alphabetically by name
|
||||
return attributeMetadata.sort((a, b) => a.name.localeCompare(b.name));
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
debug("Failed to get LDAP available attributes:", error);
|
||||
|
||||
// Return a default set of attributes instead of throwing
|
||||
const defaultAttributes = [...commonAttributes, ...specificAttributes[targetObject]].sort();
|
||||
|
||||
// Convert to metadata objects with minimal information
|
||||
return defaultAttributes.map(name => ({ name }));
|
||||
|
||||
const client = ldap.createClient(clientOptions);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
client.on('error', async (err) => {
|
||||
console.error(`LDAP connection error for ${connection.name}:`, err);
|
||||
this.isConnected.set(connection.id, false);
|
||||
await storage.updateLdapConnection(connection.id, { status: 'disconnected' });
|
||||
this.emit('status', {
|
||||
connectionId: connection.id,
|
||||
status: 'disconnected',
|
||||
error: err.message
|
||||
});
|
||||
});
|
||||
|
||||
client.bind(connection.username, connection.password, async (err) => {
|
||||
if (err) {
|
||||
console.error(`LDAP bind error for ${connection.name}:`, err);
|
||||
this.isConnected.set(connection.id, false);
|
||||
await storage.updateLdapConnection(connection.id, { status: 'disconnected' });
|
||||
this.emit('status', {
|
||||
connectionId: connection.id,
|
||||
status: 'disconnected',
|
||||
error: err.message
|
||||
});
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
this.clients.set(connection.id, client);
|
||||
this.isConnected.set(connection.id, true);
|
||||
await storage.updateLdapConnection(connection.id, {
|
||||
status: 'connected',
|
||||
lastConnected: new Date()
|
||||
});
|
||||
|
||||
this.emit('status', {
|
||||
connectionId: connection.id,
|
||||
status: 'connected'
|
||||
});
|
||||
|
||||
resolve(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> {
|
||||
const client = this.clients.get(connectionId);
|
||||
if (client) {
|
||||
return new Promise((resolve) => {
|
||||
client.unbind(() => {
|
||||
this.clients.delete(connectionId);
|
||||
this.isConnected.set(connectionId, false);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getClient(connectionId: number): ldap.Client | undefined {
|
||||
return this.clients.get(connectionId);
|
||||
}
|
||||
|
||||
isConnectionActive(connectionId: number): boolean {
|
||||
return this.isConnected.get(connectionId) || false;
|
||||
}
|
||||
|
||||
// LDAP CRUD operations
|
||||
async searchUsers(connectionId: number, filter = '(objectClass=user)', attributes?: string[]): Promise<any[]> {
|
||||
const client = this.getClient(connectionId);
|
||||
if (!client) throw new Error('LDAP connection not established');
|
||||
|
||||
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'];
|
||||
const searchAttributes = attributes?.length ? attributes : defaultAttributes;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const results: any[] = [];
|
||||
|
||||
client.search(baseDN, {
|
||||
filter,
|
||||
scope: 'sub',
|
||||
attributes: searchAttributes
|
||||
}, (err, res) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
res.on('searchEntry', (entry) => {
|
||||
results.push(entry.object);
|
||||
});
|
||||
|
||||
res.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
res.on('end', (result) => {
|
||||
resolve(results);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async searchGroups(connectionId: number, filter = '(objectClass=group)', attributes?: string[]): Promise<any[]> {
|
||||
const defaultAttributes = ['cn', 'distinguishedName', 'member'];
|
||||
return this.searchUsers(connectionId, filter, attributes || defaultAttributes);
|
||||
}
|
||||
|
||||
async searchOUs(connectionId: number, filter = '(objectClass=organizationalUnit)', attributes?: string[]): Promise<any[]> {
|
||||
const defaultAttributes = ['ou', 'distinguishedName'];
|
||||
return this.searchUsers(connectionId, filter, attributes || defaultAttributes);
|
||||
}
|
||||
|
||||
async searchComputers(connectionId: number, filter = '(objectClass=computer)', attributes?: string[]): Promise<any[]> {
|
||||
const defaultAttributes = ['cn', 'distinguishedName', 'operatingSystem'];
|
||||
return this.searchUsers(connectionId, filter, attributes || defaultAttributes);
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const ldapClient = new LdapClient();
|
||||
|
||||
Reference in New Issue
Block a user