不把令牌放在浏览器里的方法:BFF
一句话总结
SPA 如果自己持有 access token,只要该页面上运行的某一个脚本被污染,令牌就会流到外部。BFF(Backend for Frontend)只在服务器上保存令牌,只给浏览器一个 HttpOnly 会话 Cookie。这是让浏览器里根本没有可偷令牌的设计。
为什么需要它
有一段时间,SPA 的标准结构是这样的:浏览器从签发方拿到 access token,存进 localStorage,每次调用 API 都用 Authorization: Bearer 附上。服务器没有状态,所以很省事。问题在于,能读取这个令牌的主体是“在这个页面上运行的所有脚本”。广告标签、分析脚本、被污染的 npm 包、一行 XSS——任何一个都能读取令牌并发送到攻击者的服务器,而这个令牌在攻击者手里直到过期都有效。用户关闭标签页也没有用。
IETF 长期打磨这个问题,发布为 RFC 10017 OAuth 2.0 for Browser-Based Applications(草案名称 draft-ietf-oauth-browser-based-apps)。比较了多种结构之后,在 第 6.1 节 中给出了 BFF。核心只有一句话——令牌只在 BFF 上,所以浏览器里没有可以取走的令牌。
工作原理
BFF 是与前端同源的小型服务器。这个服务器整个接管 OAuth 客户端的角色。
브라우저 ── GET /login ─────────────▶ BFF (state 를 담은 임시 세션 발급)
브라우저 ── 302 ▶ 발급자 /authorize (로그인) ── 302 ▶ BFF /callback?code=..
BFF ─────── code + client_secret ──▶ 발급자 /token → access token
BFF: 토큰을 서버 쪽 세션에 저장, 세션 ID 재발급, HttpOnly 쿠키만 내려줌
브라우저 ── GET /bff/api/me + 쿠키 ─▶ BFF ── Authorization: Bearer ▶ 하류 API
该遵守的规则集中在 第 6.1.3 节 中。
- 机密客户端。BFF 在签发方处持有凭据(客户端密钥),使用授权码授权模式。即使授权码从地址栏或日志中泄露,没有密钥也无法兑换成令牌。
- Cookie。
Secure和HttpOnly是 MUST,SameSite=Strict、Path=/,以及表示只能通过 HTTP 设置的__Host-Http-这样的前缀是 SHOULD。Cookie 中只放会话 ID,这个值对下游 API 没有任何意义。 - 转发。BFF 从请求中去掉 Cookie,附上用户的 access token 再转给下游。下游 API 不认识 Cookie,只信任 Bearer 令牌。令牌缺失或已失效时,按 RFC 6750 第 3 节 返回 401 和
WWW-Authenticate: Bearer。 - CSRF。因为浏览器会自动附带 Cookie,所以 BFF 必须做 CSRF 防护(MUST)。RFC 提到 SameSite=Strict,以及要求自定义请求头,使跨源请求必须经过 CORS preflight 的方法。本实验把上一个模块做的绑定会话的 CSRF 令牌通过自定义请求头发送,同时获得这两种性质。
- 退出登录。只清除 BFF 会话的话,签发方那边的令牌会一直存活到过期。要先通知 RFC 7009 的撤销端点。
在现场相遇的样子
接入 BFF 之后最常见的事故,是“为了方便”令牌又泄露了。有人在会话确认的响应里一并带上 access_token,或者前端想直接调用下游 API,于是再下发一个 JS 能读取的 Cookie 来放令牌。那一刻,BFF 就只剩名字了。反方向也有:下游 API 以“这是内网”为由接受没有令牌的请求,绕过 BFF 的调用就会原样通过。
也要了解它的局限再使用。第 6.1.4.1 节 写明,恶意脚本通过用户的浏览器向 BFF 发送请求的客户端劫持,即使有 BFF 也无法阻止。BFF 消除的是令牌窃取,而不是 XSS 本身。用户关闭标签页,攻击也就结束了,这个差别就是 BFF 的价值所在。
下一项实验要做什么
在一个 Pod 里启动签发方(8312)、下游 API(8311)和 BFF(8310),用 curl 扮演浏览器,走一遍登录流程。检查 BFF 下发的 Cookie 的属性,从 Redis 的 BFF 会话中取出真正的令牌,确认浏览器看到的任何响应里都没有这个值。接着用请求证明:经 BFF 的调用返回 200,直接调用下游返回 401,没有 CSRF 令牌的写入会被拦住,退出登录之后旧令牌在下游会失效。