先说结论
很多 LDAP 项目并不是死在“连不上目录服务器”,而是死在 身份映射不稳定。
从 /home/ubuntu/odoo-temp/addons/auth_ldap/models/res_company_ldap.py 可以看出,Odoo 的标准 LDAP 链路其实分成四步:
- 用
ldap_filter搜索目录,必须找到 唯一一条 记录; - 拿到那条记录的 DN,再用用户输入密码做 bind;
- 认证成功后,把目录身份映射成 Odoo 本地用户;
- 如果本地没有用户,就按公司配置和模板语义创建账号。
所以 LDAP 在 Odoo 里不是“外部验密插件”,而是 把目录身份翻译为本地用户对象 的桥。
_get_entry() 真正做的是“身份决议”
很多人一看到 LDAP 配置,会把注意力放在:
- 服务器地址
- 端口
- binddn
- 密码
这些当然重要,但源码里 _get_entry() 才是最容易决定成败的函数。
它会:
- 读取
ldap_filter - 统计其中
%s的占位符数量 - 用用户输入的 login 去替换这些占位符
- 调
_query()去 LDAP 搜索 - 丢掉没有 DN 的结果
- 只接受
len(results) == 1
也就是说,0 条不行,多条也不行。
这意味着 LDAP 登录不是模糊匹配,而是一次严格的人员解析。你的 filter 不是“搜到一个差不多的人”,而是“把这个登录名唯一落到某个 DN 上”。
为什么这一步比大家想象中更关键
因为后面密码 bind、用户创建、权限复制,全部都建立在“你到底是谁”已经被唯一确定这个前提上。
如果搜索阶段就不稳定:
- 同一个邮箱命中两条历史账号;
uid和mail混用导致多条结果;- 离职账号没清理;
- 大小写和空格不统一;
那后面的失败在用户眼里只会显示成“LDAP 登录不稳定”,但根因其实是 身份决议不唯一。
Odoo 把 search 和 bind 明确拆成两段
_query() 与 _authenticate() 的分工很清楚。
_query() 干什么
它使用配置里的:
ldap_binddnldap_passwordldap_baseldap_filter
先去目录里找人。
这一段的 bind 身份,通常是:
- 一个有搜索权限的服务账号;
- 或者匿名 bind;
- 或某些目录允许的轻权限查询身份。
_authenticate() 干什么
等 _get_entry() 拿到唯一 DN 后,Odoo 再重新连接一次,执行:
conn.simple_bind_s(dn, password)
这一步才是在验证“用户输入的口令是否真能绑定这个 DN”。
这说明标准 auth_ldap 的心智模型不是“直接拿 login 和 password 去撞 LDAP”,而是:
先解析出用户对象,再验证这个对象的密码。
这个设计非常合理,因为很多目录体系里,最终可 bind 的标识并不是用户登录页里直接输入的那串字符串。
_map_ldap_attributes() 很克制,默认只映射少数字段
很多团队第一次接 LDAP 时,会下意识以为 Odoo 会把目录里的:
- 部门
- 岗位
- 主管
- 邮箱
- 手机
- 组织树
全部自动映射进来。
标准模块并没有这么做。
源码里 _map_ldap_attributes() 默认只构造了这几个值:
name: 取ldap_entry[1]['cn'][0]login: 取当前登录名company_id: 取当前 LDAP 配置所属公司- 如果 login 看起来像邮箱,再把
email = login
这很重要。
这说明什么
说明 Odoo 标准模块把 LDAP 视为:
- 认证来源
- 以及 最小身份初始化来源
而不是完整的人事主数据同步器。
因此很多实施中的误解,根子就在这里:
- 以为接完 LDAP,部门组织自动就会对;
- 以为 LDAP 组会自然变成 Odoo 权限;
- 以为目录里变更了姓名/邮箱,Odoo 一定同步刷新;
标准模块并不负责这一层。
company_id 来自 LDAP 配置,而不是目录条目动态推断
这也是一个特别容易被忽略的边界。
在 _map_ldap_attributes() 里,company_id 直接取的是:
conf['company'][0]
也就是当前这条 LDAP 配置所属的 Odoo 公司。
这意味着标准模块默认并不会根据 LDAP 条目中的 ou、departmentNumber、company 等属性动态判断用户该归属哪个公司。
这背后的设计含义非常直接
Odoo 的标准策略是:
先由管理员决定“这条 LDAP 配置服务哪个 Odoo 公司”,再把认证成功的用户放进这个公司语义里。
所以如果你有:
- 多公司数据库;
- 多个法人共用同一套 AD;
- 不同子公司用不同登录入口;
真正稳定的方案往往不是一条 LDAP 配置打天下,而是按公司边界拆配置、拆 filter、拆模板用户。
否则你会遇到很典型的问题:
- 人是同一个目录里的,但首登落错公司;
- 复制出来的模板权限属于另一家公司;
- 邮件、默认公司、可见公司都开始错位。
_get_or_create_user() 是本地身份落地的真正入口
LDAP 认证成功后,Odoo 接下来做的不是“直接给一个临时 session”,而是一定要回到本地 res_users。
_get_or_create_user() 的逻辑很值得仔细看:
- 先把 login
lower().strip(); - 直接查 SQL:
SELECT id, active FROM res_users WHERE lower(login)=%s; - 如果有已激活用户,直接返回该用户 ID;
- 如果没有,并且
create_user=True,才创建本地用户; - 若既不存在又不允许自动建档,直接
AccessDenied。
这里藏着三个非常关键的边界。
边界一:login 会被标准化
也就是:
- 大小写不敏感;
- 首尾空格会被去掉。
所以实施时最稳妥的做法,是从一开始就统一:
- 登录名到底用邮箱还是员工号;
- 是否全部小写;
- 是否允许历史别名继续存在。
边界二:LDAP 最终还是要落到本地用户
Odoo 不是无状态地“验完密码就放行”。
它必须知道:
- 这个人对应哪个
res.users; - 属于哪个公司;
- 拥有哪些组和本地语义。
这就是为什么 LDAP 项目上线后,用户治理仍然是 Odoo 内部治理的一部分。
边界三:create_user 决定的是“认证”和“开户”是否绑定
如果不开自动建档,那么 LDAP 只是证明:
- 这个目录身份是真的。
但它不自动代表:
- 这个人有资格进入当前数据库。
这条边界在正式环境里其实非常有价值。
Template User 解决的是“首登角色骨架”,不是目录权限镜像
当 conf['user'] 有值时,标准模块不是简单 create(values),而是:
browse(template_user).copy(default=values)
这说明 Template User 的作用是:
- 提供一份本地用户骨架;
- 把组、默认值、偏好等本地语义复制过来;
- 再把
name/login/company_id之类覆盖掉。
这和“LDAP 组同步”完全不是一回事。
所以最常见的问题不是 LDAP 验证失败,而是:
- Template User 太高权,首批用户一上来权限过大;
- Template User 太偏某部门,别的部门登录后体验完全不对;
- 多公司环境里模板本身就带着错误公司语义。
为什么标准模块刻意不做“全自动用户自动映射”
如果你从产品设计角度看,会发现 Odoo 这套实现非常保守。
它没有试图:
- 自动同步整个组织架构;
- 自动根据 LDAP group 映射用户组;
- 自动根据目录属性切换多公司可见性;
- 自动覆盖所有本地字段。
这不是能力不够,而是标准模块在守边界。
因为一旦把这件事做成“目录属性全自动驱动 Odoo 用户对象”,很快就会碰到:
- 多目录源冲突;
- 权限放大;
- 本地手工修正被反复覆盖;
- 账号治理责任归属不清。
标准模块宁可只提供一条稳健的最小链路:
搜索唯一身份 → bind 验证 → 找本地用户 → 没有则按模板创建。
实战里最该优先检查什么
如果用户反馈“LDAP 有时能进有时不能进”或“新用户首登落错了”,我会按这个顺序排:
- 先看
ldap_filter是否稳定命中唯一 DN; - 确认 bind 账号是否真的有搜索权限;
- 确认
company_id是否来自正确的 LDAP 配置; - 检查 login 规范是否统一且会 lower/strip 后不冲突;
- 检查 Template User 是否把本地角色骨架带偏;
- 最后再看是否需要定制属性同步,而不是误以为标准模块已包含这层能力。
一句话记忆
Odoo 的 LDAP 成败,关键不在“目录能不能连上”,而在“Search/Bind 之后,这个人如何被唯一、稳定、可治理地映射成一个本地 Odoo 用户”。
DISCUSSION
评论区