mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 00:53:33 +00:00
892 lines
28 KiB
JavaScript
892 lines
28 KiB
JavaScript
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
|
||
|
||
// 设置服务器类型,默认为 OpenLDAP
|
||
this.serverType = this.config.serverType || 'openldap'
|
||
this.isActiveDirectory = this.serverType === 'activedirectory'
|
||
|
||
// 验证配置 - 只有在 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')
|
||
}
|
||
}
|
||
|
||
// 🔍 解析Windows AD用户名格式
|
||
parseActiveDirectoryUsername(username) {
|
||
if (!this.isActiveDirectory) {
|
||
return { username, domain: null, format: 'simple' }
|
||
}
|
||
|
||
const trimmedUsername = username.trim()
|
||
|
||
// 检查UPN格式 (user@domain.com)
|
||
if (trimmedUsername.includes('@')) {
|
||
const parts = trimmedUsername.split('@')
|
||
if (parts.length === 2 && parts[0] && parts[1]) {
|
||
return {
|
||
username: parts[0],
|
||
domain: parts[1],
|
||
format: 'upn',
|
||
fullUsername: trimmedUsername
|
||
}
|
||
}
|
||
}
|
||
|
||
// 检查域\用户名格式 (DOMAIN\user)
|
||
if (trimmedUsername.includes('\\')) {
|
||
const parts = trimmedUsername.split('\\')
|
||
if (parts.length === 2 && parts[0] && parts[1]) {
|
||
return {
|
||
username: parts[1],
|
||
domain: parts[0],
|
||
format: 'domain',
|
||
fullUsername: trimmedUsername
|
||
}
|
||
}
|
||
}
|
||
|
||
// 简单用户名格式
|
||
return {
|
||
username: trimmedUsername,
|
||
domain: null,
|
||
format: 'simple',
|
||
fullUsername: trimmedUsername
|
||
}
|
||
}
|
||
|
||
// 🔍 获取服务器类型特定的搜索过滤器
|
||
getServerSpecificSearchFilter(usernameInfo) {
|
||
if (this.isActiveDirectory) {
|
||
const { username, fullUsername } = usernameInfo
|
||
// Windows AD: 支持 sAMAccountName 和 userPrincipalName
|
||
if (fullUsername && fullUsername.includes('@')) {
|
||
// 如果是UPN格式,优先使用userPrincipalName搜索
|
||
return `(|(userPrincipalName=${fullUsername})(sAMAccountName=${username}))`
|
||
} else {
|
||
// 否则同时搜索两个属性
|
||
return `(|(sAMAccountName=${username})(userPrincipalName=${username}))`
|
||
}
|
||
} else {
|
||
// OpenLDAP: 使用配置的搜索过滤器或默认的uid
|
||
const filterTemplate = this.config.server.searchFilter || '(uid={{username}})'
|
||
return filterTemplate.replace('{{username}}', usernameInfo.username)
|
||
}
|
||
}
|
||
|
||
// 🔍 获取服务器类型特定的搜索属性
|
||
getServerSpecificSearchAttributes() {
|
||
if (this.isActiveDirectory) {
|
||
// Windows AD 特定属性
|
||
return (
|
||
this.config.server.searchAttributes || [
|
||
'dn',
|
||
'sAMAccountName',
|
||
'userPrincipalName',
|
||
'cn',
|
||
'displayName',
|
||
'mail',
|
||
'givenName',
|
||
'sn',
|
||
'memberOf',
|
||
'objectClass',
|
||
'userAccountControl'
|
||
]
|
||
)
|
||
} else {
|
||
// OpenLDAP 默认属性
|
||
return this.config.server.searchAttributes || ['dn', 'uid', 'cn', 'mail', 'givenName', 'sn']
|
||
}
|
||
}
|
||
|
||
// 🔍 提取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
|
||
}
|
||
|
||
// 🔗 创建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) => {
|
||
// 解析用户名(对Windows AD进行特殊处理)
|
||
const usernameInfo = this.parseActiveDirectoryUsername(username)
|
||
|
||
// 防止LDAP注入:转义特殊字符
|
||
// 根据RFC 4515,需要转义的特殊字符:* ( ) \ NUL
|
||
const escapedUsername = usernameInfo.username
|
||
.replace(/\\/g, '\\5c') // 反斜杠必须先转义
|
||
.replace(/\*/g, '\\2a') // 星号
|
||
.replace(/\(/g, '\\28') // 左括号
|
||
.replace(/\)/g, '\\29') // 右括号
|
||
.replace(/\0/g, '\\00') // NUL字符
|
||
.replace(/\//g, '\\2f') // 斜杠
|
||
|
||
// 如果是UPN格式,也需要转义完整用户名
|
||
let escapedFullUsername = usernameInfo.fullUsername
|
||
if (escapedFullUsername && escapedFullUsername !== usernameInfo.username) {
|
||
escapedFullUsername = escapedFullUsername
|
||
.replace(/\\/g, '\\5c')
|
||
.replace(/\*/g, '\\2a')
|
||
.replace(/\(/g, '\\28')
|
||
.replace(/\)/g, '\\29')
|
||
.replace(/\0/g, '\\00')
|
||
.replace(/\//g, '\\2f')
|
||
}
|
||
|
||
// 构建转义后的用户名信息
|
||
const escapedUsernameInfo = {
|
||
...usernameInfo,
|
||
username: escapedUsername,
|
||
fullUsername: escapedFullUsername
|
||
}
|
||
|
||
// 获取服务器特定的搜索过滤器和属性
|
||
const searchFilter = this.getServerSpecificSearchFilter(escapedUsernameInfo)
|
||
const searchAttributes = this.getServerSpecificSearchAttributes()
|
||
|
||
const searchOptions = {
|
||
scope: 'sub',
|
||
filter: searchFilter,
|
||
attributes: searchAttributes
|
||
}
|
||
|
||
logger.debug(
|
||
`🔍 Searching for user: ${username} (${usernameInfo.format} format) with filter: ${searchFilter}`
|
||
)
|
||
if (this.isActiveDirectory && usernameInfo.domain) {
|
||
logger.debug(`🏢 Domain detected: ${usernameInfo.domain}`)
|
||
}
|
||
|
||
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,
|
||
serverType: this.serverType
|
||
})
|
||
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 (${this.serverType})`
|
||
)
|
||
|
||
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),
|
||
serverType: this.serverType
|
||
})
|
||
}
|
||
|
||
if (entries.length === 1) {
|
||
resolve(entries[0])
|
||
} else {
|
||
logger.warn(
|
||
`⚠️ Multiple LDAP entries found for username: ${username} (${this.serverType})`
|
||
)
|
||
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)
|
||
}
|
||
})
|
||
})
|
||
}
|
||
|
||
// 📝 提取用户信息
|
||
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
|
||
})
|
||
|
||
// 根据服务器类型和配置映射用户属性
|
||
if (this.isActiveDirectory) {
|
||
// Windows AD 特定属性映射
|
||
const mapping = this.config.userMapping || {}
|
||
|
||
// 显示名称:优先使用displayName,其次cn
|
||
userInfo.displayName =
|
||
attrMap[mapping.displayName || 'displayName'] ||
|
||
attrMap[mapping.displayName || 'cn'] ||
|
||
attrMap['displayName'] ||
|
||
attrMap['cn'] ||
|
||
username
|
||
|
||
// 邮箱
|
||
userInfo.email =
|
||
attrMap[mapping.email || 'mail'] ||
|
||
attrMap['mail'] ||
|
||
attrMap['userPrincipalName'] || // UPN作为后备邮箱
|
||
''
|
||
|
||
// 名字
|
||
userInfo.firstName = attrMap[mapping.firstName || 'givenName'] || attrMap['givenName'] || ''
|
||
|
||
// 姓氏
|
||
userInfo.lastName = attrMap[mapping.lastName || 'sn'] || attrMap['sn'] || ''
|
||
|
||
// Windows AD 特有信息
|
||
userInfo.sAMAccountName = attrMap['sAMAccountName'] || username
|
||
userInfo.userPrincipalName = attrMap['userPrincipalName'] || ''
|
||
|
||
// 检查用户账户是否被禁用
|
||
const { userAccountControl } = attrMap
|
||
if (userAccountControl) {
|
||
// 检查 ADS_UF_ACCOUNTDISABLE 标志位 (0x02)
|
||
const isDisabled = (parseInt(userAccountControl) & 0x02) !== 0
|
||
if (isDisabled) {
|
||
userInfo.accountDisabled = true
|
||
logger.warn(`⚠️ Windows AD account is disabled: ${username}`)
|
||
}
|
||
}
|
||
|
||
logger.debug('📋 Extracted Windows AD user info:', {
|
||
username: userInfo.username,
|
||
displayName: userInfo.displayName,
|
||
email: userInfo.email,
|
||
sAMAccountName: userInfo.sAMAccountName,
|
||
userPrincipalName: userInfo.userPrincipalName,
|
||
accountDisabled: userInfo.accountDisabled || false
|
||
})
|
||
} else {
|
||
// OpenLDAP 标准属性映射
|
||
const mapping = this.config.userMapping || {}
|
||
|
||
userInfo.displayName = attrMap[mapping.displayName || 'cn'] || attrMap['cn'] || username
|
||
userInfo.email = attrMap[mapping.email || 'mail'] || attrMap['mail'] || ''
|
||
userInfo.firstName = attrMap[mapping.firstName || 'givenName'] || attrMap['givenName'] || ''
|
||
userInfo.lastName = attrMap[mapping.lastName || 'sn'] || attrMap['sn'] || ''
|
||
|
||
logger.debug('📋 Extracted OpenLDAP user info:', {
|
||
username: userInfo.username,
|
||
displayName: userInfo.displayName,
|
||
email: userInfo.email
|
||
})
|
||
}
|
||
|
||
// 如果没有displayName,尝试组合firstName和lastName
|
||
if (!userInfo.displayName || userInfo.displayName === username) {
|
||
if (userInfo.firstName || userInfo.lastName) {
|
||
userInfo.displayName = `${userInfo.firstName || ''} ${userInfo.lastName || ''}`.trim()
|
||
}
|
||
}
|
||
|
||
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()
|
||
|
||
if (this.isActiveDirectory) {
|
||
// Windows AD 用户名验证:支持 UPN 和 domain\username 格式
|
||
// UPN 格式:user@domain.com
|
||
if (trimmedUsername.includes('@')) {
|
||
const emailRegex = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
|
||
if (!emailRegex.test(trimmedUsername)) {
|
||
throw new Error('Invalid UPN format (user@domain.com)')
|
||
}
|
||
|
||
if (trimmedUsername.length > 256) {
|
||
throw new Error('UPN cannot exceed 256 characters')
|
||
}
|
||
|
||
return trimmedUsername
|
||
}
|
||
|
||
// Domain\username 格式
|
||
if (trimmedUsername.includes('\\')) {
|
||
const parts = trimmedUsername.split('\\')
|
||
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
||
throw new Error('Invalid domain\\username format')
|
||
}
|
||
|
||
const domain = parts[0]
|
||
const user = parts[1]
|
||
|
||
// 验证域名(允许字母数字和连字符)
|
||
const domainRegex = /^[a-zA-Z0-9-]+$/
|
||
if (!domainRegex.test(domain)) {
|
||
throw new Error('Domain name can only contain letters, numbers, and hyphens')
|
||
}
|
||
|
||
// 验证用户名部分
|
||
const userRegex = /^[a-zA-Z0-9._-]+$/
|
||
if (!userRegex.test(user)) {
|
||
throw new Error(
|
||
'Username can only contain letters, numbers, dots, underscores, and hyphens'
|
||
)
|
||
}
|
||
|
||
if (trimmedUsername.length > 256) {
|
||
throw new Error('Domain\\username cannot exceed 256 characters')
|
||
}
|
||
|
||
return trimmedUsername
|
||
}
|
||
|
||
// 简单用户名格式(sAMAccountName)
|
||
const samAccountRegex = /^[a-zA-Z0-9._-]+$/
|
||
if (!samAccountRegex.test(trimmedUsername)) {
|
||
throw new Error(
|
||
'sAMAccountName can only contain letters, numbers, dots, underscores, and hyphens'
|
||
)
|
||
}
|
||
|
||
// sAMAccountName 长度限制(AD 限制为 20 字符)
|
||
if (trimmedUsername.length > 20) {
|
||
throw new Error('sAMAccountName cannot exceed 20 characters')
|
||
}
|
||
|
||
return trimmedUsername
|
||
} else {
|
||
// OpenLDAP 用户名验证(原有逻辑)
|
||
// 用户名只能包含字母、数字、下划线和连字符
|
||
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. 验证用户密码
|
||
const isPasswordValid = await this.authenticateUser(userDN, password)
|
||
if (!isPasswordValid) {
|
||
logger.info(`🚫 Invalid password for user: ${sanitizedUsername}`)
|
||
return { success: false, message: 'Invalid username or password' }
|
||
}
|
||
|
||
// 5. 提取用户信息
|
||
const userInfo = this.extractUserInfo(ldapEntry, sanitizedUsername)
|
||
|
||
// 6. Windows AD 特定检查:验证账户是否被禁用
|
||
if (this.isActiveDirectory && userInfo.accountDisabled) {
|
||
logger.security(
|
||
`🔒 Disabled Windows AD account login attempt: ${sanitizedUsername} from LDAP authentication`
|
||
)
|
||
return {
|
||
success: false,
|
||
message: 'Your account has been disabled. Please contact administrator.'
|
||
}
|
||
}
|
||
|
||
// 7. 创建或更新本地用户
|
||
const user = await userService.createOrUpdateUser(userInfo)
|
||
|
||
// 8. 检查用户是否被禁用
|
||
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.'
|
||
}
|
||
}
|
||
|
||
// 9. 记录登录
|
||
await userService.recordUserLogin(user.id)
|
||
|
||
// 10. 创建用户会话
|
||
const sessionToken = await userService.createUserSession(user.id)
|
||
|
||
logger.info(
|
||
`✅ LDAP authentication successful for user: ${sanitizedUsername} (${this.serverType})`
|
||
)
|
||
|
||
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,
|
||
serverType: this.serverType,
|
||
isActiveDirectory: this.isActiveDirectory,
|
||
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
|
||
}
|
||
}
|
||
|
||
// Windows AD 特定配置信息
|
||
if (this.isActiveDirectory) {
|
||
configInfo.activeDirectoryFeatures = {
|
||
supportsUPN: true,
|
||
supportsDomainUsername: true,
|
||
supportsGlobalCatalog: true,
|
||
checksAccountDisabled: true
|
||
}
|
||
}
|
||
|
||
return configInfo
|
||
}
|
||
}
|
||
|
||
module.exports = new LdapService()
|