| const ldap = require('ldapjs') |
| const logger = require('../utils/logger') |
| const config = require('../../config/config') |
| const userService = require('./userService') |
|
|
| class LdapService { |
| constructor() { |
| this.config = config.ldap || {} |
| this.client = null |
|
|
| |
| if (this.config && this.config.enabled) { |
| this.validateConfiguration() |
| } |
| } |
|
|
| |
| validateConfiguration() { |
| const errors = [] |
|
|
| if (!this.config.server) { |
| errors.push('LDAP server configuration is missing') |
| } else { |
| if (!this.config.server.url || typeof this.config.server.url !== 'string') { |
| errors.push('LDAP server URL is not configured or invalid') |
| } |
|
|
| if (!this.config.server.bindDN || typeof this.config.server.bindDN !== 'string') { |
| errors.push('LDAP bind DN is not configured or invalid') |
| } |
|
|
| if ( |
| !this.config.server.bindCredentials || |
| typeof this.config.server.bindCredentials !== 'string' |
| ) { |
| errors.push('LDAP bind credentials are not configured or invalid') |
| } |
|
|
| if (!this.config.server.searchBase || typeof this.config.server.searchBase !== 'string') { |
| errors.push('LDAP search base is not configured or invalid') |
| } |
|
|
| if (!this.config.server.searchFilter || typeof this.config.server.searchFilter !== 'string') { |
| errors.push('LDAP search filter is not configured or invalid') |
| } |
| } |
|
|
| if (errors.length > 0) { |
| logger.error('❌ LDAP configuration validation failed:', errors) |
| |
| logger.warn('⚠️ LDAP authentication may not work properly due to configuration errors') |
| } else { |
| logger.info('✅ LDAP configuration validation passed') |
| } |
| } |
|
|
| |
| extractDN(ldapEntry) { |
| if (!ldapEntry) { |
| return null |
| } |
|
|
| |
| let dn = null |
|
|
| |
| if (ldapEntry.dn) { |
| ;({ dn } = ldapEntry) |
| } |
| |
| else if (ldapEntry.objectName) { |
| dn = ldapEntry.objectName |
| } |
| |
| else if (ldapEntry.distinguishedName) { |
| dn = ldapEntry.distinguishedName |
| } |
| |
| else if (typeof ldapEntry === 'string' && ldapEntry.includes('=')) { |
| dn = ldapEntry |
| } |
|
|
| |
| if (dn && typeof dn === 'object') { |
| if (dn.toString && typeof dn.toString === 'function') { |
| dn = dn.toString() |
| } else if (dn.dn && typeof dn.dn === 'string') { |
| ;({ dn } = dn) |
| } |
| } |
|
|
| |
| if (typeof dn === 'string' && dn.trim() !== '' && dn.includes('=')) { |
| return dn.trim() |
| } |
|
|
| return null |
| } |
|
|
| |
| extractDomainFromDN(dnString) { |
| try { |
| if (!dnString || typeof dnString !== 'string') { |
| return null |
| } |
|
|
| |
| const dcMatches = dnString.match(/DC=([^,]+)/gi) |
| if (!dcMatches || dcMatches.length === 0) { |
| return null |
| } |
|
|
| |
| const domainParts = dcMatches.map((match) => { |
| const value = match.replace(/DC=/i, '').trim() |
| return value |
| }) |
|
|
| if (domainParts.length > 0) { |
| const domain = domainParts.join('.') |
| logger.debug(`🌐 从DN提取域名: ${domain}`) |
| return domain |
| } |
|
|
| return null |
| } catch (error) { |
| logger.debug('⚠️ 域名提取失败:', error.message) |
| return null |
| } |
| } |
|
|
| |
| createClient() { |
| try { |
| const clientOptions = { |
| url: this.config.server.url, |
| timeout: this.config.server.timeout, |
| connectTimeout: this.config.server.connectTimeout, |
| reconnect: true |
| } |
|
|
| |
| if (this.config.server.url.toLowerCase().startsWith('ldaps://')) { |
| const tlsOptions = {} |
|
|
| |
| if (this.config.server.tls) { |
| if (typeof this.config.server.tls.rejectUnauthorized === 'boolean') { |
| tlsOptions.rejectUnauthorized = this.config.server.tls.rejectUnauthorized |
| } |
|
|
| |
| if (this.config.server.tls.ca) { |
| tlsOptions.ca = this.config.server.tls.ca |
| } |
|
|
| |
| if (this.config.server.tls.cert) { |
| tlsOptions.cert = this.config.server.tls.cert |
| } |
|
|
| if (this.config.server.tls.key) { |
| tlsOptions.key = this.config.server.tls.key |
| } |
|
|
| |
| if (this.config.server.tls.servername) { |
| tlsOptions.servername = this.config.server.tls.servername |
| } |
| } |
|
|
| clientOptions.tlsOptions = tlsOptions |
|
|
| logger.debug('🔒 Creating LDAPS client with TLS options:', { |
| url: this.config.server.url, |
| rejectUnauthorized: tlsOptions.rejectUnauthorized, |
| hasCA: !!tlsOptions.ca, |
| hasCert: !!tlsOptions.cert, |
| hasKey: !!tlsOptions.key, |
| servername: tlsOptions.servername |
| }) |
| } |
|
|
| const client = ldap.createClient(clientOptions) |
|
|
| |
| client.on('error', (err) => { |
| if (err.code === 'CERT_HAS_EXPIRED' || err.code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE') { |
| logger.error('🔒 LDAP TLS certificate error:', { |
| code: err.code, |
| message: err.message, |
| hint: 'Consider setting LDAP_TLS_REJECT_UNAUTHORIZED=false for self-signed certificates' |
| }) |
| } else { |
| logger.error('🔌 LDAP client error:', err) |
| } |
| }) |
|
|
| client.on('connect', () => { |
| if (this.config.server.url.toLowerCase().startsWith('ldaps://')) { |
| logger.info('🔒 LDAPS client connected successfully') |
| } else { |
| logger.info('🔗 LDAP client connected successfully') |
| } |
| }) |
|
|
| client.on('connectTimeout', () => { |
| logger.warn('⏱️ LDAP connection timeout') |
| }) |
|
|
| return client |
| } catch (error) { |
| logger.error('❌ Failed to create LDAP client:', error) |
| throw error |
| } |
| } |
|
|
| |
| async bindClient(client) { |
| return new Promise((resolve, reject) => { |
| |
| const { bindDN } = this.config.server |
| const { bindCredentials } = this.config.server |
|
|
| if (!bindDN || typeof bindDN !== 'string') { |
| const error = new Error('LDAP bind DN is not configured or invalid') |
| logger.error('❌ LDAP configuration error:', error.message) |
| reject(error) |
| return |
| } |
|
|
| if (!bindCredentials || typeof bindCredentials !== 'string') { |
| const error = new Error('LDAP bind credentials are not configured or invalid') |
| logger.error('❌ LDAP configuration error:', error.message) |
| reject(error) |
| return |
| } |
|
|
| client.bind(bindDN, bindCredentials, (err) => { |
| if (err) { |
| logger.error('❌ LDAP bind failed:', err) |
| reject(err) |
| } else { |
| logger.debug('🔑 LDAP bind successful') |
| resolve() |
| } |
| }) |
| }) |
| } |
|
|
| |
| async searchUser(client, username) { |
| return new Promise((resolve, reject) => { |
| |
| |
| const escapedUsername = username |
| .replace(/\\/g, '\\5c') |
| .replace(/\*/g, '\\2a') |
| .replace(/\(/g, '\\28') |
| .replace(/\)/g, '\\29') |
| .replace(/\0/g, '\\00') |
| .replace(/\//g, '\\2f') |
|
|
| const searchFilter = this.config.server.searchFilter.replace('{{username}}', escapedUsername) |
| const searchOptions = { |
| scope: 'sub', |
| filter: searchFilter, |
| attributes: this.config.server.searchAttributes |
| } |
|
|
| logger.debug(`🔍 Searching for user: ${username} with filter: ${searchFilter}`) |
|
|
| const entries = [] |
|
|
| client.search(this.config.server.searchBase, searchOptions, (err, res) => { |
| if (err) { |
| logger.error('❌ LDAP search error:', err) |
| reject(err) |
| return |
| } |
|
|
| res.on('searchEntry', (entry) => { |
| logger.debug('🔍 LDAP search entry received:', { |
| dn: entry.dn, |
| objectName: entry.objectName, |
| type: typeof entry.dn, |
| entryType: typeof entry, |
| hasAttributes: !!entry.attributes, |
| attributeCount: entry.attributes ? entry.attributes.length : 0 |
| }) |
| entries.push(entry) |
| }) |
|
|
| res.on('searchReference', (referral) => { |
| logger.debug('🔗 LDAP search referral:', referral.uris) |
| }) |
|
|
| res.on('error', (error) => { |
| logger.error('❌ LDAP search result error:', error) |
| reject(error) |
| }) |
|
|
| res.on('end', (result) => { |
| logger.debug( |
| `✅ LDAP search completed. Status: ${result.status}, Found ${entries.length} entries` |
| ) |
|
|
| if (entries.length === 0) { |
| resolve(null) |
| } else { |
| |
| if (entries[0]) { |
| logger.debug('🔍 Full LDAP entry structure:', { |
| entryType: typeof entries[0], |
| entryConstructor: entries[0].constructor?.name, |
| entryKeys: Object.keys(entries[0]), |
| entryStringified: JSON.stringify(entries[0], null, 2).substring(0, 500) |
| }) |
| } |
|
|
| if (entries.length === 1) { |
| resolve(entries[0]) |
| } else { |
| logger.warn(`⚠️ Multiple LDAP entries found for username: ${username}`) |
| resolve(entries[0]) |
| } |
| } |
| }) |
| }) |
| }) |
| } |
|
|
| |
| async authenticateUser(userDN, password) { |
| return new Promise((resolve, reject) => { |
| |
| if (!userDN || typeof userDN !== 'string') { |
| const error = new Error('User DN is not provided or invalid') |
| logger.error('❌ LDAP authentication error:', error.message) |
| reject(error) |
| return |
| } |
|
|
| if (!password || typeof password !== 'string') { |
| logger.debug(`🚫 Invalid or empty password for DN: ${userDN}`) |
| resolve(false) |
| return |
| } |
|
|
| const authClient = this.createClient() |
|
|
| authClient.bind(userDN, password, (err) => { |
| authClient.unbind() |
|
|
| if (err) { |
| if (err.name === 'InvalidCredentialsError') { |
| logger.debug(`🚫 Invalid credentials for DN: ${userDN}`) |
| resolve(false) |
| } else { |
| logger.error('❌ LDAP authentication error:', err) |
| reject(err) |
| } |
| } else { |
| logger.debug(`✅ Authentication successful for DN: ${userDN}`) |
| resolve(true) |
| } |
| }) |
| }) |
| } |
|
|
| |
| async tryWindowsADAuthentication(username, password) { |
| if (!username || !password) { |
| return false |
| } |
|
|
| |
| const domain = this.extractDomainFromDN(this.config.server.searchBase) |
|
|
| const adFormats = [] |
|
|
| if (domain) { |
| |
| adFormats.push(`${username}@${domain}`) |
|
|
| |
| const domainParts = domain.split('.') |
| if (domainParts.length > 1) { |
| adFormats.push(`${username}@${domainParts.slice(-2).join('.')}`) |
| } |
|
|
| |
| const firstDomainPart = domainParts[0] |
| if (firstDomainPart) { |
| adFormats.push(`${firstDomainPart}\\${username}`) |
| adFormats.push(`${firstDomainPart.toUpperCase()}\\${username}`) |
| } |
| } |
|
|
| |
| adFormats.push(username) |
|
|
| logger.info(`🔄 尝试 ${adFormats.length} 种Windows AD认证格式...`) |
|
|
| for (const format of adFormats) { |
| try { |
| logger.info(`🔍 尝试格式: ${format}`) |
| const result = await this.tryDirectBind(format, password) |
| if (result) { |
| logger.info(`✅ Windows AD认证成功: ${format}`) |
| return true |
| } |
| logger.debug(`❌ 认证失败: ${format}`) |
| } catch (error) { |
| logger.debug(`认证异常 ${format}:`, error.message) |
| } |
| } |
|
|
| logger.info(`🚫 所有Windows AD格式认证都失败了`) |
| return false |
| } |
|
|
| |
| async tryDirectBind(identifier, password) { |
| return new Promise((resolve, reject) => { |
| const authClient = this.createClient() |
|
|
| authClient.bind(identifier, password, (err) => { |
| authClient.unbind() |
|
|
| if (err) { |
| if (err.name === 'InvalidCredentialsError') { |
| resolve(false) |
| } else { |
| reject(err) |
| } |
| } else { |
| resolve(true) |
| } |
| }) |
| }) |
| } |
|
|
| |
| extractUserInfo(ldapEntry, username) { |
| try { |
| const attributes = ldapEntry.attributes || [] |
| const userInfo = { username } |
|
|
| |
| const attrMap = {} |
| attributes.forEach((attr) => { |
| const name = attr.type || attr.name |
| const values = Array.isArray(attr.values) ? attr.values : [attr.values] |
| attrMap[name] = values.length === 1 ? values[0] : values |
| }) |
|
|
| |
| const mapping = this.config.userMapping |
|
|
| userInfo.displayName = attrMap[mapping.displayName] || username |
| userInfo.email = attrMap[mapping.email] || '' |
| userInfo.firstName = attrMap[mapping.firstName] || '' |
| userInfo.lastName = attrMap[mapping.lastName] || '' |
|
|
| |
| if (!userInfo.displayName || userInfo.displayName === username) { |
| if (userInfo.firstName || userInfo.lastName) { |
| userInfo.displayName = `${userInfo.firstName || ''} ${userInfo.lastName || ''}`.trim() |
| } |
| } |
|
|
| logger.debug('📋 Extracted user info:', { |
| username: userInfo.username, |
| displayName: userInfo.displayName, |
| email: userInfo.email |
| }) |
|
|
| return userInfo |
| } catch (error) { |
| logger.error('❌ Error extracting user info:', error) |
| return { username } |
| } |
| } |
|
|
| |
| validateAndSanitizeUsername(username) { |
| if (!username || typeof username !== 'string' || username.trim() === '') { |
| throw new Error('Username is required and must be a non-empty string') |
| } |
|
|
| const trimmedUsername = username.trim() |
|
|
| |
| const usernameRegex = /^[a-zA-Z0-9_-]+$/ |
| if (!usernameRegex.test(trimmedUsername)) { |
| throw new Error('Username can only contain letters, numbers, underscores, and hyphens') |
| } |
|
|
| |
| if (trimmedUsername.length > 64) { |
| throw new Error('Username cannot exceed 64 characters') |
| } |
|
|
| |
| if (trimmedUsername.startsWith('-') || trimmedUsername.endsWith('-')) { |
| throw new Error('Username cannot start or end with a hyphen') |
| } |
|
|
| return trimmedUsername |
| } |
|
|
| |
| async authenticateUserCredentials(username, password) { |
| if (!this.config.enabled) { |
| throw new Error('LDAP authentication is not enabled') |
| } |
|
|
| |
| const sanitizedUsername = this.validateAndSanitizeUsername(username) |
|
|
| if (!password || typeof password !== 'string' || password.trim() === '') { |
| throw new Error('Password is required and must be a non-empty string') |
| } |
|
|
| |
| if (!this.config.server || !this.config.server.url) { |
| throw new Error('LDAP server URL is not configured') |
| } |
|
|
| if (!this.config.server.bindDN || typeof this.config.server.bindDN !== 'string') { |
| throw new Error('LDAP bind DN is not configured') |
| } |
|
|
| if ( |
| !this.config.server.bindCredentials || |
| typeof this.config.server.bindCredentials !== 'string' |
| ) { |
| throw new Error('LDAP bind credentials are not configured') |
| } |
|
|
| if (!this.config.server.searchBase || typeof this.config.server.searchBase !== 'string') { |
| throw new Error('LDAP search base is not configured') |
| } |
|
|
| const client = this.createClient() |
|
|
| try { |
| |
| await this.bindClient(client) |
|
|
| |
| const ldapEntry = await this.searchUser(client, sanitizedUsername) |
| if (!ldapEntry) { |
| logger.info(`🚫 User not found in LDAP: ${sanitizedUsername}`) |
| return { success: false, message: 'Invalid username or password' } |
| } |
|
|
| |
| logger.debug('🔍 LDAP entry details for DN extraction:', { |
| hasEntry: !!ldapEntry, |
| entryType: typeof ldapEntry, |
| entryKeys: Object.keys(ldapEntry || {}), |
| dn: ldapEntry.dn, |
| objectName: ldapEntry.objectName, |
| dnType: typeof ldapEntry.dn, |
| objectNameType: typeof ldapEntry.objectName |
| }) |
|
|
| |
| const userDN = this.extractDN(ldapEntry) |
|
|
| logger.debug(`👤 Extracted user DN: ${userDN} (type: ${typeof userDN})`) |
|
|
| |
| if (!userDN) { |
| logger.error(`❌ Invalid or missing DN for user: ${sanitizedUsername}`, { |
| ldapEntryDn: ldapEntry.dn, |
| ldapEntryObjectName: ldapEntry.objectName, |
| ldapEntryType: typeof ldapEntry, |
| extractedDN: userDN |
| }) |
| return { success: false, message: 'Authentication service error' } |
| } |
|
|
| |
| let isPasswordValid = false |
|
|
| |
| try { |
| isPasswordValid = await this.authenticateUser(userDN, password) |
| if (isPasswordValid) { |
| logger.info(`✅ DN authentication successful for user: ${sanitizedUsername}`) |
| } |
| } catch (error) { |
| logger.debug( |
| `DN authentication failed for user: ${sanitizedUsername}, error: ${error.message}` |
| ) |
| } |
|
|
| |
| if (!isPasswordValid) { |
| logger.debug(`🔄 Trying Windows AD authentication formats for user: ${sanitizedUsername}`) |
| isPasswordValid = await this.tryWindowsADAuthentication(sanitizedUsername, password) |
| if (isPasswordValid) { |
| logger.info(`✅ Windows AD authentication successful for user: ${sanitizedUsername}`) |
| } |
| } |
|
|
| if (!isPasswordValid) { |
| logger.info(`🚫 All authentication methods failed for user: ${sanitizedUsername}`) |
| return { success: false, message: 'Invalid username or password' } |
| } |
|
|
| |
| const userInfo = this.extractUserInfo(ldapEntry, sanitizedUsername) |
|
|
| |
| const user = await userService.createOrUpdateUser(userInfo) |
|
|
| |
| if (!user.isActive) { |
| logger.security( |
| `🔒 Disabled user LDAP login attempt: ${sanitizedUsername} from LDAP authentication` |
| ) |
| return { |
| success: false, |
| message: 'Your account has been disabled. Please contact administrator.' |
| } |
| } |
|
|
| |
| await userService.recordUserLogin(user.id) |
|
|
| |
| const sessionToken = await userService.createUserSession(user.id) |
|
|
| logger.info(`✅ LDAP authentication successful for user: ${sanitizedUsername}`) |
|
|
| return { |
| success: true, |
| user, |
| sessionToken, |
| message: 'Authentication successful' |
| } |
| } catch (error) { |
| |
| logger.error('❌ LDAP authentication error:', { |
| username: sanitizedUsername, |
| error: error.message, |
| stack: process.env.NODE_ENV === 'development' ? error.stack : undefined |
| }) |
|
|
| |
| |
| return { |
| success: false, |
| message: 'Authentication service unavailable' |
| } |
| } finally { |
| |
| if (client) { |
| client.unbind((err) => { |
| if (err) { |
| logger.debug('Error unbinding LDAP client:', err) |
| } |
| }) |
| } |
| } |
| } |
|
|
| |
| async testConnection() { |
| if (!this.config.enabled) { |
| return { success: false, message: 'LDAP is not enabled' } |
| } |
|
|
| const client = this.createClient() |
|
|
| try { |
| await this.bindClient(client) |
|
|
| return { |
| success: true, |
| message: 'LDAP connection successful', |
| server: this.config.server.url, |
| searchBase: this.config.server.searchBase |
| } |
| } catch (error) { |
| logger.error('❌ LDAP connection test failed:', { |
| error: error.message, |
| server: this.config.server.url, |
| stack: process.env.NODE_ENV === 'development' ? error.stack : undefined |
| }) |
|
|
| |
| let userMessage = 'LDAP connection failed' |
|
|
| |
| if (error.code === 'ECONNREFUSED') { |
| userMessage = 'Unable to connect to LDAP server' |
| } else if (error.code === 'ETIMEDOUT') { |
| userMessage = 'LDAP server connection timeout' |
| } else if (error.name === 'InvalidCredentialsError') { |
| userMessage = 'LDAP bind credentials are invalid' |
| } |
|
|
| return { |
| success: false, |
| message: userMessage, |
| server: this.config.server.url.replace(/:[^:]*@/, ':***@') |
| } |
| } finally { |
| if (client) { |
| client.unbind((err) => { |
| if (err) { |
| logger.debug('Error unbinding test LDAP client:', err) |
| } |
| }) |
| } |
| } |
| } |
|
|
| |
| getConfigInfo() { |
| const configInfo = { |
| enabled: this.config.enabled, |
| server: { |
| url: this.config.server.url, |
| searchBase: this.config.server.searchBase, |
| searchFilter: this.config.server.searchFilter, |
| timeout: this.config.server.timeout, |
| connectTimeout: this.config.server.connectTimeout |
| }, |
| userMapping: this.config.userMapping |
| } |
|
|
| |
| if (this.config.server.url.toLowerCase().startsWith('ldaps://') && this.config.server.tls) { |
| configInfo.server.tls = { |
| rejectUnauthorized: this.config.server.tls.rejectUnauthorized, |
| hasCA: !!this.config.server.tls.ca, |
| hasCert: !!this.config.server.tls.cert, |
| hasKey: !!this.config.server.tls.key, |
| servername: this.config.server.tls.servername |
| } |
| } |
|
|
| return configInfo |
| } |
| } |
|
|
| module.exports = new LdapService() |
|
|