自建 SSO:从 Basic Auth 弹窗地狱到登录一次全家通

自建 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。于是出现了这种死循环:

  1. 用户输入 Caddy 密码,进入管理页
  2. 页面 JS 请求 API,没带业务 Token
  3. 后端返回 401 + WWW-Authenticate 头
  4. 浏览器又弹窗,用户输什么都是 401
  5. 无限循环

修复方式很反直觉:后端未授权时,干脆别带 WWW-Authenticate。反正外层已经有 Caddy 挡着了,这个头只会坑浏览器。

3. 手机端体验归零

部分手机浏览器不持久化 Basic 凭据,每次打开都要重新输,密码还进不了系统密码管理器。

结论:Basic Auth 只适合”临时挡一下”。要长期用,得上正经的登录页。

四、架构设计:自建 SSO 认证中心

需求其实很朴素:登录一次,所有服务通。这就是 SSO(Single Sign-On)要解决的问题。

组件

  • 认证中心:一个 Go 写的认证服务(约 300 行,零第三方依赖),提供登录页和验证接口
  • Caddy forward_auth:每个受保护站点的请求先发给认证服务”验票”,2xx 放行,302 跳登录页

登录时序

统一登录时序图

  1. 访问 mock.example.com/admin/
  2. Caddy forward_auth → 认证服务:没有 cookie → 302 到登录页
  3. 用户输入用户名密码 → 登录成功
  4. 认证服务发一个一次性 ticket → 302 到 mock.example.com/__auth?t=xxx
  5. mock 站点验证 ticket → 给浏览器种登录 cookie → 302 回 /admin/
  6. 之后每次请求带 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.comDomain=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 是通用接口,换实现不动架构。