把一台机器上的登录网关扩成覆盖多个子域的单点登录,看起来只是「让另外两台也认同一个密钥」。密钥部分确实简单——同一份代码、同一个签名密钥,各站独立验签就行。
难的是另一半:密钥是可以复制的,状态不是。这篇记录三台机器共用一套网关时踩到的问题,它们几乎全部出在「我以为共享了、实际没有」或者「我以为没共享、实际有」的地方。
先说这套东西长什么样
三台机器各跑一份同样的网关程序,挡在各自的应用前面。用户在任意一台登录一次,浏览器拿到一个作用域为父域的 cookie,之后访问其它子域时浏览器会自动带上它,各站独立验签放行。
cookie = b64u(payload) . b64u(HMAC-SHA256(payload, cookie_key))
payload = {"exp": 过期时间, "u": 用户名, "v": 版本}
三台共用同一个 cookie_key,所以任意一台签发的 cookie,另外两台都能验通过。这部分没有任何精妙之处,也确实一次就跑通了。
防重放是站内有效、跨站无效
第一个问题在设计里,跑通之后才意识到。
动态口令的防重放依赖一个已用计数器:记下最近一次用过的时间步,要求后续口令严格递增。三台机器共享了口令种子——这是必须的,否则同一个验证器 App 生成的码只能在一台上用——但各自维护自己的状态文件。
后果是:同一个 30 秒窗口里的口令,在 A 站用过之后,在 B 站还能再用一次,在 C 站再一次。防重放变成了站内有效、跨站无效。
这不是实现错误,是「共享了密钥但没共享状态」的直接结果。要真正做到全局一次性,得让三台共用一份计数器状态,那就需要一个共享存储和跨机的加锁——为了一个 30 秒窗口内最多两次的重放窗口,引入一个新的单点故障,不划算。
所以这里的正确做法是把它当成已知的、被接受的性质写下来,而不是留着不说。真实的攻击面是:攻击者需要在 30 秒内拿到一个刚被用过的有效口令,并且还要有正确的密码。已知并接受,和不知道,是两回事。
密钥有第二份副本,而轮换脚本不知道
这个坑最难查,因为它的症状指向的位置是错的。
其中一台机器上的应用需要在网关之外自己再校验一次 cookie(它有自己的会话逻辑),于是它的配置文件里也存了一份 cookie_key。轮换密钥时,同步脚本更新了三台网关,没更新这第二份副本。
症状是:nginx 的鉴权子请求通过了(网关用新密钥验签成功),请求转到应用,应用用旧密钥验签失败,返回 401。而网关日志里什么都看不到——它这一侧完全正常。
排查时很容易往网关方向找,因为「401」这个信号和「鉴权失败」的直觉是一致的。真正有用的判据是:网关的访问日志有没有记录这次鉴权成功。如果有,问题就在网关之后。
可推广的一条:任何密钥都要维护一份「谁持有它」的清单,轮换脚本按清单走,而不是按记忆走。一份密钥只要有第二个消费者,就一定会在某次轮换时被漏掉——区别只在于是这次还是下次。
部署脚本的保留规则太窄,等于把登录关了
同一类问题的另一种形式。
那台机器的部署脚本会重写应用的环境变量文件,为了不冲掉运行时配置,它有一条保留规则:匹配某个前缀的变量原样保留。原本的正则是 ^APP_PANEL_。
后来加进去的 SSO 密钥变量叫 APP_HB_COOKIE_KEY——不匹配那条规则。于是下一次部署把它抹掉了,登录直接失效。
# 改前:只保留 PANEL 前缀,新加的变量会被部署抹掉
grep -E '^APP_PANEL_' "$ENV" > "$KEEP"
# 改后
grep -E '^APP_(PANEL|HB)_' "$ENV" > "$KEEP"
这个坑真实发生过一次。它的教训不是「正则写窄了」,而是白名单式的保留规则会随着新增配置静默失效——新变量不在白名单里不会报错,只会被删掉。
更稳的做法是反过来:默认全部保留,只显式覆盖部署需要更新的那几项。这样新增配置的默认行为是「留下」,而不是「消失」。
清掉应用自己的会话,不等于登出
接入 SSO 之后,应用原有的登出按钮变成了一个陷阱。
它清的是应用自己签发的会话凭证。但下一个请求进来时,网关看到 SSO cookie 仍然有效,照常放行——用户刷新一下就又是登录状态。界面上那个「退出」按钮看起来一切正常,只是不起作用。
正确的登出必须打到网关的登出接口,清掉那个作用域为父域的 cookie。而且因为 cookie 是全域的,这一下会把三个站一起登出——这是对的,单点登录的对偶就是单点登出。
这类问题的共性是:接入统一鉴权之后,应用里所有和身份相关的旧逻辑都要重新审一遍。登录、登出、会话过期、权限变更,每一条原来由应用自己负责的,现在的真相来源都变了。不审的话,它们不会报错,只会在某个具体场景下表现得不对。
比对凭证要比存储的材料,不要比生成的结果
还有一个纯粹是测量方法的坑,值得单独说,因为它会产生假故障。
同步完凭证之后要验证三台是否一致。最直觉的做法是让每台各生成一个当前口令,比对是否相同:
# 会产生假不一致
for h in host-a host-b host-c; do
ssh "$h" 'gate.py now'
done
问题在于口令是按 30 秒的时间步生成的。三次 SSH 依次执行,中间有几百毫秒到几秒的间隔,只要恰好跨过一个 30 秒边界,后面那台就会给出下一个窗口的码。看起来是「凭证不一致」,实际上完全一致。
这个假故障很容易让人去重跑同步、去查网络,浪费的时间远超它本身。
正确做法是比对存储的材料本身——种子和密钥的哈希,它们不随时间变化:
for h in host-a host-b host-c; do
printf '%-14s ' "$h"
ssh "$h" 'sudo sha256sum /etc/gate/secret.json | cut -c1-16'
done
这条可以推广:验证两份配置是否一致时,比对配置本身,不要比对由它派生出来的、带时间或随机性的产物。派生结果的不一致既可能来自配置不同,也可能来自派生过程本身,而你无法区分。
顺带一提,这三台之间没有全连通的直连路径——云上那台到家里两台的入站是不通的。所以同步脚本必须从一台能同时到达三方的机器发起,充当中转。这个约束不影响正确性,但会影响你把脚本放在哪台机器上跑,值得在设计时就确认,而不是等脚本写完才发现某一跳不通。
代理鉴权的豁免路径,比鉴权路径更需要小心
三台机器里有两台的应用支持代理鉴权模式——应用自己不再校验凭证,而是信任反向代理传来的一个身份请求头。这个模式让 SSO 落地变得很干净,但它把「谁是合法用户」这个判断完全外包给了那个请求头。
于是每一条豁免鉴权的路径都变成了潜在的后门。公开分享链接、健康检查、给程序调用的接口——这些出于各自的理由绕过了网关,如果它们没有显式清空那个身份头,任何人只要自己带上它就能以任意身份进入。
location / {
auth_request /__auth;
auth_request_set $user $upstream_http_x_auth_user;
proxy_set_header X-Auth-User $user; # 只能来自网关的响应
}
location ^~ /share/ {
proxy_set_header X-Auth-User ""; # 豁免路径必须显式清空
}
这里有个具体的判断标准:凡是写了豁免的地方,都要问一句「这条路径会不会把鉴权路径依赖的某个输入原样透传进去」。请求头、cookie、查询参数都算。
三台机器上豁免路径的清单各不相同——有的豁免了公开下载,有的豁免了 Bearer 鉴权的程序接口。清单不同意味着不能靠「照抄另一台的配置」来保证正确,每台都要单独过一遍。这也是多机部署比单机麻烦的地方:配置看起来一样,例外各不相同。
同步是单向的,因为链路本身不对称
凭证同步脚本的形状受一个物理约束支配:这三台之间的可达性不是对称的。
云上那台有公网地址,家里两台没有稳定的入站。所以云 → 家的方向连不通,家 → 云可以。这决定了同步只能是「拉」不能是「推」:由能到达云端的那一侧发起,取回材料再写进本地。
写脚本时容易假设「三台互相都能连」,因为在本机测试时,你面前这台确实能连所有机器。真实的拓扑是一个有向图,而不是全连通的。在设计任何多机流程之前,先把可达性矩阵实际测一遍——两两之间各测一次,不要从「我的笔记本能连它们」推断「它们之间能互连」。
for a in host-a host-b host-c; do
for b in host-a host-b host-c; do
[ "$a" = "$b" ] && continue
printf '%-10s -> %-10s ' "$a" "$b"
ssh -o ConnectTimeout=8 "$a" "nc -z -w 5 $b 22 && echo 通 || echo 不通"
done
done
这个矩阵还有一个用处:它决定了密钥轮换时哪台先改。如果先改了发起方而它暂时无法推给其余机器,中间会有一段各站互不认证的窗口。正确的顺序是先让所有验证方接受新旧两个密钥,全部更新完之后再撤掉旧的——用一次多余的部署,换掉那个窗口。
这套设计的既有代价
最后要写清楚一件不是 bug、但必须知道的事。
作用域为父域的 cookie,会被浏览器发送到每一个子域,包括那个跑 WordPress 的主站。HttpOnly 挡得住页面里的 JavaScript 读取它,但挡不住被入侵的服务端从请求头里直接读到。
也就是说,这套 SSO 把所有子域的安全性绑定到了其中最弱的那一个上。这不是加站点加出来的问题——从共用一个 cookie 域的那一刻起就成立了,多一个站点不会让它更严重,但也不会更轻。
能做的是把它写在设计文档里,并据此决定哪些服务不该放进这个域。真正敏感的东西应该用独立的域名和独立的凭证,而不是图省事挂进同一套 SSO。
这也是整篇的落点:单点登录省下的是用户的重复输入,代价是把多个系统的信任边界合并成了一个。合并之后,边界的强度由最弱的那一段决定——这个结论在接入之前就该被算进去。