基于 Traefik ForwardAuth 实现集群服务统一认证
我在腾讯云上有一台 4C8G 的 LightHouse 轻量云服务器,服务器上使用 k3s 搭了个小集群部署自己开发的小玩意,以及一些常见的基础组件。如 Grafana 做仪表盘展示、Uptrace 记录 Go 程序的链路、Metabase 用作 NekoBox 的数据库 BI。这些服务通过 Helm Charts 部署至集群,配置 Ingress 后直接通过公网域名即可以访问。
我时常在想这些第三方应用会不会哪天爆出个 0day 被打穿。进而导致我存在里面的数据库配置、云 AK SK 之类的凭证泄露。因而在想能否在集群的 Ingress 反代层面做统一的权限认证,就像公司内的某统一认证系统一样 —— 具体名字我不知道能不能说,不过你应该可以在公网上找到它的痕迹。
我一直觉得,这种架设在反代上的统一认证,比那些跳第三方 OAuth 的验证方式安全多了。
经常能看到一些企业内部的 Web 站,做的前后端分离的架构。第一次访问时加载前端页面,前端逻辑判断用户未登录,跳转到第三方 SSO 做统一登录。登录成功后 callback 一个 SSO Token 回原站点。然后后端 API 签一个自己业务的 Token 发给前端,前端把业务 Token 放 Local Storage 里存着。由于网站是前后端分离的,攻击者在未登录的时候就可以访问前端,他就可以从前端打包后的 JavaScript 里把后端接口全提取出来去 Fuzz。(更别说还有些不关 Sourcemap 的)后端在实现上万一漏了个路由,鉴权中间件没包到(往往还是些上传下载文件的接口),然后就接口越权一把梭了。
因此我觉得供内部使用的服务,不管是基于第三方的还是自建的,都应该在网关层面做一套统一的鉴权。
那么说干就干!在查阅了相关资料后,站在前人的肩膀上,我造了个小轮子 —— ikD。
比 traefik-forward-auth 简洁
由于使用 k3s 搭建的集群会内置一个 Traefik 做为默认的 Ingress Class,我也就围绕 Traefik 来展开了。ikD 这个名字,其实也就是取自 Traefik ID 中的三个字母。一开始想叫 ikID
的,但是仔细一读像是什么儿童品牌……?遂改名。
我的想法是先找找看 Traefik 有没有类似 K8s Mutating Webhook 的特性,当准备代理一个集群内的 Service 时,先去调用一下我写得“WebHook”,由我来指挥它后续的行为。找了一圈发现 Traefik 里还真有这样一个中间件:ForwardAuth,同时还找到了前人开发的 traefik-forward-auth 项目。该项目利用 ForwardAuth 中间件让 Traefik 反代支持前置使用 Google 账号或 OpenID 服务进行身份认证。然而我很少用 Google 账号登录,OAuth、OpenID、SAML 那些玩意更是傻傻分不清,总不能为了用这玩意我再去注册个 Auth0 吧?!
因此我在阅读了 traefik-forward-auth 的源码后,写了 ikD 这一版拥有更简洁更适合我自己使用的 Traefik ForwardAuth 认证服务。
ForwardAuth
Traefik 本身不支持用户编写自定义逻辑的中间件,只能将官方文档中给的内置中间件简单配置后使用。比如官方给你提供了个 Errors
错误中间件,那你可以自己配置哪些状态码要报错,以及报错页面的地址是啥。
ForwardAuth 就是官方提供的用于转发请求到外部服务进行验证的中间件。这里直接贴文档里的图,方便后文介绍。
对于使用了 ForwardAuth 中间件的路由,Traefik 会先请求 address
中配置的第三方服务地址,并使用 X-Forwarded-*
请求头传递上游请求的请求方式、协议、主机名、URL、源 IP 地址给第三方服务。第三方服务就可以根据这些信息来执行自定义的验证逻辑了,若第三方服务返回 2XX 响应码,则代表验证通过;否则验证不通过,Traefik 将把第三方服务的响应传给上游。
这个设计十分简洁。验证不通过时返回第三方服务的响应,可以方便我们将未验证用户 302 跳转到登录页面。
值得一提的是,我十分好奇 Traefik 源码中关于 2XX 响应码的判断方式,我以为会是 statusCode / 100 == 2
这样的写法,但实际是:
// https://github.com/traefik/traefik/blob/9dc2155e637318c347b8b00e084c3dd0c75f18e4/pkg/middlewares/auth/forward.go#L187-L189
// Pass the forward response's body and selected headers if it
// didn't return a response within the range of [200, 300).
if forwardResponse.StatusCode < http.StatusOK || forwardResponse.StatusCode >= http.StatusMultipleChoices {
它是判断状态码的数字是否落在 [200, 300)
这个区间内,我感觉这样的写法可以规避掉 statusCode / 100 == 2
中出现的 2
这个 Magic Number。在 Lint 上会更好一些。
完整的登录流程
画了张图来梳理 ikD 是怎样处理用户登录的。
- 用户请求了
https://hello.example.com/index.php
网站,集群内 Traefik 请求 ikD 服务,ikD 发现用户未登录,返回 302 跳转到https://ikd.example.com/?redirect=https://hello.example.com/index.php
。 - 由于状态码非 2XX,Traefik 知道这是验证不通过,将 ikD 的 302 响应返回给上游。用户的浏览器跳到了登录页。(这里跳转的 URL 里 Query 需要带一下来源 URL,方便登录成功后跳回去)
- 登录页
https://ikd.example.com/
是单独做的 Web 服务,用户在这里提交凭证登录成功,后端接口会在来源 URL 中加上一个ikdcode
Query 参数,如:https://hello.example.com/index.php?ikdcode=a1b2c3d4e5f6g7
前端控制用户浏览器跳转到该地址。 - 跳到
hello.example.com
域下后,又被 ikD ForwardAuth 中间件拦了,但它发现这次多了个ikdcode
参数,会去验证这个参数是否有效。如果有效,则会在返回 302 跳转到去除ikdcode
的地址:https://hello.example.com/index.php
,并 Set-Cookie。这里是整个登录过程中我认为最巧妙的地方:ForwardAuth 中间件劫持了目标站的响应,返回Set-Cookie
头让它可以在目标站的域名下写一个 ikD 的 Cookie。 - 用户浏览器再次跳到
https://hello.example.com/index.php
,会带上之前一步设置的 Cookie。此时再被 ikD 拦截,ikD 认出了这个 Cookie 并验证通过,返回状态码200 OK
,至此请求终于能够被转发到后面的hello.example.com
服务的 Service 上了。
具体到代码实现上,有一些细节需要注意:
- ikD 登录页登录成功后,也需要给
https://ikd.example.com/
Session 存个登录态,下次再跳过来,发现之前已经登录过了,直接跳走就行。 ikdcode
拼接后作为 Redis 的 Key,Value 存储目的站点的 Proto + Host。实际登录时,用户拿到带ikdcode
的 URL 几秒不到就跳转去验证了,所以 Key 的有效期可以设置的短一点,如一分钟。像上面的例子在 Redis 中存储的就是:
redis.SetEx("ikd:authcode:a1b2c3d4e5f6g7", "https://hello.example.com", 1*time.Minute)
- 校验
ikdcode
时,使用GETDEL
来获取 Redis Key,确保ikdcode
仅可使用一次。还要将 Value 中存储的目的站点和实际要跳转的站点进行比对,防止一开始使用恶意站点 A 获取到的ikdcode
可以用来登录站点 B。 - 最后
Set-Cookie
的值可以签一个存储了目的站点 Proto + Host 和有效期的 JWT。因为访问目的站点的每个请求都要先打到 ikD 上,这个请求量比较大,验证 JWT 比查 Redis 验证 SessionID 快多了。
一次性字符串凭证登录
你会发现 ikD 的登录页并没有要求输入用户名和密码,而是一个 发送登录凭证
的按钮。这里的登录方式和 Notion 类似 —— 随机发送由三个英文单词组成的字符串到我的手机上,我输入字符串登录。
在 macOS 环境下可以读取 /usr/share/dict/words
文件来获得英文单词,这个 words
文件是软链接到同目录下的 web2
文件。线上基于 Alpine 打包的 Docker 镜像,可以从苹果开源 https://opensource.apple.com/source/files/files-473/usr/share/dict/web2 下载到这份单词表。GitHub Actions 打镜像的时候丢进去就行。
发送字符串是后端请求我手机 Bark App 的 WebHook URL 发送推送消息。收到推送后手机上复制,iCloud 剪贴板同步粘贴到电脑浏览器即可登录。由于是直接复制的内容,几乎不可能出错。所以每次发送的字符串凭证的验证仅有一次机会,输入错误了就得再重新下发一个新的。嗯,感觉十分的安全呢。后续其实可以做个 App 来弹出个框让我点确认的。
接下来呢?
现在我已将集群内的 Metabase、Grafana、Uptrace、以及自己开发的自用服务接上了 ikD 做统一认证。好好好,这下 ikD 被打穿了就全部完蛋!
但目前还只是个刚好能用的状态,对于各种操作还需要记录行为日志,后续可以考虑下把集群里搭的 Loki 用起来。
我一开始是想用 WebAuthn 来做一个帅到爆的 TouchID 刷指纹登录的。但尝试了下 WebAuthn 单独拆出来做成单用户调用还挺复杂的。真要做的话只能老老实实地按照 SDK 文档先做注册生成公私钥,公钥还得分用户存数据库,登录的时候发送 Challenge 挑战给客户端,解完后还得查库找到对应的用户。那就又回归到了朴实无华的 Go 写一套用户账号的 CRUD 了,已经不想再写 CRUD 了!放弃!😖
以及最后那个问题,ikD 开源吗?很遗憾,依旧不想开源。如果你对此有兴趣,可以找我讨论。😋
喜欢这篇文章?为什么不打赏一下呢?