Harbor LDAP 模式用户登录与登出验证指南:从测试用例到源码实现
本篇指南以 Harbor 仓库测试用例 1-05-LDAP-user-log-in-log-out.md 为主体,完整讲解在 LDAP 认证模式(
auth_mode = ldap_auth)下验证普通用户登录、登出、错误凭据处理以及管理员管理的全过程。文中同时结合 Harbor 源码中 LDAP 认证链路的实现细节(搜索用户、绑定校验、首次登录落库、密码随机化等),帮助读者理解"为什么测试用例要这样设计",并可直接将用例步骤复用到自己的 Harbor 环境做验收验证。
一、测试目标与适用场景
该用例的核心目标非常明确:验证当用户由外部 LDAP 服务器统一管理时,一个非管理员用户(non-admin user)能够正常完成 UI 与 Docker CLI 的登录、登出,且其身份管理完全受制于 LDAP 数据源。具体而言,它验证:
- LDAP 用户首次登录 Harbor 后能否被正确识别与落库;
- LDAP 用户在 UI 上无法修改自身账户设置、无法修改密码(密码由 LDAP/AD 管理);
- 登录失败时的错误提示不会泄露到底是用户名还是密码错误;
- 管理员可以在 Harbor 中看到 LDAP 用户,并为其授予/移除系统管理员角色;
- 当用户被从 LDAP 中禁用或删除后,将彻底失去登录能力。
该用例适用于 Harbor 使用 LDAP 作为唯一认证源(LDAP mode) 的部署场景,这也是企业内部将 Harbor 与公司统一身份目录(OpenLDAP / AD / FreeIPA 等)对接时的最常见形态。
二、环境前提(Environment)
用例明确要求如下环境,缺一不可:
- 一个正在运行且可访问的 Harbor 实例;
- Harbor 配置为 LDAP 认证模式,即
auth_mode设置为ldap_auth。在 Harbor 源码中,该取值定义于 src/common/const.go#L24:LDAPAuth = "ldap_auth"同时,用户数据存储于 LDAP 服务器(本地数据库仅保存认证所需的"影子"记录,详见下文首次登录一节);
- 一台安装了 Docker CLI(Docker 客户端)的 Linux 主机,用于验证
docker login; - 一台已配置好、并包含若干可用于测试用户的 LDAP 服务器。
从源码角度,LDAP 会话与认证相关的所有功能集中在三个层级:
- src/pkg/ldap/ldap.go:LDAP 会话(
Session)的建立、搜索、绑定等底层操作; - src/controller/ldap/controller.go:基于系统配置创建会话、提供 Ping/SearchUser/ImportUser/SearchGroup 能力;
- src/core/auth/ldap/ldap.go:实现
AuthenticateHelper接口,是登录认证的最终入口。
三、LDAP 模式核心配置参数
要复现本用例,需要先保证 Harbor 的 LDAP 配置正确。配置项集中定义于 src/lib/config/models/model.go#L77-L99,分为"基本 LDAP 配置"与"LDAP 组配置"两部分。
3.1 基本 LDAP 配置(LdapConf)
| 配置键 | 字段含义 | 说明 |
|---|---|---|
ldap_url | LDAP 服务器地址 | 支持 ldap:// 与 ldaps://;源码 formatURL 会在未指定端口时自动补全 389(ldap)或 636(ldaps),见 src/pkg/ldap/ldap.go#L77-L115 |
ldap_search_dn | 搜索 DN | 用于以只读身份绑定 LDAP 执行目录搜索(SearchUser 前先 Bind) |
ldap_search_password | 搜索 DN 的密码 | 与 ldap_search_dn 配套 |
ldap_base_dn | 用户搜索基 DN | 例如 ou=people,dc=example,dc=com |
ldap_filter | 用户搜索过滤器 | 在源码中会与 (uid=<用户名>) 条件通过 AND 组合,见 createUserSearchFilter(src/pkg/ldap/ldap.go#L291-L309) |
ldap_uid | 用户标识属性 | 用例中特别强调"id 可以是 LDAP 用户 DN 中配置的 uid 属性(或 ldap_uid 中配置的属性)";源码 SearchUser 会依据该属性从返回条目中提取用户名,见 src/pkg/ldap/ldap.go#L163-L181 |
ldap_scope | 搜索范围 | 对应 go-ldap 的 Scope 枚举(Base/OneLevel/Subtree) |
ldap_connection_timeout | 连接超时(秒) | 源码 Open 中会写入 goldap.DefaultTimeout |
ldap_verify_cert | 是否校验证书 | 仅对 ldaps 生效;为 false 时 InsecureSkipVerify 为真,见 src/pkg/ldap/ldap.go#L217-L228 |
3.2 LDAP 组配置(GroupConf)
组配置在登录过程中用于将 LDAP 组成员关系附着到用户身上,并识别"组管理员":
| 配置键 | 字段含义 |
|---|---|
ldap_group_base_dn | 组搜索基 DN |
ldap_group_filter | 组搜索过滤器 |
ldap_group_name_attribute | 组名属性(默认为 cn) |
ldap_group_search_scope | 组搜索范围 |
ldap_group_admin_dn | 组管理员 DN(匹配到该 DN 的用户获得 AdminRoleInAuth) |
ldap_group_membership_attribute | 成员关系属性(如 memberof) |
ldap_group_attach_parallel | 是否并行附着组(源码中最多 5 个 worker,见 src/core/auth/ldap/ldap.go#L142-L195) |
注意:当 ldap_group_filter 为空时,登录流程会跳过组附着(attachLDAPGroup 直接返回),但组管理员 DN 的判断仍会执行,见 src/core/auth/ldap/ldap.go#L103-L123。
四、首次登录(用例 Step 1):LDAP 认证的完整调用链
用例 Step 1 要求:一个从未登录过 Harbor 的用户,第一次用其 LDAP id(uid 属性或 ldap_uid 配置的属性)登录 UI。这是整条认证链路最核心的一步,其源码流程如下。
4.1 认证主流程
入口是 src/core/auth/ldap/ldap.go#L55-L101 的 Authenticate 方法,逻辑分为四段:
- 空用户名校验:
p为空时直接返回auth.NewErrAuth("Empty user id")。 - 建立 LDAP 会话:通过
ldapCtl.Ctl.Session(ctx)读取系统 LDAP 配置并创建Session,随后Open()拨号建立连接(ldap/ldaps协议处理见 src/pkg/ldap/ldap.go#L195-L231)。 - 搜索用户:调用
ldapSession.SearchUser(p),实际执行带分页的目录搜索(pageSize = 1000,见 src/pkg/ldap/ldap.go#L33)。搜索结果必须恰好一条:- 0 条 →
auth.NewErrAuth("Not found an entry"); - 多于 1 条 →
auth.NewErrAuth("Multiple entries found")。
- 0 条 →
- 绑定校验密码:用搜索到的用户
DN加用户输入密码执行ldapSession.Bind(dn, m.Password)(见 src/pkg/ldap/ldap.go#L189-L192)。绑定失败即认证失败——LDAP 服务器才是密码校验的真正执行者,Harbor 本地并不存储 LDAP 用户密码。
搜索返回的用户模型定义在 src/pkg/ldap/model/ldap.go#L18-L24,包含用户名、邮箱、真实姓名、DN 及组成员 DN 列表。
4.2 首次登录的用户落库(OnBoardUser)
认证通过后,PostAuthenticate(src/core/auth/ldap/ldap.go#L329-L368)检查用户是否已存在于 Harbor 本地 user 表:
- 已存在:同步本地记录的
UserID,并校验 LDAP 返回的邮箱是否合法、是否需要回写更新; - 不存在:调用
OnBoardUser(src/core/auth/ldap/ldap.go#L227-L241)插入一条"影子记录"。
OnBoardUser 有两个值得注意的实现细节:
pwd, err := utils.GenerateRandomStringOrError()
u.Password = pwd // Password is not kept in local db
u.Comment = "from LDAP." // Source is from LDAP
- 密码不落库:为本地记录生成一个随机密码占位,真实密码始终由 LDAP 管理;
- 来源标记:
Comment标记为"from LDAP.",便于管理员在用户列表中识别 LDAP 导入用户。
这正解释了用例 Step 3 的断言:LDAP 用户无法在 Harbor 内修改密码,因为密码根本不存在于 Harbor,必须到 LDAP/AD 中修改。
4.3 组附着与系统管理员标记
attachLDAPGroup(src/core/auth/ldap/ldap.go#L103-L140)在登录时完成两件事:
- 若用户的某个组 DN 与配置的
ldap_group_admin_dn匹配,则将u.AdminRoleInAuth置为true; - 若配置了组搜索过滤器,则验证组 DN 存在性并调用
ugCtl.Ctl.Populate将组 ID 关联到用户(支持串行/并行两种模式)。
此外 syncUserInfoFromDB(src/core/auth/ldap/ldap.go#L213-L223)会从本地库回填 SysAdminFlag,保证通过 UI 授予过系统管理员角色的 LDAP 用户在会话中依然生效。
五、登出与再次登录(用例 Step 2–4):非管理员视角验证
用例要求:
- Step 2:用户从 UI 登出;
- Step 3:用户再次登录 UI,此时不应看到自己的账户设置,也不能修改密码(需到 LDAP/AD 修改);
- Step 4:再次登出。
5.1 登出行为
登出后应回到登录页(用例 Expected Outcome 明确要求"login page will be displayed again")。这是标准的服务端会话销毁 + 前端跳转流程,可在任何认证模式下复验。
5.2 为何 LDAP 用户看不到"账户设置/改密码"
根源即 4.2 节所述:LDAP 用户密码由外部目录管理,Harbor 仅保存占位随机密码与 "from LDAP." 标记。因此 UI 依据认证来源隐藏账户设置与密码修改入口,避免用户产生"在 Harbor 改了密码"的误解——真正生效的密码变更必须在 LDAP/AD 侧完成。
验证要点(来自用例 Expected Outcome):
- 首次登录与再次登录后,dashboard 与导航栏都应呈现非管理员视图(看不到 admin options);
- 账户设置与修改密码能力对 LDAP 用户不可用;
- 每次登出后重新显示登录页。
六、错误密码登录(用例 Step 5):错误信息不泄露细节
用例要求使用"错误的用户名 + 错误的密码"组合登录,并检查错误信息。Expected Outcome 给出明确约束:
The error message in Step 5 should not show which input value is incorrect. It should only display the username(email) and password combination is incorrect.
即:错误提示只能笼统地说"用户名/邮箱与密码组合不正确",绝不能区分"用户不存在"或"密码错误"。这是安全最佳实践,防止攻击者通过错误信息差异枚举有效用户名。
从实现看,这一约束也贯穿认证源码的多个分支:"Empty user id"、"Not found an entry"、"Multiple entries found"、绑定失败的错误均通过 auth.NewErrAuth 包装为统一的认证失败语义,由上层控制器收敛为不泄露细节的提示文案(参见 src/core/auth/ldap/ldap.go#L55-L101 各失败分支)。
七、Docker CLI 登录验证(用例 Step 6–7):docker login
用例要求在有 Docker CLI 的主机上执行:
docker login <harbor_host>
- Step 6:使用正确的用户名/密码,必须登录成功;
- Step 7:使用错误的密码,必须登录失败。
这一步验证的是 Harbor 的 Registry v2 token 服务与 LDAP 认证的打通:docker login 将用户名/密码提交给 Harbor 的认证接口,走的就是 4.1 节描述的 LDAP 认证链路;认证成功后 Harbor 签发 Docker 所需的 token。因此,凡 UI 能登录的 LDAP 用户,docker login 也应能登录;反之亦然,两个入口共享同一套 Authenticate 实现。
八、管理员视角(用例 Step 8):LDAP 用户管理
用例要求系统管理员登录 UI,进入 "admin Options"(管理员选项),并确认:
- 上述 LDAP 用户出现在用户列表中;
- 管理员可以给该用户授予或移除系统管理员角色。
这验证了 "LDAP user should have no difference from a user in local database"(LDAP 用户与本地数据库用户无差别)这一预期。支撑机制:
- 首次登录落库的"影子记录"让 LDAP 用户拥有了与本地用户一致的 Harbor 内身份(
UserID),从而可以参与项目成员、角色、审计等所有实体关联(见 src/core/auth/ldap/ldap.go#L52-L54 注释:"if the check is successful a dummy record will be inserted into DB, such that this user can be associated to other entities in the system"); - 管理员授予的
SysAdminFlag会在用户下次登录时通过syncUserInfoFromDB回填到会话(src/core/auth/ldap/ldap.go#L213-L223)。
九、禁用/移除 LDAP 用户(用例 Step 9–10):即时失去登录能力
用例最后要求:
- Step 9:在 LDAP 服务器中禁用或删除该用户;
- Step 10:该用户不再能通过 UI 或 Docker 客户端登录。
由于 Harbor 每次登录都实时与 LDAP 交互(搜索用户 → 绑定校验),而非使用缓存的凭据,因此在 LDAP 侧禁用/删除用户后,后续登录会因:
- 搜索不到用户(
"Not found an entry"),或 - 绑定失败(LDAP 返回凭据无效)
而立即失败。无需在 Harbor 侧做任何额外操作,这正是外部认证源管理模式的核心特性:账户生命周期完全由 LDAP 目录掌控,Harbor 只是忠实执行校验结果。管理员若希望从 Harbor 用户列表中彻底清理该影子记录,可另行通过用户管理界面删除(Harbor 支持基于 GDPR 的用户删除配置,见 src/lib/config/models/model.go#L101-L104 的 GDPRSetting)。
十、预期结果速查表(Expected Outcome 汇总)
| 步骤 | 预期结果 |
|---|---|
| Step 1 & 3 | 可用 LDAP id 登录 UI;dashboard 与导航栏为非管理员视图(无 admin options) |
| Step 3 | 无法更新账户设置、无法修改密码 |
| Step 2 & 4 | 登出后重新显示登录页 |
| Step 5 | 错误提示仅显示"用户名/邮箱与密码组合不正确",不泄露具体是哪个输入有误 |
| Step 6 | Docker 客户端可正常 docker login |
| Step 7 | Docker 客户端以错误密码登录失败 |
| Step 8 | LDAP 用户与本地数据库用户无差别;管理员可授予/移除其系统管理员角色 |
| Step 10 | LDAP 用户登录失败(UI 与 Docker 客户端均不可用) |
十一、相关源码与测试索引
如需深入阅读实现与测试,可按以下路径继续:
- 认证主流程:
src/core/auth/ldap/ldap.go(Authenticate/OnBoardUser/PostAuthenticate/attachLDAPGroup) - LDAP 会话与搜索:
src/pkg/ldap/ldap.go(Session.Open/SearchUser/Bind/formatURL/createUserSearchFilter) - LDAP 管理器与导入:
src/pkg/ldap/manager.go - LDAP 控制器:
src/controller/ldap/controller.go - LDAP 配置模型:
src/lib/config/models/model.go(LdapConf/GroupConf) - 认证模式常量:
src/common/const.go(LDAPAuth = "ldap_auth") - 原始测试用例:
tests/testcases/Group1-user-management/1-05-LDAP-user-log-in-log-out.md - LDAP 单元测试:
src/core/auth/ldap/ldap_test.go、src/pkg/ldap/ldap_test.go、src/pkg/ldap/filter_test.go
按照上述步骤完成全部验证后,即证明当前 Harbor 实例的 LDAP 认证模式在 UI 与 Docker CLI 双入口上均工作正常,用户生命周期与密码策略完全由外部 LDAP 目录托管,符合企业内部统一身份认证的预期。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考



