TT Lab
开始
学习 学习路径 课程

会话与令牌 — 从浏览器到服务网格

用 BFF 把令牌留在浏览器之外

在 TT Lab 中继续学习

目标

在一个 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 就只剩名字。本实验让你用请求分别确认这三个缺口。

步骤

  1. 用一个 /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}。
  2. 按 GET /login → 签发方 POST /authorize → BFF /callback 的顺序登录,把回调下发的 Set-Cookie 保存为一行,写入 /root/st/bff/cookie.txt。Cookie 的名称是 __Host-Http-bff,带有 HttpOnly、Secure、SameSite=Strict、Path=/。
  3. 确认 Redis 的 bff:sess:<세션ID>(占位符为会话 ID)中有 access_token,且该值在浏览器收到的响应的头部和正文中都不存在,把结果写入 /root/st/bff/exposure.txt。
  4. 用以 bob 登录的 Cookie 调用 GET /bff/api/me,确认 BFF 附上 Bearer 后在下游得到 200 和 sub=bob,且 Cookie 没有被转给下游,把结果写入 /root/st/bff/proxy.txt。
  5. 直接调用下游 /me:不带令牌、只带会话 Cookie、带随意编造的 Bearer,确认都返回 401 和 WWW-Authenticate: Bearer,把结果写入 /root/st/bff/direct.txt。
  6. 确认 POST /bff/api/notes 只有在 X-CSRF-Token 正确时才返回 201,缺少或错误则返回 403,把结果写入 /root/st/bff/csrf.txt。
  7. 用 curl -X POST 退出登录后,确认 BFF 会话消失,且旧 access token 在下游返回 401,把结果写入 /root/st/bff/logout.txt。
  8. 用 /root/st/bff/e2e.sh 测试整个流程,在 /root/st/bff/e2e.out 中留下六行。

参考

启动签发方、下游 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,查看是否得到同样的结果。

把前面各步用一个脚本串起来。脚本要删除自己创建的临时文件,并且只通过标准输出给出结果,这样重新运行也是安全的。