摘要
老板反馈”我登录了后台,但官网首页还是游客,没法测试”。排查后发现这不是 bug,是架构决定的死结:登录态种在 A 域,官网在 B 域,浏览器永远不会把 A 域的 cookie 发给 B 域。于是做了一次账号体系切换——官网身份从国际站后台整体切到同机的 WordPress,两边用一枚 HMAC 签名 cookie 打通。切换过程里还挖出一个更严重的暗雷:计费服务的 systemd unit 开了 PrivateTmp,导致它看不到 MySQL 的 socket 文件,登录用户扣费链路从上线第一天起就是坏的——而此前所有验证都“通过”了。本文完整记录:跨域 cookie 的死结怎么解、HMAC 身份票怎么设计才不会被伪造、open_basedir 怎么让密钥文件读不到、以及“手工验证全绿、生产环境全坏”这个最阴的陷阱。所有内网地址、端口、路径、密钥均已脱敏。

一、症状:登录成功,但首页认不出你
现象很干净:在国际站后台登录,能看到自己的账号、数据、套餐;回到官网首页,右上角还是“登录 / 免费开始”,创作面板显示游客额度,生成视频被判定为未登录。
第一反应通常是“前端没读到登录态”或者“接口挂了”。但直接 curl 后端接口,返回的是 401 Unauthorized——服务端确实不知道你是谁。所以问题不在读取,在凭证根本没送出去。
先把路由关系摸清楚,一台机器上一个 nginx 配置里能看到全貌:
# 官网静态站(首页 / 登录页 / 注册页都是本地 HTML 壳)
location = /sign-in { try_files /sign-in.html =404; }
# 登录 API 与所有未匹配路径 → 透传到国际站后台
location ~ ^/(auth|login)$ {
proxy_pass https://backend-intl.example.com;
proxy_set_header Host backend-intl.example.com;
proxy_cookie_domain .example-intl.com .example.com; # ← 关键:改写 cookie 域
}
location / { proxy_pass https://backend-intl.example.com; ... }
这里已经做了 proxy_cookie_domain 改写,理论上从官网域名登录时,后端下发的 cookie 域会被改写成官网域,浏览器就能存下来。所以设计意图是对的。
那问题出在哪?出在用户从哪个域名登录。
二、根因:cookie 的域边界是浏览器铁律
浏览器的 cookie 发送规则很硬:请求 www.a.com 时,只会带上 Domain 匹配 .a.com 或 www.a.com 的 cookie。你登录 backend.example-intl.com 拿到的 cookie,Domain=.example-intl.com——访问 www.a.com 时它静静躺在浏览器里,永远不会被发出去。
实测三组请求,把这个链条钉死:
# ① 直连后端:cookie 落在 .example-intl.com
$ curl -sI https://backend-intl.example.com/api/user/self | grep -i set-cookie
set-cookie: auth=; Domain=.example-intl.com; Path=/; HttpOnly; Secure; SameSite=None
# ② 经官网域名透传:域被 nginx 改写(设计正确)
$ curl -skI -H "Host: www.example.com" https://127.0.0.1/api/user/self | grep -i set-cookie
set-cookie: auth=; Domain=.example.com; Path=/; HttpOnly; Secure; SameSite=None
# ③ 未登录访问官网 API:正常 401
$ curl -sk -H "Host: www.example.com" https://127.0.0.1/api/user/self
HTTP/1.1 401
②说明 nginx 改写是生效的。所以用户只要从官网的 /sign-in 登录,就没问题;他之所以是游客,是因为他从后端域名直接登录了——那条路径不经过 nginx 的 cookie 改写。
顺带还查到一个更尴尬的事实:用户说的那个后端子域,公网 DNS 根本没有解析记录(NXDOMAIN),真实存在的是另一个子域。也就是说这个登录入口本身就是记错的。
到这里有两个选择:
- A:告诉用户“请从官网
/sign-in登录”。零成本,但账号体系仍然分裂——身份在国际站后台,积分钱包在同机 WordPress,两边靠 email 字符串匹配。 - B:把官网身份整体切到 WordPress,与后端解耦。
老板选了 B,理由很直接:“官网直接走 WordPress 这个站的账号,不要搞到国际站里面了,那个是国际站。”

三、方案:一枚 HMAC 身份票,两个系统都认
切换前先侦察,几个事实决定了方案形态:
| 侦察项 | 结果 | 影响 |
|---|---|---|
| WordPress REST 是否可达 | /site/wp-json/ 返回 200 |
可以挂自定义端点 |
| REST 是否被页面缓存 | wp-json 已在缓存 bypass 名单 |
登录态不会被缓存串号 |
| 注册赠分钩子 | user_register → on_register 送 50 |
新建号自动发福利,无需我补 |
| 余额函数 | balance($uid) = SUM(amount) |
单一事实源,直接复用 |
| Google 登录 | 已有插件,/start?return= + 回调里 wp_set_auth_cookie |
可复用,不用重做 OAuth |
| COOKIEPATH | /site/ |
致命细节,见下 |
最后一条是坑:WordPress 原生登录 cookie 的 Path 是站点子目录(/site/),而官网首页在域名根(/)。浏览器规则里,Path=/site/ 的 cookie 不会随 / 的请求发送。所以哪怕用户在 WordPress 登录成功了,官网根页面照样看不到他。
硬改 COOKIEPATH 会波及后台、商城、账户页,风险太大。正确做法是不动原生 cookie,额外发一枚自己的身份票:
用户在官网登录页提交邮箱密码
→ POST /site/wp-json/front-sso/v1/login (WordPress 自定义 REST 端点)
→ wp_authenticate() 校验密码
→ wp_set_auth_cookie() (原生 cookie,Path=/site/,后台/账户页用)
→ 额外 Set-Cookie: front_auth=<HMAC票>; Path=/ (官网根 + 计费服务用)
票据格式刻意做到极简,四段点分:
v1.<uid>.<expire>.<sig>
sig = base64url( HMAC-SHA256( secret, "v1.<uid>.<expire>" ) )
这个设计有三个考虑:
- 版本号打头(
v1.):将来密钥轮换或算法升级,可以让新旧票据并存,服务端按版本分支验签,不用一刀切让所有人重登。 - 过期时间在签名内:改
expire续命必然导致签名失配,攻击者无法延长票据寿命。 - 纯本地验签:计费服务验证身份只需读一次 secret 算一次 HMAC,零网络请求。原方案要连境外后端验 cookie(每次 1–2 秒,还依赖跨境网络),现在这个依赖彻底消失。
签发方是 PHP(WordPress 插件),验签方有两处:PHP 自己(官网前端 /me)和 Python(计费服务)。跨语言 HMAC 必须逐字节对齐,两边的规范化写法:
// PHP 签发
$msg = "v1.$uid.$exp";
$sig = rtrim(strtr(base64_encode(hash_hmac('sha256', $msg, $secret, true)), '+/', '-_'), '=');
return "$msg.$sig";
# Python 验签
msg = ("v1.%s.%s" % (uid, exp)).encode()
expect = base64.urlsafe_b64encode(
hmac.new(secret.encode(), msg, hashlib.sha256).digest()).decode().rstrip("=")
if not hmac.compare_digest(expect, sig): # 恒定时间比较,防时序侧信道
return None
三个容易翻车的点:base64_encode(..., true) 的 true(原始二进制)不能漏;+/ → -_ 的替换顺序;rstrip("=") 去填充。任何一处不一致,签名就永远对不上。
四、三个部署陷阱,每一个都能让方案静默失效
陷阱 1:open_basedir 让密钥文件“明明存在却读不到”
密钥文件我按惯例放在服务目录下(/srv/billing/sso.key),Python 侧读得好好的。但 PHP 端点一直返回 503 sso-not-configured——它的语义是“读不到 secret”。
if (!is_readable(SECRET_FILE)) return 503; // 一直命中这里
诡异的是,用命令行 wp eval 测试同一个函数,is_readable() 返回 true,读得到内容。CLI 能读,FPM 不能读——这个差异直接指向 PHP 的 open_basedir:
; 站点目录下的 .user.ini(面板自动生成)
open_basedir=/webroot/example.com/site/:/tmp/:/srv/shared-keys/:/srv/shared-keys
/srv/billing/ 不在白名单里,所以 FPM 进程被禁止访问,而 CLI 不受此限制。这就是“命令行验证通过、Web 请求失败”的又一例。
解法不是放宽 open_basedir(那是安全边界),而是把密钥放进白名单已有的目录——那个站点上早就有一个专门放跨服务密钥的目录:
$ ls -la /srv/shared-keys/
-rw-r----- 1 root www 65 media-sign.key # 已有的跨服务密钥
-rw------- 1 www www 116 storage-enc.key
把 sso.key 挪进去,权限对齐 root:www 640,PHP 立刻能读。教训:密钥文件的选址要先看 PHP 的沙箱白名单,不是看哪个目录顺手。
陷阱 2:OPcache 的 60 秒窗口
挪完密钥,端点仍然返回 503。这次的原因更朴素:
opcache.enable => On
opcache.validate_timestamps => On
opcache.revalidate_freq => 60 # ← 最长 60 秒才重新检查文件时间戳
我上传了新版插件文件,但 OPcache 里还是旧版(旧版指向已被删掉的老密钥路径)。等 60 秒或 reload 就好,但部署脚本必须显式 reload,否则你会在“我明明传上去了”和“它明明还是坏的”之间反复横跳:
systemctl reload php-fpm-82 && sleep 3
顺带把断言也修正了:第一版部署脚本判断登录端点是否修好,用的是 "invalid-credentials" in output——但我的 shell 里 echo "---login端点(假凭据应401 invalid-credentials)---" 这行提示文字本身就含这个字符串,断言被自己的日志污染了,永远为真。改成只截取响应体区段再断言:
login_seg = out.split("---login---")[-1].split("---logout---")[0]
assert '"reason":"invalid-credentials"' in login_seg.replace(" ", "")
assert "sso-not-configured" not in login_seg
陷阱 3:PrivateTmp —— 上线两周的扣费暗雷
这个是真正的收获,而且它不是这次切换引入的,是本来就埋着的。
切换身份后做端到端验证,用 PHP 真签一张 uid=1 的票,交给计费服务验:
$ VAL=$(wp eval 'echo sign(1, time()+600);')
$ curl -s -H "Cookie: front_auth=$VAL" http://127.0.0.1:19099/whoami
{"ok": false, "reason": "bridge-down"} # ← 身份验过了,余额查询炸了
注意语义:bridge-down 出现在查余额那一步,说明 HMAC 验签已经通过(否则会返回游客态)。翻服务日志:
whoami wp fail RuntimeError("ERROR 2002 (HY000): Can't connect to local MySQL
server through socket '/tmp/mysql.sock' (2)")
ERROR 2002 + socket 找不到。但手工执行同一条 SQL 完全正常:
$ mysql --defaults-extra-file=~/.my.cnf -N db -e \
"SELECT COALESCE(SUM(amount),0) FROM credit_ledger WHERE user_id=1"
483 # ← 命令行查得到,服务进程查不到
又是“手工正常、服务异常”。看 unit 文件:
[Service]
ExecStart=/usr/bin/python3 /srv/billing/billing.py
User=root
NoNewPrivileges=true
PrivateTmp=true # ← 元凶
PrivateTmp=true 会给服务挂一个私有的空 /tmp。而 MySQL 的 unix socket 就在真实的 /tmp/mysql.sock——服务进程看不到它,只能报“连不上”。
这个雷的威力在于它的隐蔽性:
- 计费服务从上线起就开着
PrivateTmp,登录用户扣费链路一直是坏的(每次查余额/写账本都 ERROR 2002)。 - 上线时跑的公网端到端验收 12/12 全绿——因为那一轮测的是游客路径,游客额度记在 SQLite(服务自己的文件),完全不碰 MySQL。
- 账本写入也单独验证过、真扣真退全对——但那次是在 SSH shell 里
import服务模块跑的,shell 环境用的是真实/tmp,socket 就在眼前。 - 更讽刺的是,日志里早就有 15 条
wp-cli fail/bridge fail,因为身份桥也要调外部命令,同样受PrivateTmp影响。数字摆在那儿,没人把它和“扣费坏了”联系起来。
修复用 systemd drop-in,不改原 unit 正文(那份配置是同事维护的),回滚只需删一个目录:
mkdir -p /etc/systemd/system/billing.service.d
cat > /etc/systemd/system/billing.service.d/override.conf <<'EOF'
[Service]
# PrivateTmp=true 使服务看不到 /tmp/mysql.sock → ERROR 2002 →
# 登录用户扣费/退款全挂(游客走 SQLite 不受影响,故此前未暴露)
PrivateTmp=no
EOF
systemctl daemon-reload && systemctl restart billing
systemctl show billing -p PrivateTmp --value # → no
服务只监听 127.0.0.1,取消 PrivateTmp 的安全损失可以忽略;而 MySQL socket、wp-cli、临时文件都依赖真实路径。

五、可以带走的一条铁律
这三个陷阱指向同一个结论:

在 shell 里验证过的东西,不代表服务进程里也能跑。
环境差异至少包括:open_basedir(PHP 沙箱)、PrivateTmp/ProtectHome/ReadOnlyPaths(systemd 隔离)、
OPcache(代码版本)、EnvironmentFile(环境变量是否真的注入了)、工作目录、运行用户与权限。
具体做法,按成本从低到高:
- 查 unit 的隔离选项:
systemctl cat <svc>通读一遍,PrivateTmp/ProtectSystem/ReadOnlyPaths任何一个都可能咬你。 - 在服务进程内部执行验证:不要 SSH 进去手工跑,而是让服务自己跑——
curl它的端口,或者在服务环境里import它的模块。 - 打印运行时真实值:
systemctl show <svc> -p PrivateTmp、tr '\0' '\n' < /proc/<pid>/environ(看环境变量实际注入了什么)。我这次就是靠后者发现EnvironmentFile里只有 2 个键,代码里的数据库路径默认值指向一个根本不存在的目录。 - 覆盖所有代码路径:游客走 SQLite、登录用户走 MySQL,这是两条完全不同的链路。只测一条 = 另一条的状态未知。这次 12/12 全绿就是这么来的。
六、验收:跨语言 parity 是必须的一环
上线用四阶段守卫,每阶段失败即中止并自动回滚,绝不允许“传了一半”的状态存在:
① 密钥 + PHP 插件 → php -l 语法检查 → reload FPM → 端点冒烟(401/403/200 各就各位)
② 计费服务新版 → py_compile → 重启 → is-active → health → 游客路径回归
③ 跨语言 parity → PHP 真签票 → Python 验签 → 认出 uid → 篡改一位 → 拒绝
④ 前端六个文件 → 服务端备份 → 上传 md5 双端核验 → 公网回读 md5 → 版本引用检查
第③阶段是这次新增的,也是最不能省的一步。跨语言 HMAC 只要有一个字符规范化不一致(+/ vs -_、有无 = 填充、消息拼接顺序),线上表现就是“所有人都被当成游客”——不报错、不崩溃、静默降级。用真实生产代码签发一张票、交给真实生产代码验证,是唯一能证明两边对齐的方法。
公网真实浏览器验收 19 项,关键几项:
PASS 未登录首页 whoami=guest
PASS 登录页加载新版脚本(版本号核验)
PASS 假凭据 → 401 + 页面显示错误提示(不误跳、不崩)
PASS PHP 签 uid=1 票 → /me 认出身份 + 余额 483
PASS 计费 whoami=user uid=1 balance=483 ← MySQL 通了(PrivateTmp 修复生效)
PASS 隔离测试号 reserve 扣 5 分 → 过 nginx 鉴权闸 → 上游 422 → 自动退款回 50
PASS 已登录访问 /sign-in 自动跳首页
PASS 全程无 JS 异常
PASS 测试数据零残留、真实账号余额分毫未动
扣费验证用的是隔离测试账号(种子 50 分,测完 DELETE 清零),真实账号全程只读。这条纪律值得强调:在生产库上验证写操作,一定要用可识别、可清理的隔离主体,绝不拿真账号试错。
七、回滚设计:每一层都能单独退
切换身份体系属于高危改动,回滚路径必须提前想清楚,而且分层——哪层出问题退哪层,不用整体回退:
| 层 | 回滚动作 | 影响面 |
|---|---|---|
| 前端 | 6 个文件从 .bak-<tag> 还原 |
回到旧登录流 |
| 计费服务 | 还原 .bak + restart |
回到旧身份桥 |
| PHP 插件 | 删掉插件文件(mu-plugins 目录下的文件删了就失效) |
端点 404,前端自动降级游客 |
| systemd 隔离 | 删 override.conf + daemon-reload + restart |
⚠ 登录扣费会再次挂掉,不建议退 |
| 总闸 | 计费 ENFORCE=0 |
全放行,业务零感知 |
另外新方案保留了旧身份桥作为 fallback:没有新票据时,仍尝试走老路径。这样存量已登录会话不会在切换瞬间集体掉线,等确认没人用老路径了再删。
八、遗留清单(诚实记录未完成项)
- 真实密码登录未人工验证。上面所有登录态验证用的是”服务端签发合法票据”,绕过了密码校验环节。密码校验逻辑本身有假凭据 401 的反向验证,但”正确密码能登进去”这一步需要真人点一次。
- Google 登录未实测。链路已接好(新端点 302 → 现有 OAuth 插件 → 回调里
wp_set_auth_cookie→ 钩子补发身份票 → 回跳首页),但需要真 Google 账号交互。这里有个细节值得记:那个 OAuth 插件的回调是直接调wp_set_auth_cookie,不经过wp_signon,所以wp_login钩子不会触发——我最终挂在set_logged_in_cookie钩子上(它在wp_set_auth_cookie内部触发,覆盖所有登录路径)。如果当初想当然用了wp_login,Google 登录就拿不到身份票,而邮箱密码登录一切正常,这种“一半功能坏掉”的 bug 最难查。 - 老身份桥未下线,等观察期结束再清理。
九、小结
这次改动的代码量不大:一个 PHP 插件(约 200 行)、计费服务加两个函数、前端六个文件改几处 URL。真正花时间的是侦察和排雷:
- 跨域 cookie 是浏览器铁律,
proxy_cookie_domain只在“从改写它的那个域名登录”时才起作用。身份系统选型时,cookie 域要和应用域一致,这是前提不是优化项。 - WordPress 的
COOKIEPATH会限制 cookie 的作用路径,站点在子目录时,域名根的页面拿不到原生登录 cookie。发一枚Path=/的自定义身份票比改COOKIEPATH安全得多。 open_basedir和 OPcache 会造成“文件明明对了但行为没变”,密钥选址要看沙箱白名单,部署要显式 reload。PrivateTmp这类 systemd 隔离选项,能让服务在你眼皮底下坏掉两周,而你所有的手工验证都是绿的。验证必须在服务进程内做,且要覆盖全部代码路径。- 跨语言 HMAC 必须做 parity 验证——用生产代码签发、生产代码验证,这是唯一能证明两边对齐的方式。
最后那条最重要:当“手工验证通过”和“生产环境失败”同时成立时,不要怀疑代码,去查两个环境的差异。 这次差异是 /tmp,上次可能是沙箱,下次可能是环境变量。它们共同的特征是——你的验证脚本永远看不见它们。



