一、从一堆各自为政的密码说起
一个跑了六七年的小站,后台密码通常长这样:WordPress 一套、Typecho 一套、监控面板一套、数据库一套、对象存储一套、CDN 控制台一套、Git 平台一套、论坛一套、邮箱一套。每套的规则都不一样——有的是「至少 8 位含大小写」,有的是「只允许数字加字母」,有的强制 90 天改一次。结果就是密码本上写满了 Admin2024!、Admin2025! 这种加个年份的变体,改密码这件事变成了「改完记不住、忘了走找回」。
这篇文章讲的是小团队、十来个人、几台服务器规模下的单点登录该怎么做。注意这个规模很重要:如果只有你一个人,装一套 Keycloak 会变成新的负担;如果有两百人,那确实应该上企业方案。十人上下是最尴尬也最普遍的区间,而 LDAP 加一层轻量网关恰好是这一区间性价比最高的选择。
二、LDAP 到底是什么,一句话版
LDAP 是一个读多写少的树状目录数据库,专门用来存「人和组织的事」:谁是谁、属于哪个组、密码是什么、什么时候不能再登录。它不是关系型数据库,别想着在上面跑复杂的 join 查询;它的强项是「按路径快速取出一条记录」。
dc=example,dc=com ← 根
├── ou=people ← 放人
│ ├── uid=zhangsan
│ ├── uid=lisi
│ └── uid=wangwu
└── ou=groups ← 放组
├── cn=admins (member: uid=zhangsan)
├── cn=editors (member: uid=lisi, uid=wangwu)
└── cn=readers
每条记录有若干「属性」(attribute),属性值受 schema 约束。人的条目一般用 inetOrgPerson 这个 objectClass,它能提供 uid、cn、sn、mail、userPassword。组有两种实现方式:groupOfNames(把成员 DN 列在组上)和 groupOfUniqueNames(防重复)。这一点在对接应用时很关键,因为不同的应用只认其中一种。
三、用 slapd 把目录跑起来
Debian 12 上装 slapd 加 ldap-utils 就够,不需要从源码编译。安装时会交互式问管理员密码,非交互环境用 debconf-set-selections 预置好再装。装完先确认 base DN 和根密码已经写进配置:
slapcat -n 0 | grep -E 'olcSuffix|olcRootDN'
# 期望看到 dc=example,dc=com 与 cn=admin,dc=example,dc=com
接下来是建组织单元和用户。用 ldapadd 一次性灌入比一条条手敲可靠,注意 dn 大小写和逗号后的空格不能乱:
# base.ldif
dn: ou=people,dc=example,dc=com
objectClass: organizationalUnit
ou: people
dn: ou=groups,dc=example,dc=com
objectClass: organizationalUnit
ou: groups
dn: uid=zhangsan,ou=people,dc=example,dc=com
objectClass: inetOrgPerson
objectClass: posixAccount
objectClass: shadowAccount
uid: zhangsan
cn: Zhang San
sn: Zhang
mail: zhangsan@example.com
uidNumber: 10001
gidNumber: 10001
homeDirectory: /home/zhangsan
loginShell: /bin/bash
ldapadd -x -D cn=admin,dc=example,dc=com -W -f base.ldif
密码不要用明文属性写进 ldif。userPassword 存的是哈希,用 slappasswd 生成后填进去:
HASH=$(slappasswd -h '{SSHA}' -s 'InitialPassw0rd')
# 结果形如 {SSHA}xQ9k...,整串写进 userPassword 属性
顺带一个真实踩过的坑:slappasswd 默认生成的是 {SSHA},而有些应用(尤其老 Java 系统)只认明文比对或者 {MD5},遇到这种情况不要改 slapd 的默认,而是在那条记录上单独指定 userPassword: {MD5}...。全局改成弱哈希是一件几乎不可逆的事。
四、先想清楚:认证和授权是两件事
对接任何一个应用之前,先问自己两个问题,答案会决定后面所有配置:
- 认证(Authentication):这个人的密码对不对?这一步永远是「应用把用户输入的密码发给 LDAP 做一次 bind」。
- 授权(Authorization):这个人能不能进这个应用、进来之后是什么角色?这一步是「应用自己去 LDAP 查这个人的组成员关系」。
很多自建系统只做了第一件事——密码对了就放进去。于是「离职的人还在组里」这种问题就查不出来。正确姿势是:登录时双检,密码正确并且 属于某个指定的组才算通过。
五、最常见的四个错误配置,以及它们的表征
错误一:用管理员账号做服务绑定。 为了省事,应用配置里填 cn=admin,dc=example,dc=com 加管理员密码。一旦某天应用被拿下,攻击者拿到的是整个目录的写权限。正确做法是建一个只读的「服务账号」:
dn: cn=svc_app,dc=example,dc=com
objectClass: simpleSecurityObject
objectClass: organizationalRole
cn: svc_app
userPassword: {SSHA}...
然后在 slapd 的 ACL 里只给它读 ou=people 和 ou=groups,绝不给 userPassword 的可读权限——注意这点:即使读不到密码照样能完成 bind 验证,因为 bind 是服务端内部比较。
错误二:bind DN 拼接用了相对名。 很多代码写成 "uid=" + username + ",ou=people",少了 ,dc=example,dc=com。表现是「永远认证失败」,而日志里只看到 Invalid credentials。这不是密码错,是 DN 不存在。
错误三:搜索域写成了根。 有人图省事把 base 设成 dc=example,dc=com,搜索范围 sub,于是任何一次用户名查询都会扫描整棵树。人少时看不出来,人多时每次登录要 300 毫秒——用户的感觉就是「后台登录特别慢」。
错误四:用了 uid 却让用户输邮箱。 用户以为自己可以用 zhangsan@example.com 登录,而 bind DN 是按 uid= 拼的,自然失败。这类问题的标准解法是在前面加一步「按 mail 或 uid 查一次真实 DN,再拿真实 DN 去 bind」:
ldapsearch -x -H ldap://127.0.0.1:389 -D cn=svc_app,dc=example,dc=com -W \
-b ou=people,dc=example,dc=com '(|(uid=zhangsan)(mail=zhangsan@example.com))' dn uid mail
六、ACL:把「能看什么」写死
slapd 的权限通过 olcAccess 控制,规则是从上往下匹配,第一条命中即生效。这个「第一条命中」的语义害过不少人:把「允许所有人读 mail」写在「拒绝匿名读」上面,规则就永远不生效。推荐的整体结构:
olcAccess: {0}to attrs=userPassword
by self write
by anonymous auth
by * none
olcAccess: {1}to attrs=shadowLastChange
by self write
by * read
olcAccess: {2}to dn.subtree="ou=people,dc=example,dc=com"
by cn=svc_app,dc=example,dc=com read
by cn=admin,dc=example,dc=com write
by self read
by * none
olcAccess: {3}to *
by self read
by users read
by * none
第一条最关键:by anonymous auth 让匿名连接可以做 bind(否则连密码校验都做不了),by * none 保证任何人(包括管理员)都不能把密码哈希读出来。管理员要重置密码就写新值,不需要读旧值。
改完必须做一次反向验证——用普通用户去查别人的 userPassword,应该返回 0 条结果而不是报错:
ldapsearch -x -H ldap://127.0.0.1 -D uid=zhangsan,ou=people,dc=example,dc=com -W \
-b ou=people,dc=example,dc=com '(uid=lisi)' userPassword
七、TLS:389 和 636 的选择
LDAP 默认 389 明文,密码和所有属性在网络上裸奔。三种做法:StartTLS(在 389 上协商升级)、LDAPS(636 端口全程 TLS)、以及本地 389 加外部隧道。小团队最容易落地的是服务端装证书 + 强制 StartTLS:
# 证书
olcTLSCertificateFile: /etc/ldap/certs/fullchain.pem
olcTLSCertificateKeyFile: /etc/ldap/certs/privkey.pem
olcTLSCACertificateFile: /etc/ldap/certs/chain.pem
# 强制:明文连接一律拒绝改密码
olcSecurity: tls=1
olcSecurity: tls=1 只影响「写操作」,读仍然是明文。想全禁就用 update_ssf 和 simple_bind 组合,或者干脆只监听 ldaps://。实际部署中,如果 LDAP 只在内网用、前面就是本机的应用,那么「只监听 127.0.0.1 的 389」是最省事的方案——不加密但也不出机器。一旦跨机房,就老老实实上 LDAPS。
客户端验证证书时最常见的坑是证书里的 CN/SAN 与服务地址不一致。用 ldapsearch -H ldaps://ldap.example.com 而证书只签了 ldap.internal,就会报 Can't contact LDAP server (-1) 这种毫无信息量的错。诊断方法:
openssl s_client -connect ldap.example.com:636 -servername ldap.example.com </dev/null 2>&1 \
| openssl x509 -noout -subject -ext subjectAltName
八、应用侧对接:一份最小可用的校验脚本
不管用什么语言,流程都是三步:服务绑定 → 搜索真实 DN → 用真实 DN 加用户密码再 bind。下面用 Python 的 ldap3 写一遍,这段代码可以直接当测试用:
from ldap3 import Server, Connection, ALL, SUBTREE, Tls
import ssl
server = Server("ldaps://ldap.example.com:636", use_ssl=True, get_info=ALL)
def check(user, password):
if not password:
return False # 空密码 = 匿名 bind,永远返回 True,必须先拦
svc = Connection(server, user="cn=svc_app,dc=example,dc=com",
password=SVC_PASS, auto_bind=True)
svc.search("ou=people,dc=example,dc=com", "(|(uid=%s)(mail=%s))" % (user, user),
attributes=["cn", "mail"], search_scope=SUBTREE, size_limit=2)
if len(svc.entries) != 1:
svc.unbind()
return False
dn = svc.entries[0].entry_dn
group = svc.search("cn=admins,ou=groups,dc=example,dc=com", "(member=%s)" % dn,
search_scope=SUBTREE)
is_admin = group
svc.unbind()
# 关键:新开连接用用户 DN bind,绝不能在服务账号连接上换身份
c = Connection(server, user=dn, password=password)
ok = c.bind()
c.unbind()
return ok and is_admin and dn
这里有个必须写进代码的细节:空密码必须显式拦截。LDAP 的「匿名 bind」在密码为空时是成功的,很多系统的「密码错也能登进去」漏洞就是这么来的。另一个细节是日志里不要打印 DN 以外的任何东西,尤其不要打印 password 变量。
九、灰度与回滚
单点登录这件事的最大风险不是技术,是「改完之后所有人都进不去」。所以上线顺序只能是:先建目录并导入账号(不改任何应用)→ 挑一个不重要的内部工具切换 → 观察一周 → 再切后台。回滚要能在五分钟内完成,也就是保留原有的本地密码作为兜底,而不是删掉。等新的登录路径稳定运行一个月,再考虑禁用本地密码。
另外,LDAP 里的「人」和现实里的「人」必须对得上。每月跑一次核对脚本,把 LDAP 的 uid 列表和 HR 或者老板手动维护的名单做 comm 对比,多出来的就是该停用的账号:
ldapsearch -x -LLL -H ldap://127.0.0.1 -D cn=svc_app,dc=example,dc=com -W \
-b ou=people,dc=example,dc=com '(objectClass=inetOrgPerson)' uid \
| awk '/^uid:/ {print $2}' | sort > /tmp/ldap_uid.txt
comm -23 /tmp/ldap_uid.txt /tmp/current_staff.txt # 只在 LDAP 里有的人
十、小结
十来个人的小团队做单点登录,重点不在「上一套多强的方案」,而在三件事:目录里的数据要准、绑定用的账号权限要小、切换的路要能退。LDAP 加 slapd 恰好能满足这三点,且没有任何按用户收费的东西。真正会出问题的从来不是 LDAP 本身,而是「用管理员账号当服务账号」「空密码没拦」「ACL 写反了顺序」这些细节。把上面的脚本和 ACL 抄过去改改 DN,就已经比大多数自建后台的登录逻辑安全了。
