From a1005e91c830b913a7ae10b35b1ba34bae1605a7 Mon Sep 17 00:00:00 2001 From: Feng Yue <2525275@gmail.com> Date: Wed, 3 Sep 2025 13:30:13 +0800 Subject: [PATCH] add support of Windows AD Server --- .env.example | 20 ++- config/config.example.js | 11 +- src/services/ldapService.js | 343 ++++++++++++++++++++++++++++++++---- 3 files changed, 332 insertions(+), 42 deletions(-) diff --git a/.env.example b/.env.example index 62f7fcfb..b4866560 100644 --- a/.env.example +++ b/.env.example @@ -64,11 +64,16 @@ TRUST_PROXY=true # 🔐 LDAP 认证配置 LDAP_ENABLED=false +# 服务器类型:openldap 或 activedirectory +LDAP_SERVER_TYPE=openldap +# LDAP 服务器配置 LDAP_URL=ldaps://ldap-1.test1.bj.yxops.net:636 LDAP_BIND_DN=cn=admin,dc=example,dc=com LDAP_BIND_PASSWORD=admin_password LDAP_SEARCH_BASE=dc=example,dc=com +# 搜索过滤器 (OpenLDAP 使用 uid,AD 会自动使用 sAMAccountName/userPrincipalName) LDAP_SEARCH_FILTER=(uid={{username}}) +# 搜索属性 (根据服务器类型自动设置,也可手动指定) LDAP_SEARCH_ATTRIBUTES=dn,uid,cn,mail,givenName,sn LDAP_TIMEOUT=5000 LDAP_CONNECT_TIMEOUT=10000 @@ -85,13 +90,26 @@ LDAP_TLS_REJECT_UNAUTHORIZED=true # 服务器名称 (可选,用于 SNI) # LDAP_TLS_SERVERNAME=ldap.example.com -# 🗺️ LDAP 用户属性映射 +# 🗺️ LDAP 用户属性映射 (根据服务器类型自动设置默认值) LDAP_USER_ATTR_USERNAME=uid LDAP_USER_ATTR_DISPLAY_NAME=cn LDAP_USER_ATTR_EMAIL=mail LDAP_USER_ATTR_FIRST_NAME=givenName LDAP_USER_ATTR_LAST_NAME=sn +# 🏢 Windows Active Directory 示例配置 +# LDAP_SERVER_TYPE=activedirectory +# LDAP_URL=ldaps://ad-server.company.com:636 +# # 或使用全局目录端口进行森林范围搜索 +# LDAP_URL=ldap://ad-server.company.com:3268 +# LDAP_BIND_DN=CN=Service Account,CN=Users,DC=company,DC=com +# LDAP_BIND_PASSWORD=service_account_password +# LDAP_SEARCH_BASE=DC=company,DC=com +# # AD 用户属性映射 (可选,会自动使用 AD 默认值) +# LDAP_USER_ATTR_USERNAME=sAMAccountName +# LDAP_USER_ATTR_DISPLAY_NAME=displayName +# LDAP_USER_ATTR_EMAIL=mail + # 👥 用户管理配置 USER_MANAGEMENT_ENABLED=false DEFAULT_USER_ROLE=user diff --git a/config/config.example.js b/config/config.example.js index 433ecd1f..88074000 100644 --- a/config/config.example.js +++ b/config/config.example.js @@ -130,14 +130,20 @@ const config = { // 🔐 LDAP 认证配置 ldap: { enabled: process.env.LDAP_ENABLED === 'true', + // 服务器类型:'openldap' 或 'activedirectory' + serverType: process.env.LDAP_SERVER_TYPE || 'openldap', server: { url: process.env.LDAP_URL || 'ldap://localhost:389', bindDN: process.env.LDAP_BIND_DN || 'cn=admin,dc=example,dc=com', bindCredentials: process.env.LDAP_BIND_PASSWORD || 'admin', searchBase: process.env.LDAP_SEARCH_BASE || 'dc=example,dc=com', + // 搜索过滤器 - OpenLDAP 默认使用 uid,Windows AD 会自动使用 sAMAccountName/userPrincipalName searchFilter: process.env.LDAP_SEARCH_FILTER || '(uid={{username}})', + // 搜索属性 - 根据服务器类型自动设置默认值 searchAttributes: process.env.LDAP_SEARCH_ATTRIBUTES ? process.env.LDAP_SEARCH_ATTRIBUTES.split(',') + : process.env.LDAP_SERVER_TYPE === 'activedirectory' + ? ['dn', 'sAMAccountName', 'userPrincipalName', 'cn', 'displayName', 'mail', 'givenName', 'sn', 'memberOf', 'objectClass', 'userAccountControl'] : ['dn', 'uid', 'cn', 'mail', 'givenName', 'sn'], timeout: parseInt(process.env.LDAP_TIMEOUT) || 5000, connectTimeout: parseInt(process.env.LDAP_CONNECT_TIMEOUT) || 10000, @@ -161,9 +167,10 @@ const config = { servername: process.env.LDAP_TLS_SERVERNAME || undefined } }, + // 用户属性映射 - 根据服务器类型自动设置默认值 userMapping: { - username: process.env.LDAP_USER_ATTR_USERNAME || 'uid', - displayName: process.env.LDAP_USER_ATTR_DISPLAY_NAME || 'cn', + username: process.env.LDAP_USER_ATTR_USERNAME || (process.env.LDAP_SERVER_TYPE === 'activedirectory' ? 'sAMAccountName' : 'uid'), + displayName: process.env.LDAP_USER_ATTR_DISPLAY_NAME || (process.env.LDAP_SERVER_TYPE === 'activedirectory' ? 'displayName' : 'cn'), email: process.env.LDAP_USER_ATTR_EMAIL || 'mail', firstName: process.env.LDAP_USER_ATTR_FIRST_NAME || 'givenName', lastName: process.env.LDAP_USER_ATTR_LAST_NAME || 'sn' diff --git a/src/services/ldapService.js b/src/services/ldapService.js index 75b4e704..1cfb3ba5 100644 --- a/src/services/ldapService.js +++ b/src/services/ldapService.js @@ -8,6 +8,10 @@ class LdapService { 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() @@ -54,6 +58,93 @@ class LdapService { } } + // 🔍 解析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) { @@ -219,9 +310,12 @@ class LdapService { // 🔍 搜索用户 async searchUser(client, username) { return new Promise((resolve, reject) => { + // 解析用户名(对Windows AD进行特殊处理) + const usernameInfo = this.parseActiveDirectoryUsername(username) + // 防止LDAP注入:转义特殊字符 // 根据RFC 4515,需要转义的特殊字符:* ( ) \ NUL - const escapedUsername = username + const escapedUsername = usernameInfo.username .replace(/\\/g, '\\5c') // 反斜杠必须先转义 .replace(/\*/g, '\\2a') // 星号 .replace(/\(/g, '\\28') // 左括号 @@ -229,14 +323,41 @@ class LdapService { .replace(/\0/g, '\\00') // NUL字符 .replace(/\//g, '\\2f') // 斜杠 - const searchFilter = this.config.server.searchFilter.replace('{{username}}', escapedUsername) + // 如果是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: this.config.server.searchAttributes + attributes: searchAttributes } - logger.debug(`🔍 Searching for user: ${username} with filter: ${searchFilter}`) + 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 = [] @@ -254,7 +375,8 @@ class LdapService { type: typeof entry.dn, entryType: typeof entry, hasAttributes: !!entry.attributes, - attributeCount: entry.attributes ? entry.attributes.length : 0 + attributeCount: entry.attributes ? entry.attributes.length : 0, + serverType: this.serverType }) entries.push(entry) }) @@ -270,7 +392,7 @@ class LdapService { res.on('end', (result) => { logger.debug( - `✅ LDAP search completed. Status: ${result.status}, Found ${entries.length} entries` + `✅ LDAP search completed. Status: ${result.status}, Found ${entries.length} entries (${this.serverType})` ) if (entries.length === 0) { @@ -282,14 +404,17 @@ class LdapService { entryType: typeof entries[0], entryConstructor: entries[0].constructor?.name, entryKeys: Object.keys(entries[0]), - entryStringified: JSON.stringify(entries[0], null, 2).substring(0, 500) + 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}`) + logger.warn( + `⚠️ Multiple LDAP entries found for username: ${username} (${this.serverType})` + ) resolve(entries[0]) // 使用第一个结果 } } @@ -350,13 +475,70 @@ class LdapService { attrMap[name] = values.length === 1 ? values[0] : values }) - // 根据配置映射用户属性 - const mapping = this.config.userMapping + // 根据服务器类型和配置映射用户属性 + if (this.isActiveDirectory) { + // Windows AD 特定属性映射 + 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,其次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) { @@ -365,12 +547,6 @@ class LdapService { } } - 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) @@ -386,23 +562,87 @@ class LdapService { 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 (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 > 64) { - throw new Error('Username cannot exceed 64 characters') - } + if (trimmedUsername.length > 256) { + throw new Error('UPN cannot exceed 256 characters') + } - // 不能以连字符开头或结尾 - if (trimmedUsername.startsWith('-') || trimmedUsername.endsWith('-')) { - throw new Error('Username cannot start or end with a hyphen') - } + return trimmedUsername + } - 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 + } } // 🔐 主要的登录验证方法 @@ -488,10 +728,21 @@ class LdapService { // 5. 提取用户信息 const userInfo = this.extractUserInfo(ldapEntry, sanitizedUsername) - // 6. 创建或更新本地用户 + // 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) - // 7. 检查用户是否被禁用 + // 8. 检查用户是否被禁用 if (!user.isActive) { logger.security( `🔒 Disabled user LDAP login attempt: ${sanitizedUsername} from LDAP authentication` @@ -502,13 +753,15 @@ class LdapService { } } - // 8. 记录登录 + // 9. 记录登录 await userService.recordUserLogin(user.id) - // 9. 创建用户会话 + // 10. 创建用户会话 const sessionToken = await userService.createUserSession(user.id) - logger.info(`✅ LDAP authentication successful for user: ${sanitizedUsername}`) + logger.info( + `✅ LDAP authentication successful for user: ${sanitizedUsername} (${this.serverType})` + ) return { success: true, @@ -598,6 +851,8 @@ class LdapService { getConfigInfo() { const configInfo = { enabled: this.config.enabled, + serverType: this.serverType, + isActiveDirectory: this.isActiveDirectory, server: { url: this.config.server.url, searchBase: this.config.server.searchBase, @@ -619,6 +874,16 @@ class LdapService { } } + // Windows AD 特定配置信息 + if (this.isActiveDirectory) { + configInfo.activeDirectoryFeatures = { + supportsUPN: true, + supportsDomainUsername: true, + supportsGlobalCatalog: true, + checksAccountDisabled: true + } + } + return configInfo } }