LDAP单点登录:十人小团队的最小方案

一、从一堆各自为政的密码说起

一个跑了六七年的小站,后台密码通常长这样: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,就已经比大多数自建后台的登录逻辑安全了。

Last modification:October 29th, 2026 at 07:24 pm

Leave a Comment