一个密码进四个站:自己写统一登录网关
事情是从一句抱怨开始的:我自己开在四个子域上的服务,密码各不相同,还都记不住。模型网关面板、AI harness、文件站、视频解析站,每个都自带一套账号体系,装一个加一个。最早的省事办法是给每个服务发一个访问令牌,谁要用就自己去 journalctl 里翻——翻过两次之后,我放弃了这条路。
需求:一个入口,别再发令牌
令牌的问题不在于麻烦,而在于它会散。贴在聊天记录里、写进脚本里、留在浏览器历史里,发出去就收不回来。我想要的其实很简单:一个密码,登录一次,四个站都能进;不用为每个服务单独建账号,也不用把令牌交给任何人。
做法:一个只监听回环的单文件服务
网关是一个单文件的 Python 服务,只用标准库,没有第三方依赖。它不听公网,只监听 127.0.0.1:3099;公网流量全部由 Caddy 收下,Caddy 在反代之前先问它一句话:这个请求能不能放行。
dsh.example {
handle /login* {
reverse_proxy 127.0.0.1:3099
}
handle {
forward_auth 127.0.0.1:3099 {
uri /__auth
}
reverse_proxy 127.0.0.1:8080
}
}
这里的关键是顺序:登录页自己那条路由必须排在做鉴权的那条之前。我第一版把两条写反了,结果登录页本身也被拦,浏览器在「未登录 → 去登录 → 还是未登录」之间转圈,看起来像服务挂了。排障花了很久,因为日志里只有一串 303。
cookie 和密码
登录成功后,网关签发一枚 cookie:内容用 HMAC 签名,域名设成父域,四个子域共享,有效期三十天。密码不存明文,账号表里是 PBKDF2-SHA256、二十六万轮哈希出来的值;校验时把输入按同样参数算一遍再比对,比较用常数时间函数。
失败次数按 IP 记:连续五次错误锁五分钟,锁定期内连正确密码也不放行。这个策略挡不住分布式撞库,但能挡住脚本对单点的反复尝试。代价是我自己偶尔也会被自己锁在门外,只能等五分钟。
第二个坑:网关 cookie 还在,上游 cookie 没了
统一登录只管「你能不能进来」,不管上游自己那套会话。上游的 cookie 是另一个域、另一个有效期,浏览器清一次缓存,或者服务重启换了密钥,网关这边仍然认为我登录着,放行之后上游回一个 401 纯文本。页面白底黑字一行 401 Unauthorized,看起来像整个站坏了。
补的办法是一个 /__renew 端点:网关发现上游 401,就把请求转到该子域的 /__renew,在那里重新签一枚上游认的 cookie,再跳回原路径。用户看到的是一次几乎察觉不到的跳转,而不是一行报错。这个设计不优雅,但它把「两套会话各管各的」摆到了明面上。
覆盖与边界
| 子域 | 服务 | 上游自己的会话 |
|---|---|---|
| api | 模型网关面板 | 有,登录后仍要过一次 |
| dsh | AI harness | 有,靠补签续上 |
| file | 文件站 | 无 |
| parse | 视频解析 | 无 |
我给自己定了三条:
- 网关只做认证,不做授权:能不能进由它说,进来能干什么由上游自己说;
- 上游的账号体系不删:网关是外面一层门,不是替代品;
- 恢复路径要短:账号表在本地,改密码就是重算一条哈希。
以后再加一个站,步骤也固定:
- 在 Caddy 里加一段
forward_auth,指到127.0.0.1:3099; - 把登录页和
/__auth的路由放在handle最前面; - 确认上游只绑回环,公网只能从 443 进来。
统一登录的价值不是少输几次密码,是把「谁能进来」收成一个可以审计的地方。
代价也有:网关挂了,四个站一起进不去;它的日志里会出现所有子域的访问记录;换了 cookie 的签名密钥,所有人得重新登录一次。这些都接受——比起四套账号各记各的密码,一个会一起坏掉的入口更容易想明白。