一个密码进四个站:自己写统一登录网关

事情是从一句抱怨开始的:我自己开在四个子域上的服务,密码各不相同,还都记不住。模型网关面板、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模型网关面板有,登录后仍要过一次
dshAI harness有,靠补签续上
file文件站无
parse视频解析无

我给自己定了三条:

  • 网关只做认证,不做授权:能不能进由它说,进来能干什么由上游自己说;
  • 上游的账号体系不删:网关是外面一层门,不是替代品;
  • 恢复路径要短:账号表在本地,改密码就是重算一条哈希。

以后再加一个站,步骤也固定:

  1. 在 Caddy 里加一段 forward_auth,指到 127.0.0.1:3099;
  2. 把登录页和 /__auth 的路由放在 handle 最前面;
  3. 确认上游只绑回环,公网只能从 443 进来。

统一登录的价值不是少输几次密码,是把「谁能进来」收成一个可以审计的地方。

代价也有:网关挂了,四个站一起进不去;它的日志里会出现所有子域的访问记录;换了 cookie 的签名密钥,所有人得重新登录一次。这些都接受——比起四套账号各记各的密码,一个会一起坏掉的入口更容易想明白。