OAuth 2.1 与 BFF 模式下的登录态设计
OAuth 2.1 把 2.0 里那些"可选但实际必须"的做法收成默认:强制 PKCE、禁隐式流、禁密码模式。但真正的难点从来不在协议,而在登录态放哪:localStorage、Cookie、还是干脆用 BFF。本文给一套能同时支撑 Web、移动端与合作方接入的登录态设计。
一、四种方案的真实风险
| 方案 | XSS | CSRF | 适用 | |---|---|---|---| | localStorage + Bearer 头 | 可被窃取(无 HttpOnly 保护) | 免疫 | 纯 SPA 内部工具 | | Session Cookie(HttpOnly) | 令牌不出浏览器 | 必须防护 | 服务端渲染站点 | | 内存 + 静默刷新 | XSS 难窃取 | 免疫 | 高安全后台 | | BFF(HttpOnly Cookie + 服务端持令牌) | 最优 | 需 CSRF 双提交 | 生产 Web 首选 |
关键事实:localStorage 里没有任何东西能防 XSS。一次第三方脚本注入就能把令牌全量带走。
二、BFF 的结构
Browser ──HttpOnly Cookie(session id)──▶ BFF ──access_token──▶ 资源服务
└── token 存储(Redis/加密内存)
浏览器永远拿不到 access_token,只拿到一个随机会话标识。刷新、撤销、权限收敛都发生在服务端,可控性最高。
POST /api/v1/auth/login
Set-Cookie: okgoes_session=AbCd...; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=604800
SameSite 选择:Lax 兼顾跳转场景(大多数站点够用);若存在跨站嵌入的第三方组件,才需要 None; Secure 并承担 CSRF 面。
Cookie 属性一致性是常见事故点:代理层没透传 Set-Cookie 的 Secure,或前后端不同子域导致 domain 不匹配,表现就是"登录成功但刷新后未登录"。
三、CSRF 防护的正确做法
SameSite 不是万能盾牌,仍要加 CSRF token:
func CSRFMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if isUnsafe(r.Method) {
got := r.Header.Get("X-CSRF-Token")
if subtle.ConstantTimeCompare([]byte(got), []byte(session.CSRFToken(r))) != 1 {
http.Error(w, "csrf", http.StatusForbidden)
return
}
}
next.ServeHTTP(w, r)
})
}
配合前端把 token 放在可读写 Cookie 中(双提交模式)或接口下发。注意:只对改状态的方法校验,GET 一律不校验,否则预取与刷新全炸。
四、PKCE 的落地细节
PKCE 防的是授权码被拦截,Web SPA 与移动端都必须用。
function verifier() {
const buf = new Uint8Array(32);
crypto.getRandomValues(buf);
return base64url(buf);
}
async function challenge(v) {
const d = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(v));
return base64url(new Uint8Array(d));
}
const code_verifier = verifier();
sessionStorage.setItem("pkce_v", code_verifier);
const url = new URL(AUTHORIZE);
url.searchParams.set("code_challenge", await challenge(code_verifier));
url.searchParams.set("code_challenge_method", "S256");
url.searchParams.set("state", state);
url.searchParams.set("redirect_uri", REDIRECT);
location.assign(url);
三条容易漏的:redirect_uri 必须精确匹配(不允许前缀匹配);state 要做一次性校验(存 sessionStorage,用完即删);code_verifier 校验失败必须终止,不能降级成无 PKCE。
五、Token 轮换与撤销窗口
浏览器 Cookie 的有效期 ≠ access_token 的有效期。BFF 里两者分开:
session_ttl: 7d # Cookie 会话长度
access_token_ttl: 15m # 发给资源服务的令牌
refresh_token_ttl: 32d # 轮换式刷新令牌
刷新令牌必须一次性使用 + 轮换,并在检测到"旧 refresh_token 被重复使用"时判定为泄露,撤销整条令牌族:
if stored.FamilyID != presented.FamilyID || presented.Used {
revokeFamily(stored.FamilyID) // 全族失效
log.Warn("refresh token reuse detected", "family", stored.FamilyID)
return ErrTokenReuse
}
六、前后端分离项目里最容易犯的四个错
- 在响应体里返回 access_token 同时又下发 HttpOnly Cookie —— 等于把令牌同时暴露在两条通道,BFF 的安全收益归零;
- 前端用 401 触发登出时不清 Cookie —— 服务端已吊销,但前端仍显示已登录,形成僵尸态;
- 登录跳转的 redirect 参数未做白名单校验 —— 变成开放重定向。校验要限制同源且拒绝
//host、\等绕过形式;
func safeRedirect(raw string) string {
if !strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "//") || strings.HasPrefix(raw, `/\`) {
return "/"
}
if u, err := url.Parse(raw); err == nil && (u.IsAbs() || u.Host != "") {
return "/"
}
return raw
}
- 把角色写进 JWT 且不做实时校验 —— 权限改了但旧 token 仍有权限。生产做法是 JWT 只放身份,角色走服务端实时查询(或短 TTL + 显式失效通道)。
七、给合作方/服务的第三种入口
浏览器走 BFF,服务端到服务端不该复用同一套。给合作方发 client_credentials 凭据,独立限流与 scopes,独立审计。三条通道彻底隔离:
Web 用户 → Cookie Session(BFF)
移动端 → PKCE 授权码 + 本地存储(更短 TTL,支持系统级密钥库)
合作方/内部服务 → client_credentials + mTLS 或签名
任何"把浏览器 token 给服务间调用用"的做法,都会在审计和撤销阶段付出代价。
小结
登录态设计的核心判断只有一个:access_token 应该在哪里存活。对面向公网的产品,BFF + HttpOnly Cookie 是最稳的答案;移动端用 PKCE 授权码 + 系统密钥库;服务间用 client_credentials。在此之上,CSRF 双提交、refresh token 轮换与重用检测、redirect 白名单、角色实时化四件事决定了实现的成熟度。协议细节容易抄,安全边界的一致性只能靠纪律。
评论(0)
登录 后参与评论。