WeKan wekan-ldap 包实战:LDAP 登录配置项全解与源码级实现剖析
【免费下载链接】wekanThe Open Source kanban, built with Meteor. GitHub issues/PRs are only for FLOSS Developers, not for support, support is at https://wekan.fi/commercial-support/ . PR source translation to imports/i18n/data/en.i18n.json, other translations at https://app.transifex.com/wekan/wekan项目地址: https://gitcode.com/GitHub_Trending/we/wekan
WeKan 通过本地 Meteor 包packages/wekan-ldap实现企业级 LDAP/Active Directory 单点登录、用户自动创建与后台同步。本篇以该包的官方 README(配置项定义与 settings.json 示例)为骨架,结合 ldap.js、loginHandler.js、sync.js 等源码,逐条讲清每个环境变量的真实解析路径、登录与同步的完整调用链、加密协商规则与安全边界,帮助你在 Docker/源码部署中正确配置 LDAP 并理解其底层行为。
1. 包结构与依赖
wekan-ldap是一个独立的 Meteor 本地包,其 package.js 声明了核心依赖:
accounts-base、accounts-password(server):挂载到 Meteor 账号体系的登录处理器;ddp(server):用于把组织/团队同步做成“server-to-server”的内部方法调用;quave:synced-cron(server):驱动后台同步的定时任务;- NPM 依赖
ldapts@4.2.6(LDAP 协议客户端,取代旧版 ldapjs)与limax@4.1.0(用户名 slugify)。
包入口 server/index.js 只做两件事:导入loginHandler(注册登录处理器),并导出setLdapSettingsAccessor供应用侧在启动时注入管理面板的 LDAP 设置读取器。客户端唯一入口是 client/loginHelper.js,它提供Meteor.loginWithLDAP(username, password, customLdapOptions, callback),通过Accounts.callLoginMethod发送{ldap: true, username, ldapPass, ldapOptions}触发服务端的Accounts.registerLoginHandler('ldap', ...)。
2. 配置项定义(README 完整继承)
README(packages/wekan-ldap/README.md)对每个环境变量给出了定义,以下为完整清单(按原 README 语义整理):
| 配置项 | 含义 |
|---|---|
LDAP_Enable | 是否启用 LDAP |
LDAP_Port | LDAP 服务器端口 |
LDAP_Host | LDAP 服务器主机 |
LDAP_BaseDN | LDAP 树的根 DN |
LDAP_Login_Fallback | LDAP 失败时回退到默认认证方式 |
LDAP_Reconnect | 连接丢失时是否重连 |
LDAP_Timeout | 超时(自明) |
LDAP_Idle_Timeout | 空闲超时(自明) |
LDAP_Connect_Timeout | 连接超时(自明) |
LDAP_Authentication | 是否需要一个“服务账号”来执行搜索 |
LDAP_Authentication_UserDN | 搜索用服务账号的 DN |
LDAP_Authentication_Password | 搜索用服务账号密码 |
LDAP_Internal_Log_Level | 模块日志级别 |
LDAP_Background_Sync | 是否在后台同步用户 |
LDAP_Background_Sync_Interval | 后台同步的触发间隔 |
LDAP_Encryption | 使用 LDAPS 时设为'ssl'(现推荐true),否则走ldap://明文 |
LDAP_CA_Cert | LDAPS 服务器证书 |
LDAP_Reject_Unauthorized | 是否拒绝未授权证书 |
LDAP_User_Search_Filter | 用户搜索过滤条件 |
LDAP_User_Search_Scope | 搜索范围 |
LDAP_User_Search_Field | 用哪个字段查找用户 |
LDAP_Search_Page_Size | 分页大小 |
LDAP_Search_Size_Limit | 结果数量限制 |
LDAP_Group_Filter_Enable | 启用组过滤 |
LDAP_Group_Filter_ObjectClass | 组过滤的 objectClass |
LDAP_Group_Filter_Group_Id_Attribute | 组标识属性 |
LDAP_Group_Filter_Group_Member_Attribute | 组成员属性 |
LDAP_Group_Filter_Group_Member_Format | 成员值格式 |
LDAP_Group_Filter_Group_Name | 允许登录的组名 |
LDAP_Unique_Identifier_Field | 唯一标识字段,常用 GUID(Globally Unique Identifier) |
UTF8_Names_Slugify | 是否把用户名转成 UTF8 slug |
LDAP_Username_Field | LDAP 中存放用户名的字段 |
LDAP_Fullname_Field | LDAP 中存放全名的字段 |
LDAP_Email_Match_Enable | 用户名不匹配时允许按邮箱匹配已有账号 |
LDAP_Email_Match_Require | 用户名匹配时强制按邮箱匹配已有账号 |
LDAP_Email_Match_Verified | 匹配时要求邮箱已验证 |
LDAP_Email_Field | LDAP 中存放邮箱的字段 |
LDAP_Sync_User_Data | 是否同步用户数据 |
LDAP_Sync_User_Data_FieldMap | LDAP 字段到 WeKan 字段的映射 |
Accounts_CustomFields | 自定义字段元数据(供 FieldMap 映射customFields.*) |
LDAP_Default_Domain | LDAP 默认域名,当邮箱字段映射不正确时用它拼接邮箱 |
注意 README 中的变量名(如LDAP_Enable)是历史写法;当前源码 ldap.js 通过settings_get()实际读取的全大写环境变量名为LDAP_ENABLE、LDAP_HOST、LDAP_PORT、LDAP_BASEDN、LDAP_AUTHENTIFICATION(源码中沿用此拼写)、LDAP_INTERNAL_LOG_LEVEL等。Snap 部署则使用小写连字符等价形式(如ldap-enable),官方使用文档见 docs/Features/Login/LDAP.md。
3. README 官方 settings.json 示例
README 给出的可复制示例(原样保留):
{ "LDAP_Port": 389, "LDAP_Host": "localhost", "LDAP_BaseDN": "ou=user,dc=example,dc=org", "LDAP_Login_Fallback": false, "LDAP_Reconnect": true, "LDAP_Timeout": 10000, "LDAP_Idle_Timeout": 10000, "LDAP_Connect_Timeout": 10000, "LDAP_Authentication": true, "LDAP_Authentication_UserDN": "cn=admin,dc=example,dc=org", "LDAP_Authentication_Password": "admin", "LDAP_Internal_Log_Level": "debug", "LDAP_Background_Sync": false, "LDAP_Background_Sync_Interval": "100", "LDAP_Encryption": false, "LDAP_Reject_Unauthorized": false, "LDAP_Group_Filter_Enable": false, "LDAP_Search_Page_Size": 0, "LDAP_Search_Size_Limit": 0, "LDAP_User_Search_Filter": "", "LDAP_User_Search_Field": "uid", "LDAP_User_Search_Scope": "", "LDAP_Unique_Identifier_Field": "guid", "LDAP_Username_Field": "uid", "LDAP_Fullname_Field": "cn", "LDAP_Email_Match_Enable": true, "LDAP_Email_Match_Require": false, "LDAP_Email_Match_Verified": false, "LDAP_Email_Field": "mail", "LDAP_Sync_User_Data": false, "LDAP_Sync_User_Data_FieldMap": "{\"cn\":\"name\", \"mail\":\"email\"}", "LDAP_Merge_Existing_Users": true, "UTF8_Names_Slugify": true }实际部署时这些键对应 Docker/源码环境的LDAP_HOST、LDAP_PORT等大写环境变量(Snap 环境为ldap-host等),下文按源码变量名展开。
4. 配置解析机制:环境变量 + 管理面板覆盖
ldap.js 的静态方法settings_get(name)是全部配置的统一读取口,其行为由 configResolver.js 的resolveConfigValue()决定,优先级为:
- 管理面板值(admin)优先:
LDAP_ADMIN_OVERRIDE_FIELD映射了 8 个可被管理面板覆盖的非敏感字段(LDAP_ENABLE→ldap.enabled、LDAP_HOST→ldap.host、LDAP_PORT→ldap.port、LDAP_BASEDN→ldap.baseDN、LDAP_AUTHENTIFICATION_USERDN、LDAP_USER_SEARCH_FILTER、LDAP_USER_SEARCH_FIELD、LDAP_ENCRYPTION),这些字段在 models/settings.js 的ldap子文档中声明,均为可选; - 其次读环境变量
process.env[name]; - 均为空则返回 undefined。
字符串值会做类型强转:'true'/'false'解析为布尔,纯数字解析为数值。两个关键安全设计值得注意:
- bind 密码单独解析:
Authentication_Password不走通用映射,而是resolveConfigValue('LDAP_AUTHENTIFICATION_PASSWORD', currentLdapAdminSettings().bindPassword)。源码注释明确说明这是唯一永远不发布到客户端的敏感字段(admin 面板仅存bindPasswordSet布尔标记表示“已设置”),且resolveConfigValue的注释指明 models/lib/configResolver.js 是规范实现,包内 configResolver.js 是 vendored 副本,两者必须保持同步(有 tests/configResolver.test.cjs 约束); - Accessor 注入模式:本地包不能直接 import 应用侧 Settings 集合,故由 server/ldapAdminSettingsBridge.js 在启动时调用
setLdapSettingsAccessor()注入 getter;注入前(如单测隔离加载)自动退化为“仅环境变量”行为。
5. 加密协商:LDAP_ENCRYPTION 的精确语义
README 说“用 LDAPS 就设'ssl'”,但源码 encryptionSetting.js 对该值做了严格的规范化(issue #4158 的修复),完整映射表如下:
| 原始值(不区分大小写) | 模式 | 说明 |
|---|---|---|
true/ 布尔true | tls | LDAPS,首字节即 TLS(通常 636 端口) |
ssl(legacy) | tls | 仍有效,但记录弃用警告,建议改true |
starttls | starttls | 明文连接后 STARTTLS 升级(通常 389 端口) |
tls(legacy) | starttls | 仍有效,记录弃用警告,建议改starttls |
false/''/undefined/null | off | 不加密 |
| 其他任意值 | off | 明文连接并打印醒目警告,列出合法值 |
normalizeLdapEncryption()永不抛错、也绝不“静默猜测”;ldap.js 的connect()依据模式拼接ldaps://host:port或ldap://host:port,并在starttls模式下调用client.startTLS(tlsOptions)。关于 CA 证书:LDAP_CA_CERT传入的是纯文本证书内容(可用cat ca.pem | tr -d '\n'压成一行),源码按-----END CERTIFICATE-----分段拆出证书链数组;LDAP_REJECT_UNAUTHORIZED未设置时默认true(源码中!== undefined ? ... : true),比 README 示例里的false更安全。官方文档 docs/Features/Login/LDAP.md 的“LDAP transport encryption”一节也给出了 FreeIPA、Active Directory、OpenLDAP 三种典型服务器的完整 snap set 示例,可作为实操参考。
6. 登录全流程(loginHandler.js)
loginHandler.js 中Accounts.registerLoginHandler('ldap', ...)是登录核心,完整链路如下:
- 前置检查:请求必须含
ldap与ldapOptions;LDAP_ENABLE !== true时直接走fallbackDefaultAccountSystem()(SHA-256 摘要后调用_runLoginHandlers,即退回本地账号密码认证); - 连接与认证(整个流程包裹在
runWithLdapDisconnect中,见第 9 节):ldap.connect()建立连接;- 若设置
LDAP_USER_AUTHENTICATION(直接以用户 DN 绑定模式):bindUserIfNecessary(username, password)先构造User_Authentication_Field=username,BaseDN(或 AD Simple Auth 的username@domain,即LDAP_AD_SIMPLE_AUTH+LDAP_DEFAULT_DOMAIN)直接 bind 验密,再searchUsers取条目; - 否则(服务账号模式):
searchUsers(username)恰好返回 1 条才算命中 →isUserInGroup()组过滤(未启用则恒真)→auth(dn, password)用该条目 DN 二次 bind 验证密码;
- 账号匹配(三级查找):
- 先按唯一标识
services.ldap.id(getLdapUserUniqueID()的 hex 值)查库; - 再按用户名查(用户名取
LDAP_USERNAME_FIELD的 slug 化值);若LDAP_EMAIL_MATCH_REQUIRE=true,则查询条件附加emails.0.address(LDAP_EMAIL_MATCH_VERIFIED=true时再附加emails.0.verified: true); - 仍未找到且
LDAP_EMAIL_MATCH_ENABLE=true时,按邮箱地址(可选要求已验证)做兜底匹配;
- 先按唯一标识
- 已有用户登录:若该用户
authenticationMethod !== 'ldap'且LDAP_MERGE_EXISTING_USERS !== true,抛错“MongoDB 中已存在匹配账号”防止账号劫持;随后生成 stamped login token,并在开启时依次执行:LDAP_SYNC_ADMIN_STATUS组→管理员同步(isAdminByGroups)、LDAP_SYNC_GROUP_ROLES组→角色同步(Roles.setUserRoles)、组织/团队同步(syncUserGroupsToOrgsTeamsSafe,失败只警告不阻断登录)、syncUserData()属性同步;若LDAP_LOGIN_FALLBACK=true还会把 LDAP 密码设为本地密码(双通道); - 新用户自动创建(
addLdapUser):先拒绝已知组对象(isKnownLdapGroup,防止宽 BaseDN 把组导入成账号);邮箱依次尝试 FieldMap 映射值 →mail属性 →username@LDAP_DEFAULT_DOMAIN拼接,三者皆无则报“no email to create an account”;Accounts.createUserAsync创建后写入services.ldap.id/idAttribute、emails.0.verified: true、authenticationMethod: 'ldap'。
用户名 slugify(UTF8_Names_Slugify)在 sync.js 的slug()中实现:按-分段后逐段用 limax 转.分隔 slug,避免p.parta-partb被错误折叠成p.parta.partb导致登录失败。
7. 用户搜索、字段模板与注入防护
searchUsers()使用getUserFilter(username)构造过滤条件(ldap.js):
- 若
LDAP_USER_SEARCH_FILTER非空且以(开头则原样使用,否则自动包成(filter); - 用户名经 RFC 4515 风格的
escapedToHex()转义(反斜杠转\5c等十六进制)后再拼入(field=username)子句,防止 LDAP 注入; LDAP_USER_SEARCH_FIELD支持逗号分隔多字段,多条子句合并为(|(f1=x)(f2=x))OR 结构;- 最终过滤条件形如
(&(filter)(field=escapedUsername)),scope 取LDAP_USER_SEARCH_SCOPE(默认sub),并应用LDAP_SEARCH_SIZE_LIMIT与LDAP_SEARCH_PAGE_SIZE(>0 时启用 paged 查询)。
字段映射支持模板:LDAP_USERNAME_FIELD、LDAP_EMAIL_FIELD、LDAP_FULLNAME_FIELD中可写#{attr1}#{attr2}形式,getLdapUsername()/getLdapEmail()/getLdapFullname()会递归替换为目录属性值(sync.js)。LDAP_SYNC_USER_DATA+LDAP_SYNC_USER_DATA_FIELDMAP(JSON 字符串,如{"cn":"name","mail":"email"})驱动getDataToSyncUserData():目标字段白名单为email、name、customFields;映射到customFields.*时还会校验Accounts_CustomFields元数据中确实存在该键;LDAP 属性名通过getLDAPValue做大小写不敏感读取(issue #6481:ldapts 保留服务端属性大小写,旧的大小写敏感读取会取到 undefined)。
8. 组过滤、管理员同步与组织/团队同步
组查询(getUserGroups/isUserInGroup,ldap.js)服务于四个独立功能:登录组限制、LDAP_SYNC_ADMIN_STATUS、LDAP_SYNC_GROUP_ROLES、LDAP_SYNC_ORGANIZATIONS/LDAP_SYNC_TEAMS,任一开启才会执行组搜索,全部关闭则跳过查询。要点:
- 组子树独立:
LDAP_GROUP_BASEDN可让组与用户分属不同子树(issue #5539,ou=groups与ou=people并列的常见布局);未设置时回退用户 BaseDN,空串视为未设置(避免误搜全库); - Fail-closed 设计:组成员属性缺失时回答“无组”而非“全部组”——历史上该子句缺失会让搜索匹配目录中所有组,导致人人是管理员(#6540)或登录限制形同虚设;必填的组查询三件套由 groupFilterConfig.js 集中校验:
LDAP_GROUP_FILTER_GROUP_ID_ATTRIBUTE、LDAP_GROUP_FILTER_GROUP_MEMBER_ATTRIBUTE、LDAP_GROUP_FILTER_GROUP_MEMBER_FORMAT(成员值格式,常用dn,另接受objectName/distinguishedName兜底); - 管理员组匹配:adminGroups.js 的
isAdminByGroups()对组名做 trim + 小写化,且空配置列表永不出管理员(#6540 双根因修复:空格与空串误匹配 + 大小写敏感); - 登录组名单:
LDAP_GROUP_FILTER_GROUP_NAME支持逗号分隔多组,且开启管理员同步时LDAP_SYNC_ADMIN_GROUPS中的组也允许登录(#4036); - 组名、过滤值均经
escapeLdapFilterValue()做 RFC 4515 转义(\28/\29/\2a/\5c/\00),目录中含括号的 DN 或 cn 不会破坏过滤器(#5236); - 组织/团队同步(#4737,默认关闭):
syncUserGroupsToOrgsTeams()用LDAP_SYNC_ORGANIZATIONS_GROUPS/LDAP_SYNC_TEAMS_GROUPS白名单筛选组,再通过DDP._CurrentMethodInvocation.withValue(undefined, ...)清掉登录方法上下文后调用setUserOrgsTeamsFromLdap——这是“server-to-server”调用,避免登录时 admin 守卫把客户端连接误判为未登录而拒绝(#6461);实际创建是只增不删的。
9. 后台同步与连接生命周期
sync.js 用quave:synced-cron注册名为LDAP_Sync的定时任务:
LDAP_BACKGROUND_SYNC !== true时移除任务;schedule 由LDAP_BACKGROUND_SYNC_INTERVAL决定(later.js 文本格式,如every 1 hours),未设置时默认每分钟(parser.recur().on(0).minute());注册做了 500ms debounce,配置变更后可动态重注册;- 每轮
sync()先建连接;LDAP_BACKGROUND_SYNC_IMPORT_NEW_USERS触发importNewUsers(ldap)(宽过滤searchUsers('*')批量导入,跳过组对象,LDAP_MERGE_EXISTING_USERS时把已有同用户名账号纳入同步); LDAP_BACKGROUND_SYNC_KEEP_EXISTANT_USERS_UPDATED遍历所有带services.ldap的用户:优先按持久化的services.ldap.id + idAttribute精确回查(getUserById,歧义多结果直接抛错拒绝更新),否则按用户名回查;然后同步 username/fullname/email/头像(jpegPhoto/thumbnailPhoto 以 data: URI 写入profile.avatarUrl,仅当用户没有本地上传头像时覆盖)、管理员状态、组织/团队;LDAP_BACKGROUND_SYNC_DISABLE_NONEXISTANT_USERS=true(默认关)时 LDAP 成为活跃状态的权威源:目录中查无此人则禁用(loginDisabled),重新出现则恢复启用(#4738/#4739);- 最后调用
propagateOrgTeamMembersToBoards传播组织/团队成员到看板(自身 try/catch,失败不影响同步); - 连接必然释放:整轮同步的
finally中await ldap.disconnect();登录路径由 connectionGuard.js 的runWithLdapDisconnect()以同样的 finally 语义包裹。源码注释记录了修复背景(#6467/#6469):旧代码任何退出路径都不disconnect(),每次登录/每轮同步都泄漏一个 socket,直到目录服务器“too many open connections”拒绝连接,拖垮全部登录。disconnect()自身也做了防御:client 未建立时安全 no-op,unbind 异常只记日志不影响原结果。
管理面板的“Test Connection”按钮对应 testConnection.js 的ldap_test_connection方法:要求isAdmin(修复前任何登录用户都能触发对目录服务器的 bind 尝试)、LDAP_ENABLE=true,成功后返回Connection_success,且同样保证连接释放。
10. 部署建议与注意事项
综合源码约束,实操要点:
- 最小可用配置(OpenLDAP 服务账号模式):
LDAP_ENABLE=true、LDAP_HOST/LDAP_PORT、LDAP_BASEDN、LDAP_AUTHENTIFICATION=true+ 服务账号 DN/密码、LDAP_USER_SEARCH_FIELD=uid、LDAP_USERNAME_FIELD=uid、LDAP_FULLNAME_FIELD=cn、LDAP_EMAIL_FIELD=mail;若 BaseDN 里混有组对象,务必设LDAP_USER_SEARCH_FILTER=(objectClass=inetOrgPerson)(AD 用(objectClass=user)); - 加密:生产环境优先
LDAP_ENCRYPTION=true(LDAPS/636)或starttls(389),配LDAP_CA_CERT(纯文本)与LDAP_REJECT_UNAUTHORIZED=true;拼错值不会报错但会明文连接并打警告,注意查日志; - 邮箱兜底:新建账号必须能拿到邮箱,否则依赖
LDAP_DEFAULT_DOMAIN拼接(username@domain),建议同时配好 FieldMapmail → email; - 账号安全:已有非 LDAP 账号与 LDAP 同名时默认拒绝合并,需显式
LDAP_MERGE_EXISTING_USERS=true;bind 密码只走环境变量或管理面板专用字段,永不发布到客户端; - AD Simple Auth:不想用服务账号时可设
LDAP_USER_AUTHENTICATION=true、LDAP_AD_SIMPLE_AUTH=true+LDAP_DEFAULT_DOMAIN,直接以user@domain形式 bind,详见 docs/Features/Login/LDAP-AD-Simple-Auth.md; - 组相关功能(登录限制/管理员同步/角色同步/组织团队同步)共用同一套组查询配置,缺一即 fail-closed(拒绝登录或回答无组),配置时按 groupFilterConfig.js 的三件套逐项检查日志中的缺失清单。
以上配置项、默认值与行为均可在当前仓库 packages/wekan-ldap 的源码中直接核验;使用层面的更多服务器样例(Active Directory、FreeIPA、OpenLDAP 的完整snap set命令)请参阅 docs/Features/Login/LDAP.md。
【免费下载链接】wekanThe Open Source kanban, built with Meteor. GitHub issues/PRs are only for FLOSS Developers, not for support, support is at https://wekan.fi/commercial-support/ . PR source translation to imports/i18n/data/en.i18n.json, other translations at https://app.transifex.com/wekan/wekan项目地址: https://gitcode.com/GitHub_Trending/we/wekan
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考