用 BFF 把令牌留在浏览器之外
目标
在一个 Pod 里启动签发方(8312)、下游 API(8311)和 BFF(8310),并用真实的 HTTP 请求证明:access token 从未出现在浏览器能看到的任何地方,而且只有经过 BFF 的调用才能在下游通过。
为什么重要
SPA 如果自己持有令牌,只要该页面上运行的某一个脚本被污染,令牌就会流到外部,攻击者直到过期都能使用该令牌。RFC 10017(OAuth 2.0 for Browser-Based Applications)第 6.1 节的 BFF 把令牌只放在服务器端会话中,只给浏览器一个 HttpOnly 会话 Cookie。BFF 作为机密客户端把授权码换成令牌,转给下游时去掉 Cookie 并附上 Bearer 令牌。这种结构只要有一处松动就会崩溃——会话确认的响应里带上令牌、下游接受没有令牌的请求、退出登录不撤销令牌,BFF 就只剩名字。本实验让你用请求分别确认这三个缺口。
步骤
- 用一个
/root/st/bff/app.py启动签发方(127.0.0.1:8312)、下游 API(127.0.0.1:8311)和 BFF(127.0.0.1:8310)。三处的GET /health都返回 200 和{"ok":true}。 - 按
GET /login→ 签发方POST /authorize→ BFF/callback的顺序登录,把回调下发的Set-Cookie保存为一行,写入/root/st/bff/cookie.txt。Cookie 的名称是__Host-Http-bff,带有HttpOnly、Secure、SameSite=Strict、Path=/。 - 确认 Redis 的
bff:sess:<세션ID>(占位符为会话 ID)中有access_token,且该值在浏览器收到的响应的头部和正文中都不存在,把结果写入/root/st/bff/exposure.txt。 - 用以 bob 登录的 Cookie 调用
GET /bff/api/me,确认 BFF 附上 Bearer 后在下游得到 200 和 sub=bob,且 Cookie 没有被转给下游,把结果写入/root/st/bff/proxy.txt。 - 直接调用下游
/me:不带令牌、只带会话 Cookie、带随意编造的 Bearer,确认都返回 401 和WWW-Authenticate: Bearer,把结果写入/root/st/bff/direct.txt。 - 确认
POST /bff/api/notes只有在X-CSRF-Token正确时才返回 201,缺少或错误则返回 403,把结果写入/root/st/bff/csrf.txt。 - 用
curl -X POST退出登录后,确认 BFF 会话消失,且旧 access token 在下游返回 401,把结果写入/root/st/bff/logout.txt。 - 用
/root/st/bff/e2e.sh测试整个流程,在/root/st/bff/e2e.out中留下六行。
参考
- 服务器用
setsid nohup python3 /root/st/bff/app.py > /root/st/bff/app.log 2>&1 &启动;如果修改了代码,先用pkill -f /root/st/bff/app.py停掉再重新启动。 - 实验 Pod 中没有 python-multipart,所以表单正文用
urllib.parse.parse_qs自行读取。 - 为了缩短流程,本实验省略了 PKCE 和签发方的登录界面。若在实际工作中使用签发方,请先确认其相关设置。
- 常见错误 1:再通过会话确认的响应或 JS 可读取的 Cookie 下发一次令牌——把令牌从浏览器中撤走的意义就没有了。
- 常见错误 2:退出登录时只删除 BFF 会话——即使令牌从未泄露过,在签发方那边它也会一直存活到过期。
启动签发方、下游 API 和 BFF
用一个 /root/st/bff/app.py 启动签发方(127.0.0.1:8312)、下游 API(127.0.0.1:8311)和 BFF(127.0.0.1:8310)。三处的 GET /health 都返回 200 和 {"ok":true}。
在一个进程里用线程运行三个 ThreadingHTTPServer,启动和停止一次就能完成。把服务器放到后台运行,并轮询到三个端口都有响应为止。
浏览器收到的只有一个会话 Cookie
按 GET /login → 签发方 POST /authorize(表单 user=alice&pw=wonderland)→ BFF /callback 的顺序登录,把回调下发的 Set-Cookie 保存为一行,写入 /root/st/bff/cookie.txt。Cookie 名称是 __Host-Http-bff,带有 HttpOnly、Secure、SameSite=Strict、Path=/,没有 Domain。
用 curl -w '%{redirect_url}' 取出 302 的 Location 并传给下一个请求,就可以像浏览器一样走完流程。向签发方提交账号的请求是 POST。RFC 10017 第 6.1.3.2 节把 HttpOnly 和 Secure 定为 MUST。
令牌只存在于 BFF 会话中
登录后,确认 Redis 的 bff:sess:<세션ID>(占位符为会话 ID;JSON)中有 access_token,并核对该值不出现在回调响应和 GET /bff/session 响应的头部与正文中,按 token_in_store=yes、token_in_body=no、token_in_cookie=no 写入 /root/st/bff/exposure.txt。GET /bff/session 只返回 auth、user、csrf。
会话 ID 从 Cookie 罐中取,真实令牌用 redis-cli GET 取出。再用 grep -F 在各个响应文件里找这个字符串。一旦为了方便在会话确认响应里带上令牌,BFF 就只剩名字了。
BFF 附上 Bearer 调用下游
用以 bob/builder 登录的 Cookie 调用 GET /bff/api/me,BFF 会去掉 Cookie,附上 Authorization: Bearer <토큰>(占位符为令牌)转给下游 GET /me,得到 200 和 {"sub":"bob"}。下游 GET /inspect 通过 cookie_forwarded 告知 Cookie 是否被转过来。把结果按 me=200、sub=bob、cookie_forwarded=False 写入 /root/st/bff/proxy.txt。
BFF 把 /bff/api/ 之后的路径转给下游,不要把请求头整个复制,而要重新生成 Authorization。下游必须用 401 拦住没有令牌的请求,这样经 BFF 得到的 200 才是 BFF 附上了令牌的证据。
直接调用下游返回 401
绕过 BFF,直接对下游 GET http://127.0.0.1:8311/me 分别发起三次调用:不带令牌、只带 BFF 会话 Cookie、带随意编造的 Bearer 值,确认都返回 401 并带有 WWW-Authenticate: Bearer,按 no_token=401、cookie_only=401、bogus_bearer=401、www_authenticate=Bearer 写入 /root/st/bff/direct.txt。没有令牌的 POST /notes 也必须返回 401。
下游不看 Cookie,只看 Authorization 请求头。令牌是否有效,请向签发方的 introspection 查询来判断。RFC 6750 第 3 节规定了 401 响应要带的请求头。
用 CSRF 令牌保护 BFF 的写入
只有把 GET /bff/session 的 csrf 值通过 X-CSRF-Token 头发送时,POST /bff/api/notes(表单 text=...)才会转给下游并返回 201;缺少或错误则返回 403,且不会记录到下游,按 no_header=403、wrong=403、right=201 写入 /root/st/bff/csrf.txt。
浏览器会自动附带 Cookie,所以仅凭 Cookie 无法与伪造请求区分。把 CSRF 令牌一起放进会话,并用 hmac.compare_digest 比较。拒绝必须发生在调用下游之前。
退出登录同时终结会话和令牌
登录后用 curl -X POST 调用 POST /logout(带 Cookie 和 X-CSRF-Token),确认同一个 Cookie 的 GET /bff/session 中 auth 为 False、Redis 的 bff:sess:<세션ID>(占位符为会话 ID)消失、用退出登录之前取出的 access token 直接调用下游 /me 返回 401,按 session_auth=False、store_key_gone=yes、old_token_direct=401 写入 /root/st/bff/logout.txt。
只删除会话键,签发方那边的令牌会存活到过期。退出登录时,BFF 要先调用签发方的撤销端点(RFC 7009)。不加 -X POST 的话,curl 会发送 GET。
一次性证明完整流程
用 /root/st/bff/e2e.sh 从登录测试到退出登录,在 /root/st/bff/e2e.out 中留下 cookie_httponly=yes、token_exposed=no、via_bff=200、direct_no_token=401、csrf_missing=403、after_logout_token=401 六行。评分器还会重新运行 e2e.sh,查看是否得到同样的结果。
把前面各步用一个脚本串起来。脚本要删除自己创建的临时文件,并且只通过标准输出给出结果,这样重新运行也是安全的。