Let users sign in with their directory (Active Directory / OpenLDAP) credentials through the existing login form. Reuses sso_providers (kind='ldap') and user_sso_identities, so no schema change is needed. Authentication is search-then-bind: a service account resolves the user and only the returned DN is bound with the submitted password, so user input is never used as a bind identity. Roles come from a role template at first provisioning only; changing a directory group later never re-templates an existing account. Directory outages and wrong passwords stay distinguishable (502 LDAP_UNAVAILABLE vs 401 AUTH_FAILED). Hardening from review: - An account holding its own password stops at the local check, so a local secret is never forwarded to the directory nor counted against that directory's own lockout policy. - A lookup without a unique exact identifier match is refused instead of being bound against the first hit. - A failure during the user bind surfaces as LDAP_UNAVAILABLE rather than a 401 credential verdict. - Binds are throttled before they reach the directory, keyed per identifier and client address, with the address resolved from a trusted peer so a spoofed X-Forwarded-For cannot reset the budget. - Enabling a plain ldap:// URL without StartTLS is rejected; a disabled certificate check is surfaced as a warning. - auto_provision defaults to off, with an optional allowed_groups allow-list and a group-search mode (member / uniqueMember / memberUid) for directories that do not expose memberOf. Nested groups are not resolved. - The identity key is configurable (entryUUID / objectGUID) and blank by default; a connectivity probe reports which attribute the directory exposes. - Config changes are audited against the acting admin, and referrals are no longer followed during binds. Tests cover the above without a live directory (ldap3.Connection is the only thing replaced); a live test exercises a real directory when configured. Co-authored-by: jubaoliang <jubaoliang@gmail.com>
27 KiB
LDAP 目录登录
Octop 支持接入企业目录(Active Directory、OpenLDAP 等)作为登录方式:用户在现有登录表单里直接输入域账号和密码,Octop 通过目录完成认证,并可自动创建本地账号、按目录组映射角色。
本文覆盖:功能与设计、跑起来(含本地 LDAP 开发服务器)、配置项参考、登录行为细则、HTTP API、测试方法、故障排查。
一、功能概览
| 能力 | 说明 |
|---|---|
| 登录方式 | 复用 /api/auth/login,无需新端点。有本地密码的账号在本地判完即止;无本地密码、或本地无此用户时回退目录 |
| 检索方式 | 服务账号(或匿名)先检索用户 DN,再用该 DN 以用户密码二次 bind 验证 |
| 账号开通 | 首次目录登录自动创建 Octop 账号(可关闭) |
| 角色映射 | 按目录组成员关系判定 admin / user,仅在建号时判定一次 |
| 属性同步 | 登录时回写邮箱、显示名;不动已存在账号的角色 |
| 传输安全 | ldaps:// 或 ldap:// + StartTLS,证书校验可关(仅测试自签证书) |
| 凭据存储 | 绑定密码经 Fernet 加密后存 sso_providers.client_secret_enc,API 从不回传 |
设计要点
- 不新增登录表单。目录用户走原有的用户名/密码输入框,登录页只多一行提示(
login.ldapHint),与现有交互一致。后端在本地密码校验失败后回退目录,因此同一入口同时服务本地账号与目录账号。 - 角色只在开通时判定,之后不回写。把已存在的用户挪进管理员组不会静默提权;角色变更由管理员在 Octop 内显式操作。
- 零 schema 变更。复用既有 SSO 表结构:配置行是
sso_providers.kind = 'ldap'(明细放在该行的extraJSON),身份关联复用user_sso_identities。 - 目录故障 ≠ 密码错误。目录不可达返回
502 LDAP_UNAVAILABLE,密码错误返回401 AUTH_FAILED。若该用户名对应的是有本地密码的账号,即使目录宕机也只报 401(避免掩盖用户打错密码)。
二、工作原理
2.1 登录时序
用户提交 username / password
│
├─ 1. 本地密码校验(UserManager.authenticate)
│ 成功 → 签发 JWT,结束
│
└─ 2. LDAP 已启用?否 → 401 AUTH_FAILED
│
├─ 2.1 用服务账号 bind(bind_dn + 绑定密码;bind_dn 为空则匿名)
├─ 2.2 以 user_filter 检索({username} 替换为转义后的输入)→ 用户 DN
├─ 2.3 用「该用户 DN + 用户输入的密码」二次 bind ← 只 bind 检索结果,绝不 bind 用户输入值
├─ 2.4 读取属性(邮箱 / 显示名 / 所属组)
├─ 2.5 已有身份关联 → 更新资料(邮箱、显示名)后返回
│ 无关联 且 auto_provision → 建号 + 按组定角色 + 建立身份关联
│ 无关联 且 auto_provision=false → 403 LDAP_USER_NOT_PROVISIONED
└─ 3. 签发 JWT(与本地登录完全同构)
所有网络与 bind 均为阻塞调用,通过 run_in_executor 执行,不阻塞事件循环。
2.2 数据落库
| 数据 | 位置 |
|---|---|
| 目录配置 | sso_providers 行,kind='ldap';enabled / display_name 为列,其余在 extra JSON |
| 绑定密码 | 同行的 client_secret_enc(Fernet 加密,密钥在 secrets 表的 sso_fernet) |
| 身份关联 | user_sso_identities(user_id, provider_id, subject),subject = 用户 DN |
| 新账号 | users 行,password_hash IS NULL(目录账号没有本地密码) |
目录账号的
password_hash为NULL,所以POST /api/auth/change-password会返回400 PASSWORD_NOT_SET(而不是误导性的「当前密码错误」)。
三、跑起来
以下流程在全新目录中逐条验证过,可直接复制执行。
3.0 前置条件
| 依赖 | 说明 |
|---|---|
| Python 3.12+ / uv | Octop 运行环境(仓库根目录 uv sync 一次) |
| Go 1.21+ | 仅用于构建本地 LDAP 开发服务器(glauth) |
ldapsearch(可选) |
OpenLDAP 客户端,用于手工验证目录;macOS 自带 |
Octop 侧依赖 ldap3,已加入 pyproject.toml,uv sync 后会安装。
3.1 启动本地 LDAP 服务器(glauth)
开发用目录选 glauth——Go 写的轻量 LDAP 服务,单二进制、配置文件驱动、不需要数据库或容器。
下面的示例把沙箱放在 ~/octop-ldap-dev/(任意目录都行,不要放在 Octop 仓库内,避免污染工作区):
LDAP_DEV=~/octop-ldap-dev
mkdir -p "$LDAP_DEV" && cd "$LDAP_DEV"
# 1) 克隆
git clone --depth 1 https://github.com/glauth/glauth.git ldap-glauth
# 2) 构建 —— 必须 GOWORK=off:上游是 Go workspace,会拒绝 -mod=mod
cd ldap-glauth/v2
GOWORK=off go build -o "$LDAP_DEV/glauth" .
新建 ~/octop-ldap-dev/glauth.cfg(下方为完整内容,测试账号可自行增删):
debug = false
[ldap]
enabled = true
listen = "127.0.0.1:3893"
# Octop 拒绝「启用明文 ldap:// 且未开 StartTLS」的配置,所以本地目录也开 StartTLS。
tls = true
tlsCertPath = "glauth.crt"
tlsKeyPath = "glauth.key"
[ldaps]
enabled = false
[backend]
datastore = "config"
baseDN = "dc=example,dc=org"
nameformat = "uid"
groupformat = "cn"
[behaviors]
LimitFailedBinds = true
NumberOfFailedBinds = 10
PeriodOfFailedBinds = 10
BlockFailedBindsFor = 30
# 服务账号:仅用于检索
[[users]]
name = "svc-octop"
uidnumber = 6001
primarygroup = 5501
passsha256 = "ec9cea51278ff8572536540a2458a016872a2270b5876ec7ece0b31c885fdfde" # bindpw
[[users.capabilities]]
action = "search"
object = "*"
[[users]]
name = "alice"
givenname = "Alice"
sn = "Anderson"
mail = "alice@example.org"
uidnumber = 6002
primarygroup = 5502
passsha256 = "6624974ea2baffac164422e4490376c1c31313cd97724ae8ce62fb3f0a0370f2" # alicepw
[[users.capabilities]]
action = "search"
object = "dc=example,dc=org"
[[users]]
name = "bob"
givenname = "Bob"
sn = "Brown"
mail = "bob@example.org"
uidnumber = 6003
primarygroup = 5503
passsha256 = "e8f318657ce39ec4edeecbbee28fd72dea2261d8a6b2155ce4977393e0ea721b" # bobpw
[[users.capabilities]]
action = "search"
object = "dc=example,dc=org"
[[users]]
name = "carol"
givenname = "Carol"
sn = "Clark"
mail = "carol@example.org"
uidnumber = 6004
primarygroup = 5501
passsha256 = "d06dc93720809b81d6e0019579108a5745306ad6d37d976ddd6e66a1b2364758" # carolpw
[[users.capabilities]]
action = "search"
object = "dc=example,dc=org"
[[groups]]
name = "users"
gidnumber = 5501
[[groups]]
name = "admin"
gidnumber = 5502
[[groups]]
name = "engineering"
gidnumber = 5503
passsha256 是明文密码的 SHA-256 小写十六进制:
printf '%s' 'mypassword' | shasum -a 256 | cut -d' ' -f1 # macOS
printf '%s' 'mypassword' | sha256sum | cut -d' ' -f1 # Linux
先生成自签证书(glauth 按相对路径解析,故放在配置同目录):
cd ~/octop-ldap-dev
openssl req -x509 -newkey rsa:2048 -sha256 -days 3650 -nodes \
-keyout glauth.key -out glauth.crt \
-subj "/CN=127.0.0.1" \
-addext "subjectAltName=IP:127.0.0.1,DNS:localhost"
chmod 600 glauth.key
启动并自检(-ZZ 表示强制 StartTLS):
~/octop-ldap-dev/glauth -c ~/octop-ldap-dev/glauth.cfg
# 另开一个终端:以服务账号检索
ldapsearch -LLL -x -H ldap://127.0.0.1:3893 \
-D "uid=svc-octop,cn=users,dc=example,dc=org" -w bindpw \
-b "dc=example,dc=org" "(uid=alice)" uid mail memberOf
预期输出(关键:memberOf 决定管理员角色):
dn: uid=alice,cn=admin,ou=users,dc=example,dc=org
uid: alice
mail: alice@example.org
memberOf: cn=admin,ou=groups,dc=example,dc=org
3.2 启动 Octop(独立 HOME,避免污染现有实例)
用独立 HOME + OCTOP_HOME 起一个一次性实例,数据库与向导密码都落在临时目录:
cd <repo 根目录>
RUNTIME=/tmp/octop-ldap-dev
mkdir -p "$RUNTIME/home"
HOME="$RUNTIME/home" \
OCTOP_HOME="$RUNTIME/home/.octop" \
uv run octop run --host 127.0.0.1 --port 8799
首次启动会打印一次性的设置向导密码,同时写入 $RUNTIME/home/octop-login.txt:
╔══════════════════════════════════════════════════════════╗
║ Octop first-run wizard password (one-time use): ║
║ xxxxxxxxxxxxxxxxxxxx ║
║ File: ~/octop-login.txt ║
╚══════════════════════════════════════════════════════════╝
3.3 完成初始化向导
方式 A:浏览器向导(最简单) — 打开 http://127.0.0.1:8799,粘贴上面的向导密码,按提示选数据库(SQLite 即可)、创建管理员账号、完成。
方式 B:脚本化(可复现,下述命令均已验证)
API=http://127.0.0.1:8799/api
RUNTIME=/tmp/octop-ldap-dev
PW=$(head -1 "$RUNTIME/home/octop-login.txt")
# 1) 校验向导密码 → 拿到一次性 wizard_token
TOK=$(curl -sS -X POST "$API/setup/verify-password" \
-H 'Content-Type: application/json' -d "{\"password\":\"$PW\"}" \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["wizard_token"])')
# 2) 绑定控制面数据库(SQLite)
curl -sS -X POST "$API/setup/database" -H 'Content-Type: application/json' \
-H "Authorization: Bearer $TOK" -d '{"driver":"sqlite"}'
# → {"ok":true,"driver":"sqlite"}
# 3) 创建初始管理员(密码需满足强度策略:≥8 位且含字母与数字)
curl -sS -X POST "$API/setup/initial-admin" -H 'Content-Type: application/json' \
-H "Authorization: Bearer $TOK" \
-d '{"username":"admin","password":"TestPass12"}'
# 4) 结束向导
curl -sS -X POST "$API/setup/finish" -H 'Content-Type: application/json' \
-H "Authorization: Bearer $TOK" -d '{"provider_draft":null}'
# → {"ok":true}
3.4 配置 LDAP
方式 A:浏览器 — 用 admin 登录后进入 管理 → 用户 → LDAP 页,按 3.5 的表格填写,点「保存」再点「测试连接」。
方式 B:API
API=http://127.0.0.1:8799/api
AT=$(curl -sS -X POST "$API/auth/login" -H 'Content-Type: application/json' \
-d '{"username":"admin","password":"TestPass12"}' \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])')
curl -sS -X PUT "$API/auth/ldap/config" -H 'Content-Type: application/json' \
-H "Authorization: Bearer $AT" -d '{
"enabled": true,
"display_name": "Corp Directory",
"server_url": "ldap://127.0.0.1:3893",
"bind_dn": "uid=svc-octop,cn=users,dc=example,dc=org",
"bind_password": "bindpw",
"user_base_dn": "dc=example,dc=org",
"admin_groups": "admin",
"auto_provision": true
}'
# 测试连通性(服务账号 bind + 探测 user_base_dn)
curl -sS -X POST "$API/auth/ldap/config/test" -H "Authorization: Bearer $AT"
# → {"ok":true,"detail":"已连接 LDAP 目录服务 (ldap://127.0.0.1:3893)"}
# 登录页可读的公开状态
curl -sS "$API/auth/ldap/status"
# → {"enabled":true,"display_name":"Corp Directory"}
3.5 本地目录对应的配置值
| 表单/API 字段 | 值 | 说明 |
|---|---|---|
服务器地址 server_url |
ldap://127.0.0.1:3893 |
|
StartTLS start_tls |
开 | 本地目录自签证书,必须开启(否则启用会被拒) |
校验 TLS 证书 verify_tls |
关 | 仅因测试用自签证书;生产保持开启 |
绑定 DN bind_dn |
uid=svc-octop,cn=users,dc=example,dc=org |
留空则匿名检索 |
绑定密码 bind_password |
bindpw |
只写;省略则保留已存值 |
用户基准 DN user_base_dn |
dc=example,dc=org |
|
用户过滤器 user_filter |
(uid={username}) |
必须含字面量 {username} |
用户名字段 username_attribute |
uid |
|
邮箱字段 email_attribute |
mail |
|
显示名称字段 display_name_attribute |
givenName |
见下方提示 |
所属组字段 group_attribute |
memberOf |
|
管理员组 admin_groups |
admin |
逗号分隔,可填组 CN 或完整 DN |
首次登录自动创建 auto_provision |
开 |
提示:glauth 不把
cn作为用户可检索属性,因此本地目录用display_name_attribute = "cn"取不到显示名(givenName可以)。真实 OpenLDAP / AD 正常暴露cn。
3.6 用目录账号登录
浏览器:退出当前登录 → 在登录页直接输入目录账号(会看到「使用 Corp Directory 账号登录」提示行)→ 拖过验证码 → 登录。
命令行:
API=http://127.0.0.1:8799/api
for u in alice:alicepw bob:bobpw carol:carolpw; do
n=${u%%:*}; p=${u##*:}
curl -sS -X POST "$API/auth/login" -H 'Content-Type: application/json' \
-d "{\"username\":\"$n\",\"password\":\"$p\"}" \
| python3 -c "import sys,json;d=json.load(sys.stdin);print('$n ->', d['user']['role'])"
done
预期结果:
| 账号 | 密码 | 所属组 | Octop 角色 |
|---|---|---|---|
alice |
alicepw |
admin |
admin |
bob |
bobpw |
engineering |
user |
carol |
carolpw |
users |
user |
alice@example.org |
alicepw |
— | 邮箱也能登录(默认过滤器含 mail) |
密码错误返回 401 AUTH_FAILED;目录宕机返回 502 LDAP_UNAVAILABLE。
四、配置项参考
| 字段 | 默认值 | 说明 |
|---|---|---|
enabled |
false |
关闭时登录不再回退目录;可保存未填完的草稿 |
display_name |
"" |
登录页提示行里显示的名称 |
server_url |
"" |
ldap:// 或 ldaps://;端口缺省 389 / 636 |
start_tls |
false |
对明文 ldap:// 连接做 StartTLS 升级;不可与 ldaps:// 同用。启用时若用 ldap:// 且未开 StartTLS 会被拒绝,避免口令明文传输 |
verify_tls |
true |
校验服务端证书;仅自签测试服务器才关。关闭时会持续告警 |
bind_dn |
"" |
服务账号 DN;留空 = 匿名检索 |
bind_password |
— | 只写字段,从不回传;省略则保留已存值 |
user_base_dn |
"" |
用户检索基准 DN,必填 |
user_filter |
(|(uid={username})(sAMAccountName={username})(mail={username})) |
必须含字面量 {username},会被转义后替换 |
username_attribute |
uid |
用于确定 Octop 用户名 |
email_attribute |
mail |
回写到账号邮箱 |
display_name_attribute |
cn |
回写到显示名 |
subject_attribute |
""(留空) |
身份主键:目录侧稳定不变的属性(OpenLDAP entryUUID、AD objectGUID)。默认留空=显式选择用条目 DN,此时条目改名/搬家会再开一个账号。点「测试连接」会回报该目录实际提供的属性(detected_subject_attribute),照它填上即可 |
group_attribute |
memberOf |
多值属性,用于组判定;开启 group_search 后不生效 |
group_search |
false |
改为「检索组条目」判定成员,适用于没有 memberOf overlay 的 OpenLDAP |
group_search_base |
"" |
组所在基准 DN;留空沿用 user_base_dn |
group_member_attribute |
member |
组条目上的成员属性。member / uniqueMember 存成员 DN;memberUid(经典 OpenLDAP posixGroup)存用户名——两种取值都会匹配,无需额外配置 |
admin_groups |
"" |
逗号分隔;可填组 CN(admin)或完整 DN(cn=ops,ou=groups,dc=x)。仅首次开通账号时生效 |
allowed_groups |
"" |
逗号分隔;非空时仅这些组的成员可登录,其他目录账号被拒绝且不会建号 |
auto_provision |
false |
默认关闭:目录账号需先有 Octop 账号,或由管理员显式开启自动开通 |
timeout_seconds |
10 |
连接/接收超时,范围 1–60 |
保存已启用(enabled=true)的配置会做完整校验;保存草稿(enabled=false)允许字段不全,便于分次填写。
五、登录行为细则
| 场景 | 行为 |
|---|---|
| 用户名匹配 | 按 user_filter 检索;优先与 username_attribute 完全相等的条目。无精确命中但只有唯一一条时也接受;命中多条且无精确匹配则拒绝(避免把口令验到别人的条目上) |
| 过滤器注入 | 输入值经 escape_filter_chars 转义,*、( 等不会扩大检索范围 |
| 组名比较 | 大小写不敏感;admin 与 cn=admin,ou=groups,dc=x 视为同一组 |
| 身份关联 | 以 subject_attribute(默认 entryUUID)为键,条目改名/换 OU 不会重复建号 |
| 首次登录 | 建号:用户名取目录值(冲突自动加 _2 后缀)。按 admin_groups 选用 admin 或预设 user 角色模板,权限与策略都从模板拷贝 |
| 再次登录 | 更新邮箱与显示名;不覆盖已有角色、权限与策略 |
| 邮箱冲突 | 目录邮箱若已被其他 Octop 账号占用,则该账号邮箱留空,不报错 |
| 账号被停用 | 403 USER_DISABLED |
未开通且 auto_provision=false |
403 LDAP_USER_NOT_PROVISIONED |
不在 allowed_groups 内 |
403 LDAP_GROUP_NOT_ALLOWED(不建号) |
| 任何有本地密码的账号打错密码 | 在本地就结束:401 AUTH_FAILED,绝不把该口令发给目录(不泄漏本地口令、不占用目录的失败计数)。即便该账号同时也绑定了目录也一样 |
| 无本地密码的账号(目录开通的,或 Octop 里没有这个用户)打错密码 | 去目录 bind,失败返回 401,并按「用户名 + 客户端地址」计入目录登录限流 |
| 本地密码与目录密码都想用 | 请选择:设置本地密码后,以本地密码为准;不设本地密码则走目录 |
| 目录账号被改名 | 下次登录即刷新显示名(无需重启 Octop)。但若 subject_attribute 留空(按 DN 关联),改名会被当作新用户另开账号 |
| 目录不可达 / 服务账号密码轮换失效 | 502 LDAP_UNAVAILABLE(本地有密码的账号仍报 401) |
| 修改密码 | 目录账号无本地密码,400 PASSWORD_NOT_SET |
| 账号被删除 | 下次登录若 auto_provision 开启会重新建号 |
生产注意
- 角色只在首次开通时判定。之后把用户加入
admin_groups不会提权,移出也不会降权——这与「目录是权限源」的常见预期相反。需要变更请在 Octop 用户列表里改角色。- 组属性来源:默认读用户条目上的
memberOf(AD 天然提供)。没有memberofoverlay 的 OpenLDAP 请开启group_search反查组成员:member/uniqueMember(存 DN)与memberUid(存用户名)都已支持。嵌套组(组套组)不支持,仅比较直接成员。- 同名组风险:
admin_groups写短名(admin)时,任何 OU 下的同名组都算命中;写完整 DN 则按整条 DN 比较,更安全。- 身份主键:默认留空=用条目 DN,改名或换 OU 会新建账号。请点「测试连接」,它会回报该目录能提供的属性(
entryUUID或objectGUID),填进subject_attribute后新开通的账号即按该键关联。注意:切换主键会导致既有账号关联失配,需要管理员重新关联。- 组白名单:生产环境建议设置
allowed_groups,避免目录中任何可被过滤到的账号都能建号。
- 本地密码优先且终局:给一个目录账号设置本地密码后,该密码即成为其唯一登录口令——目录密码不再生效(但也不会被外发)。若要保留目录密码,请不要为该账号设置本地密码。
- 登录限流按「用户名 + 客户端地址」计数。地址取自直连对端;仅当对端是环回地址(本机/同 Pod 上的反向代理)时才采用
X-Forwarded-For,且取其中最右侧一跳(可信代理append 的那条),因此无法用伪造头重置额度。所以部署在反向代理后请确保代理与 Octop 同机,否则所有用户会共享同一个来源地址。
六、HTTP API
| 方法 | 路径 | 权限 | 说明 |
|---|---|---|---|
GET |
/api/auth/ldap/status |
公开 | {enabled, display_name},供登录页提示 |
GET |
/api/auth/ldap/config |
sso |
目录配置;bind_password 不返回,用 has_bind_password 表示是否已设 |
PUT |
/api/auth/ldap/config |
sso |
新增/更新配置;bind_password 只写 |
POST |
/api/auth/ldap/config/test |
sso |
服务账号 bind + 探测 user_base_dn → {ok, detail} |
登录本身复用 POST /api/auth/login(公开),无需新端点。错误码:
| 错误码 | HTTP | 含义 |
|---|---|---|
LDAP_BAD_REQUEST |
400 | 配置非法(URL 协议、缺 {username}、属性名、超时范围等) |
LDAP_UNAVAILABLE |
502 | 目录不可达 / 服务账号被拒 / 检索失败 |
LDAP_USER_NOT_PROVISIONED |
403 | 目录账号未开通且未启用自动创建 |
PASSWORD_NOT_SET |
400 | 试图为目录账号修改本地密码 |
AUTH_FAILED |
401 | 凭据无效 |
七、测试
7.1 单元测试(无需目录)
uv run pytest tests/unit/auth/test_ldap_config.py tests/unit/auth/test_ldap_client.py -q
# → 51 passed
test_ldap_config.py(27 项,含 11 个参数化校验用例):URL 解析与默认端口、校验规则(协议/主机/StartTLS 冲突/属性名/超时范围/{username}必填)、草稿保存、extra往返、组名逗号切分、组名归一化。test_ldap_client.py(24 项):登录成功/密码错误/未知用户、服务账号 DN 不可冒用、过滤器转义、邮箱登录、同名精确匹配、目录不可达与服务账号被拒的分类、匿名 bind、StartTLS 失败、属性缺失与类型转换。
7.2 集成测试(走真实 HTTP,仅替换 socket 边界)
uv run pytest tests/integration/test_auth_ldap.py -q
19 项,覆盖:开通与角色映射、非管理员组、改组不提权、密码错误、服务账号 DN 冒用、本地密码优先、auto_provision 开关、目录宕机分类、服务账号密码轮换、停用账号、目录账号改密、公开状态、权限校验、绑定密码不外泄、省略密码保留原值、非法配置本地化报错、连通性测试、未配置时完全跳过目录。
tests/support/ldap_fake.py 只替换 ldap3.Connection(忠实复刻它的 open() 返回 None、失败抛 LDAPSocketOpenError 等语义),其上层——客户端、服务、路由——全部是生产代码。
7.3 Live 测试(对真实目录)
需要 3.1 的目录在跑:
OCTOP_LDAP_TEST_URL=ldap://127.0.0.1:3893 \
OCTOP_LDAP_TEST_BIND_DN='uid=svc-octop,cn=users,dc=example,dc=org' \
OCTOP_LDAP_TEST_BIND_PASSWORD=bindpw \
OCTOP_LDAP_TEST_BASE_DN='dc=example,dc=org' \
OCTOP_LDAP_TEST_ADMIN_GROUP=admin \
OCTOP_LDAP_TEST_USER=alice \
OCTOP_LDAP_TEST_PASSWORD=alicepw \
OCTOP_LDAP_TEST_EXPECTED_ROLE=admin \
uv run pytest tests/live/test_ldap_live.py -m live -v
它经由真实 HTTP API 完成配置 → 连通性测试 → 目录登录 → has_password=false 校验 → 错误密码拒绝。换 OCTOP_LDAP_TEST_USER / _PASSWORD / _EXPECTED_ROLE 为 bob/bobpw/user 或 carol/carolpw/user 即可验证非管理员映射。缺任一环境变量则自动跳过,不会让 CI 变红。
7.4 浏览器验证要点
- 管理 → 用户 → LDAP 页应回填已保存配置,「测试连接」显示
已连接 LDAP 目录服务。 - 退出登录后,登录页密码框下出现「使用 Corp Directory 账号登录」提示(名称取自
display_name)。 - 用
bob/bobpw登录后进入/chat,左侧导航无「管理」栏目(角色为user)。
7.5 全量门禁
make all # format-all + lint + typecheck + test(含 dashboard 构建)
cd dashboard && npx tsc -b
两者都必须通过。
八、故障排查
| 现象 | 错误码 / 状态 | 原因与处理 |
|---|---|---|
| 保存配置报 400 | LDAP_BAD_REQUEST |
看 detail:URL 缺 ldap:///ldaps://、user_filter 缺 {username}、属性名非法、超时不在 1–60 |
| 「测试连接」提示服务账号被拒 | bind_failed |
bind_dn 或 bind_password 错;DN 写法需与目录一致(cn= / uid= / ou=) |
| 「测试连接」提示检索被拒 | search_failed |
user_base_dn 越界,或服务账号没有该子树的检索权限(glauth 需 [[users.capabilities]] action="search") |
| 登录报 502 | LDAP_UNAVAILABLE |
服务地址/端口不可达、服务账号密码已轮换、防火墙或证书校验失败(自签证书测试时关 verify_tls) |
| 登录报 403 未开通 | LDAP_USER_NOT_PROVISIONED |
开 auto_provision,或先在 Octop 内建同名账号 |
| 登录报 401 | AUTH_FAILED |
密码错、用户名在 user_filter 下检索不到(检查 username_attribute 与过滤器)、或解析出多条且无精确匹配 |
| 登录成功但不是管理员 | — | 用户不在 admin_groups 内;确认目录真的返回 memberOf(OpenLDAP 需 memberof overlay);改组不会自动提权,需在 Octop 内改角色 |
| 显示名为空 | — | 目录未暴露该属性(glauth 的 cn 即如此,改用 givenName) |
| 改密码报 400 | PASSWORD_NOT_SET |
目录账号本就没有本地密码,属预期 |
| 绑定密码丢了 | — | PUT 时省略 bind_password 会保留原值;若改了 Fernet 密钥(secrets.sso_fernet)则需重新填写 |
九、生产环境准备清单
- 服务账号:建一个只读检索账号,授予
user_base_dn子树读权限;不要用域管账号。 - 传输安全:优先
ldaps://,其次ldap://+ StartTLS;证书可信时保持verify_tls开启。 - 过滤器:按目录类型调整,AD 常用
(&(objectClass=user)(sAMAccountName={username}));OpenLDAP 常用(&(objectClass=inetOrgPerson)(uid={username}))。 - 组映射:确认目录返回
memberOf(OpenLDAP 需 overlay),或接受管理员手动授予。 - 属性映射:
display_name_attribute建议displayName(AD)或cn/displayName(OpenLDAP)。 - 首次上线:可用
auto_provision=true便于导入;稳定后可关掉,改为管理员先在 Octop 内建号。
十、相关文件
| 路径 | 作用 |
|---|---|
src/octop/infra/auth/ldap/config.py |
配置模型、校验、extra 序列化与组名解析 |
src/octop/infra/auth/ldap/client.py |
ldap3 客户端:服务 bind、检索、用户 bind 验证 |
src/octop/infra/auth/ldap/service.py |
配置读写、开通账号、角色映射 |
src/octop/api/routers/auth_ldap.py |
4 个 HTTP 端点 |
src/octop/api/routers/auth.py |
登录时回退目录(_authenticate_ldap) |
dashboard/src/pages/Admin/Users/LdapPanel.tsx |
管理端配置表单 |
tests/support/ldap_fake.py |
测试用假目录(替换 ldap3.Connection) |
tests/live/test_ldap_live.py |
对真实目录的端到端测试 |