自建 SSO:从 Basic Auth 弹窗地狱到登录一次全家通
一、需求缘起:5 个服务,0 个认证
这半年我在一台 1.6G 内存的小服务器上折腾了不少自托管服务:短链系统、Mock 服务器、视频通话、WebRTC 实验台、P2P 文件传输。它们一个个上线,玩得很开心,直到有一天我突然意识到一个问题——
这些服务全都没有认证,门户大开。
文件传输能被任何人当公共网盘传东西,Mock 服务器的 API 公开可写,谁都能改数据。这已经不是”好玩”的问题,而是安全和滥用风险。必须加锁。
二、第一版:Caddy Basic Auth,30 秒上线
当时图省事,直接用 Caddy 自带的 Basic Auth 给所有站点加密码:
mock.example.com {
basic_auth {
admin $2a$14$xxxxxxxxxxxxxxxxxxxx
}
reverse_proxy 127.0.0.1:3003
}
一个 bcrypt hash,配置三行,reload 即生效。实测全部站点:无凭据 401,有凭据 200。30 秒搞定,我当时觉得这事就这么完了。
三、弹窗地狱:Basic Auth 的体验崩塌
用了两天,问题全来了:
1. 每个域名独立记账
我有 5 个受保护入口,分布在多个子域。Basic Auth 的凭据按域名存储,每换一个域名访问,浏览器就要重新弹窗认证一次。用户从短链跳转到视频通话,跨域瞬间又是一个弹窗。感觉就像在闯关。
2. 隐藏炸弹:401 响应头触发弹窗死循环
这是最坑的。短链服务自己有 API 鉴权(Bearer Token),未授权时返回:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="shortlink"
问题来了:浏览器看到任何 401 + WWW-Authenticate 头,都会弹 Basic 认证框——不管 scheme 是 Basic 还是 Bearer。于是出现了这种死循环:
- 用户输入 Caddy 密码,进入管理页
- 页面 JS 请求 API,没带业务 Token
- 后端返回 401 + WWW-Authenticate 头
- 浏览器又弹窗,用户输什么都是 401
- 无限循环
修复方式很反直觉:后端未授权时,干脆别带 WWW-Authenticate 头。反正外层已经有 Caddy 挡着了,这个头只会坑浏览器。
3. 手机端体验归零
部分手机浏览器不持久化 Basic 凭据,每次打开都要重新输,密码还进不了系统密码管理器。
结论:Basic Auth 只适合”临时挡一下”。要长期用,得上正经的登录页。
四、架构设计:自建 SSO 认证中心
需求其实很朴素:登录一次,所有服务通。这就是 SSO(Single Sign-On)要解决的问题。
组件
- 认证中心:一个 Go 写的认证服务(约 300 行,零第三方依赖),提供登录页和验证接口
- Caddy
forward_auth:每个受保护站点的请求先发给认证服务”验票”,2xx 放行,302 跳登录页
登录时序

- 访问
mock.example.com/admin/ - Caddy forward_auth → 认证服务:没有 cookie → 302 到登录页
- 用户输入用户名密码 → 登录成功
- 认证服务发一个一次性 ticket → 302 到
mock.example.com/__auth?t=xxx - mock 站点验证 ticket → 给浏览器种登录 cookie → 302 回
/admin/ - 之后每次请求带 cookie,验证通过,秒进
关键设计一:一次性 ticket
这里有个初学者很容易踩的坑——cookie 是按域隔离的。登录发生在 auth.example.com,种下的 cookie 属于 auth.example.com;而票据兑换发生在 mock.example.com,浏览器根本不会把 auth 域的 cookie 发给 mock 域。
所以不能靠”检查 SSO cookie”来确认登录状态。解法:登录成功后生成一个一次性 ticket(随机 192 位),5 分钟过期,作为”登录成功”的凭证传给目标域。目标域验证 ticket 有效,就给用户种自己的 cookie。
关键设计二:父域 cookie,登录一次全家通
如果每个子域各存各的 cookie,那还是”每域登录一次”。解法是父域 cookie:
Set-Cookie: auth=xxx; Domain=.example.com; HttpOnly; Secure; SameSite=Lax
Domain=.example.com 的 cookie 对 example.com 的所有子域可见。于是 mock、short、call 这些子域共享同一个会话,登录一次,全家桶全通。
五、踩坑实录(本篇精华)
坑 1:forward_auth 偷偷改写了 URI
forward_auth 会把请求的 URI 重写成验证端点(如 /verify),原始路径去哪了?——在 X-Forwarded-Uri 头里。
我的票据兑换路径是 /__auth?t=xxx,如果按 r.URL.Path 判断永远匹配不到(它已经被改成 /verify 了)。必须从 X-Forwarded-Uri 解析:
origURI := r.Header.Get("X-Forwarded-Uri")
if origURI == "" {
origURI = r.URL.RequestURI()
}
origURL, _ := url.Parse(origURI)
if origURL.Path == "/__auth" {
// 兑换 ticket
}
坑 2:部分路径保护的站点,票据兑换路径没被保护
短链站点只有 / 和 /api/* 需要认证(跳转链接要公开)。但 /__auth?t=xxx 恰好不在保护范围里——它直接落到了后端服务,返回 404,cookie 种不上,又跳回登录页。死循环。
修复:把 /__auth 也加进保护范围。
坑 3:跨服务器反代,X-Forwarded-Host 被层层覆盖
文件传输服务在另一台服务器上,它的 Caddy 把验证请求转发到 auth.example.com(公网)。链路变成:
服务器B Caddy → auth.example.com (服务器A的 Caddy) → 认证服务
问题:服务器A 的 Caddy 在反代时,会把 X-Forwarded-Host 覆盖成自己收到的 Host(auth.example.com)。认证服务收到后以为用户是从 auth.example.com 来的,票据兑换路径变成了 auth.example.com/__auth,又卡住。
修复:自定义头透传。服务器B 的 Caddy 显式设置:
forward_auth https://auth.example.com {
uri /verify
header_up X-Auth-Host {host} # 自定义头,子层反代不会覆盖
copy_headers Set-Cookie
}
认证服务优先读 X-Auth-Host。标准头会被各层反代改写,自定义头不会。
坑 4:Go 序列化 cookie 会去掉 Domain 的前导点
Domain=.example.com 和 Domain=example.com 语义等价(浏览器忽略前导点),但 Go 的 http.Cookie.String() 序列化和解析时会去掉前导点。测试断言别写死带点的形式,不然会在凌晨三点怀疑人生。
坑 5:shell 里 bcrypt 的 $ 被变量展开
往远程服务器写配置时:$2a$14$... 在双引号里会被 shell 当成变量 $2、$a 展开,hash 直接变成空。这类问题统一用单引号 heredoc 或本地生成再传文件,别在命令行里拼。
六、效果与对比
| 维度 | Basic Auth | 自建 SSO |
|---|---|---|
| 首次登录 | 每域名一次弹窗 | 登录一次 |
| 会话保持 | 凭浏览器心情 | 7 天 cookie |
| 手机端 | 经常要重输 | 正常登录页 |
| 新增服务 | 每站点配 hash | Caddy 加两行 forward_auth |
| 后端 401 弹窗循环 | 会踩 | 不存在 |
代码量:认证服务 ~300 行 Go(登录页 + 会话 + ticket),加 e2e 测试 ~500 行。部署是单二进制 + systemd,内存占用 5MB。
七、总结:什么时候该自写认证
说实话,有 Authelia、Authentik 这些现成方案,为什么自写?我的判断标准:
- 场景匹配:个人/小团队自托管,3-5 个服务,需求就是”登录一次全通”
- 复杂度可控:不需要多因素、找回密码、设备管理、用户注册
- 可维护性:300 行代码,出问题半小时能看完;引一个全家桶框架,出问题要看三天文档
当然也放弃了:会话存内存(重启全员重新登录)、无找回密码、无操作审计。知道自己放弃了什么,比知道要什么更重要。
如果哪天服务多到需要用户体系,再换 Authentik 不迟——而且 Caddy 的 forward_auth 是通用接口,换实现不动架构。
