cc / src /services /ldapService.js
hequ's picture
Upload 221 files
69b897d verified
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
// 验证配置 - 只有在 LDAP 配置存在且启用时才验证
if (this.config && this.config.enabled) {
this.validateConfiguration()
}
}
// 🔍 验证LDAP配置
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)
// Don't throw error during initialization, just log warnings
logger.warn('⚠️ LDAP authentication may not work properly due to configuration errors')
} else {
logger.info('✅ LDAP configuration validation passed')
}
}
// 🔍 提取LDAP条目的DN
extractDN(ldapEntry) {
if (!ldapEntry) {
return null
}
// Try different ways to get the DN
let dn = null
// Method 1: Direct dn property
if (ldapEntry.dn) {
;({ dn } = ldapEntry)
}
// Method 2: objectName property (common in some LDAP implementations)
else if (ldapEntry.objectName) {
dn = ldapEntry.objectName
}
// Method 3: distinguishedName property
else if (ldapEntry.distinguishedName) {
dn = ldapEntry.distinguishedName
}
// Method 4: Check if the entry itself is a DN string
else if (typeof ldapEntry === 'string' && ldapEntry.includes('=')) {
dn = ldapEntry
}
// Convert DN to string if it's an object
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)
}
}
// Validate the DN format
if (typeof dn === 'string' && dn.trim() !== '' && dn.includes('=')) {
return dn.trim()
}
return null
}
// 🌐 从DN中提取域名,用于Windows AD UPN格式认证
extractDomainFromDN(dnString) {
try {
if (!dnString || typeof dnString !== 'string') {
return null
}
// 提取所有DC组件:DC=test,DC=demo,DC=com
const dcMatches = dnString.match(/DC=([^,]+)/gi)
if (!dcMatches || dcMatches.length === 0) {
return null
}
// 提取DC值并连接成域名
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
}
}
// 🔗 创建LDAP客户端连接
createClient() {
try {
const clientOptions = {
url: this.config.server.url,
timeout: this.config.server.timeout,
connectTimeout: this.config.server.connectTimeout,
reconnect: true
}
// 如果使用 LDAPS (SSL/TLS),添加 TLS 选项
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
}
// CA 证书
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
}
// 服务器名称 (SNI)
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
}
}
// 🔒 绑定LDAP连接(管理员认证)
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) => {
// 防止LDAP注入:转义特殊字符
// 根据RFC 4515,需要转义的特殊字符:* ( ) \ NUL
const escapedUsername = username
.replace(/\\/g, '\\5c') // 反斜杠必须先转义
.replace(/\*/g, '\\2a') // 星号
.replace(/\(/g, '\\28') // 左括号
.replace(/\)/g, '\\29') // 右括号
.replace(/\0/g, '\\00') // NUL字符
.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 {
// Log the structure of the first entry for debugging
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)
}
})
})
}
// 🔐 Windows AD兼容认证 - 在DN认证失败时尝试多种格式
async tryWindowsADAuthentication(username, password) {
if (!username || !password) {
return false
}
// 从searchBase提取域名
const domain = this.extractDomainFromDN(this.config.server.searchBase)
const adFormats = []
if (domain) {
// UPN格式(Windows AD标准)
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] || ''
// 如果没有displayName,尝试组合firstName和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')
}
// 验证和清理用户名 (防止LDAP注入)
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')
}
// 验证LDAP服务器配置
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 {
// 1. 使用管理员凭据绑定
await this.bindClient(client)
// 2. 搜索用户 (使用已验证的用户名)
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' }
}
// 3. 获取用户DN
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
})
// Use the helper method to extract DN
const userDN = this.extractDN(ldapEntry)
logger.debug(`👤 Extracted user DN: ${userDN} (type: ${typeof userDN})`)
// 验证用户DN
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' }
}
// 4. 验证用户密码 - 支持传统LDAP和Windows AD
let isPasswordValid = false
// 首先尝试传统的DN认证(保持原有LDAP逻辑)
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}`
)
}
// 如果DN认证失败,尝试Windows AD多格式认证
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' }
}
// 5. 提取用户信息
const userInfo = this.extractUserInfo(ldapEntry, sanitizedUsername)
// 6. 创建或更新本地用户
const user = await userService.createOrUpdateUser(userInfo)
// 7. 检查用户是否被禁用
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.'
}
}
// 8. 记录登录
await userService.recordUserLogin(user.id)
// 9. 创建用户会话
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
})
// 返回通用错误消息,避免信息泄露
// 不要尝试解析具体的错误信息,因为不同LDAP服务器返回的格式不同
return {
success: false,
message: 'Authentication service unavailable'
}
} finally {
// 确保客户端连接被关闭
if (client) {
client.unbind((err) => {
if (err) {
logger.debug('Error unbinding LDAP client:', err)
}
})
}
}
}
// 🔍 测试LDAP连接
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)
}
})
}
}
}
// 📊 获取LDAP配置信息(不包含敏感信息)
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
}
// 添加 TLS 配置信息(不包含敏感数据)
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()