土法炼钢兴趣小组的算法知识备份

【身份与访问控制工程】Session、Refresh Token 与吊销体系

文章导航

分类入口
architecturesecurity
标签入口
#JWT#refresh-token#revocation#OIDC#SLO#Redis#bloom-filter

目录

某金融 App 的用户凌晨两点收到推送:在陌生城市登录。用户打开 App,点「退出所有设备」。三秒后运营后台收到风控告警:该账号在另一个 IP 上刚刚完成了一笔 8 万元的转账——原因是攻击者拿到的 access token 还有 11 分 20 秒才过期,而「退出所有设备」的按钮只是把 refresh token 从数据库里删了。

这不是假设,是 JWT 无状态设计的固有代价。上一篇 讲完了 JWT 的签发与验证,本篇要回答的问题只有一个:令牌签出去之后,怎么把它收回来。答案不是「用 Session 就行了」,也不是「把 exp 改短就行了」,而是一整套由 access token、refresh token、rotation、reuse detection、黑名单、pub/sub 广播、OIDC logout 端点组成的吊销体系。

Token 吊销架构

本文所有代码都按生产可落地的方向写,Redis 键、TTL、Bloom filter 参数都给具体数值。如果你正在做 认证架构设计 或者在为 OAuth2 授权服务器 实现 /revoke 端点,这篇是配套手册。


一、无状态 JWT 的吊销困境

1.1 吊销是一个状态问题

JWT 的核心卖点是无状态(stateless):服务端不用存任何 session 表,拿到 token 验签、查 exp、查 aud,通过就认了。这让水平扩展变得极其简单——加机器就完事,不需要黏性会话、不需要共享存储。

但「吊销」本质上是一个状态变更:在某个时刻 T,系统决定这个 token 从此刻起不再有效。无状态验签拿不到这个「此刻起」的信息。签发时嵌入 exp 确定了一个未来的失效时间点,但吊销要求一个任意的失效时间点。

这两个目标数学上是矛盾的:只要验签不查询任何共享状态,系统就无法知道某个 token 是否在 T 时刻被吊销过。想解决吊销,要么放弃纯无状态(引入共享状态查询),要么放弃吊销语义(只靠过期时间收敛)。

1.2 为什么「把 exp 调短」不够

最朴素的想法:把 access token TTL 从 1 小时改成 1 分钟,这样最多泄露 1 分钟。这条路有三个死胡同。

第一,每分钟重新登录一次显然不可接受。于是必须引入 refresh token——长期有效的凭据,用它定期换新的 access token。问题只是被推后了:refresh token 本身也会被泄露,refresh token 的吊销回到原问题。

第二,1 分钟在很多攻击场景下足够造成损失。自动化的撞库工具每秒可以发起几百个请求,一分钟内可以完成密码修改、邮箱绑定、资金转出、下载私密数据。把 TTL 压到 10 秒呢?那签名验证本身的开销(每次 RPC 都 RS256 验签一次)会成为瓶颈,auth server 的签发 QPS 也会被放大 100 倍。

第三,时钟不同步会让短 TTL 变成灾难。集群里机器的 NTP 偏差通常在 100ms 以内,但极端情况可能到几秒甚至几十秒(虚拟机漂移、NTP 服务挂了、跨机房时区配置错误)。access token TTL 1 分钟、允许 leeway 30 秒,某台机器偏快 20 秒,用户会遇到「刚签发的 token 已过期」。

业界收敛的参数大致是:

场景 access token TTL refresh token TTL 说明
公网 Web / 移动端 5-15 分钟 7-30 天 主流
金融 / 医疗高敏感 1-5 分钟 24 小时,idle 30 分钟 强制 MFA 重验
机器间(workload) 5-60 分钟 不用(mTLS 或 SPIFFE) 没有人在操作
设备绑定(IoT) 1 天 180 天,device-bound 配合 DPoP / mTLS

5-15 分钟是一个经验值:既短到让吊销延迟大多数时候可接受,又长到让 auth server 的刷新 QPS 不爆炸。要做到比 5 分钟更短的强实时吊销,必须引入显式的吊销列表或者 token introspection。

1.3 Session 方案为什么不能直接搬回来

有人会说:那我不用 JWT 了,退回 session 不就行了。在 认证架构 那篇讲过,session 方案在微服务 / SSO 架构下有本质性的问题:

所以主流方案不是在 session 和 JWT 之间选一个,而是用 JWT 做常规验证、用 session 风格的共享状态做吊销检查——这就是下一节要讲的混合模式。


二、混合模式:短 access token + 长 refresh token + 吊销列表

2.1 职责划分

整个模型里有四个主体:

2.2 黑名单 vs 白名单

两种思路,选哪个取决于日吊销事件数和日签发事件数的比例。

白名单(allowlist):所有有效 token 都进存储,验证时查存在性。等价于 session 模式,每请求查存储。

黑名单(denylist):只记录被吊销的 token,验证时查不存在性。

大多数互联网系统选黑名单 + bloom filter 组合,金融和企业级系统选白名单。下面的实现都以黑名单为主,必要时会标注白名单的差异。

2.3 端到端流程

登录(首次):
  Client  POST /login {user, pwd, device_id}
  Auth    验证凭据 → 生成 AT (exp=15m) + RT (exp=30d, family_id=F1)
          存 RT: SET rt:{hash(rt)} {uid, fid=F1, parent=null} EX 30d
  Client ← 200 {access_token, refresh_token}

API 调用:
  Client  GET /api/xxx   Authorization: Bearer AT
  RS      1. 验签(本地 JWKS)
          2. 校验 exp / aud / iss
          3. 查黑名单 revoked:{jti}(bloom filter 先过,miss 跳过 Redis)
          4. 通过 → 返回业务数据

刷新:
  Client  POST /token {grant_type=refresh_token, refresh_token=RT1}
  Auth    1. hash(RT1) 查 rt:{...} 是否存在
          2. 若不存在 → reuse detection → 吊销 family F1 全部
          3. 若存在 → 标记 RT1 已使用 / 删除 → 生成 AT2 + RT2(family_id=F1, parent=RT1)
          4. SET rt:{hash(RT2)} ... EX remaining_lifetime
  Client ← 200 {access_token=AT2, refresh_token=RT2}

吊销(用户主动或管理员):
  Admin   POST /revoke {user=U, scope=all}
  Auth    1. 扫描 user_sessions:{U} 得到所有 fid
          2. 批量 SET revoked:{fid}:* + SET user_rev:{U} now()
          3. PUBLISH revoke {type=user, uid=U, not_before=now()}
  RS 节点  SUBSCRIBE revoke → 更新本地 bloom + cache

几个关键细节:


三、Refresh Token Rotation 与 Reuse Detection

3.1 为什么要 rotation

一个 refresh token 活 30 天,用户用手机 App 每天刷新 5 次,30 天就是 150 次刷新。如果 RT 不轮换,那这 30 天里任何一次网络包被截获、任何一次设备被借用留下的 token 残留,都够攻击者在接下来几天里无限续命。

Rotation 的规则很简单:每次用 RT 换 AT 时,同时发一个新 RT、废掉旧 RT。合法用户拿到新 RT,旧 RT 被标记为已使用(used)。下次刷新用新 RT,再发新 RT,如此链式推进。

单独看 rotation 并不能阻止攻击:攻击者拿到 RT 后立刻刷新,也能拿到新 RT,后续他能用下去。真正有用的是配合 reuse detection

3.2 Reuse Detection 与 family-based abort

核心观察:一个 RT 只应被使用一次。如果服务端看到同一个 RT 被用了第二次,必然有一方是攻击者。两种情况:

  1. 合法用户先刷新,RT1 → RT2,合法用户拿到 RT2;攻击者后来用 RT1 刷新 → 服务端发现 RT1 已使用。
  2. 攻击者先刷新,RT1 → RT2’(攻击者拿到 RT2’);合法用户后来用 RT1 刷新 → 服务端发现 RT1 已使用。

服务端无法分辨哪一个是攻击者,但可以确定:这个家族已经被污染。正确响应是把整个 family 的所有 RT 全部吊销,强制双方重新登录。合法用户会被迫登录一次,攻击者失去所有凭据。这就是 family-based abort。

family_id = F1
RT1 (used) → RT2 (used) → RT3 (active)
             ↑
     攻击者重放 RT1

检测:RT1 already used
动作:DEL family:F1, DEL 所有 rt:{hash(RTi)} where fid=F1
     PUBLISH revoke {type=family, fid=F1}
     所有 AT(jti 属于 F1)进黑名单直到各自 exp

实现存储结构:

rt:{hash(RT)}          → {uid, fid, parent_jti, used=false, exp}
family:{fid}           → {uid, created_at, last_rt_jti, status=active|compromised}
user_sessions:{uid}    → SET of fid(用户当前所有 family)

rotation 时的原子操作至关重要,必须用 Lua 或 MULTI:

-- KEYS[1] = rt:{hash(old_rt)}   KEYS[2] = family:{fid}
-- ARGV[1] = new_rt_json         ARGV[2] = new_rt_key
-- ARGV[3] = ttl_seconds
local rt = redis.call('GET', KEYS[1])
if not rt then
  redis.call('HSET', KEYS[2], 'status', 'compromised')
  return 'REUSE'
end
local parsed = cjson.decode(rt)
if parsed.used then
  redis.call('HSET', KEYS[2], 'status', 'compromised')
  return 'REUSE'
end
redis.call('DEL', KEYS[1])
redis.call('SET', ARGV[2], ARGV[1], 'EX', ARGV[3])
redis.call('HSET', KEYS[2], 'last_rt_jti', ARGV[2])
return 'OK'

如果返回 REUSE,应用层触发 family 全部吊销:

func (s *AuthServer) abortFamily(ctx context.Context, fid string) error {
    keys, err := s.redis.Keys(ctx, "rt:*:fid="+fid).Result()
    if err != nil { return err }
    if len(keys) > 0 { s.redis.Del(ctx, keys...) }

    members, _ := s.redis.SMembers(ctx, "family_jtis:"+fid).Result()
    pipe := s.redis.Pipeline()
    for _, jti := range members {
        pipe.Set(ctx, "revoked:"+jti, "1", s.atMaxTTL)
    }
    pipe.SAdd(ctx, "compromised_families", fid)
    pipe.Publish(ctx, "revoke", fmt.Sprintf(`{"type":"family","fid":"%s"}`, fid))
    _, err = pipe.Exec(ctx)
    return err
}

生产实现里 Keys * 要换成 SCAN 或者直接用反向索引(family:fid 下维护一个 SET),千万不要在生产 Redis 上 KEYS 大量键。

3.3 Family abuse 检测

Reuse detection 解决单个 RT 被重放的情况,但另一类异常需要额外检测:同一 family 在短时间内产生了异常多的 rotation

合法用户一天刷新 5-20 次很常见。但如果一个 family 在 1 小时内刷新了 200 次、同时 user-agent 频繁变化、同时来自多个 IP 段——这是典型的滥用。可能是:

检测规则示例:

def detect_family_abuse(fid: str, new_event: RefreshEvent) -> bool:
    key = f"family_metrics:{fid}"
    now = int(time.time())
    pipe = r.pipeline()
    pipe.zadd(key, {f"{new_event.jti}:{now}": now})
    pipe.zremrangebyscore(key, 0, now - 3600)
    pipe.zcard(key)
    pipe.expire(key, 7200)
    _, _, count, _ = pipe.execute()

    ips = r.smembers(f"family_ips:{fid}")
    uas = r.smembers(f"family_uas:{fid}")
    r.sadd(f"family_ips:{fid}", new_event.ip); r.expire(f"family_ips:{fid}", 7200)
    r.sadd(f"family_uas:{fid}", new_event.ua); r.expire(f"family_uas:{fid}", 7200)

    if count > 100:                    return True
    if len(ips) > 10 and count > 30:   return True
    if len(uas) > 5 and count > 20:    return True
    return False

命中后走 family abort 流程,但通常会降级为「要求用户重新登录 + MFA」,而不是直接封号,以避免误伤。

3.4 Rotation 的并发陷阱

移动端很容易出现并发刷新:App 主进程和 WebView 同时发现 AT 过期,同时用同一个 RT 发起 /token 请求。如果服务端严格要求 RT 只能用一次,其中一个请求会被判定为 reuse,family 被误杀。

工程上的两种处理方式:

宽限窗口(grace window):RT 被使用后不立刻删除,而是保留 10-30 秒,这段时间内相同 RT 的再次请求返回相同的新 RT(幂等)。超过窗口后再请求才判定 reuse。

func (s *AuthServer) rotate(ctx context.Context, oldRT string) (*TokenPair, error) {
    key := "rt:" + hash(oldRT)
    val, err := s.redis.Get(ctx, key).Result()
    if err == redis.Nil {
        cached, err2 := s.redis.Get(ctx, "rt_grace:"+hash(oldRT)).Result()
        if err2 == nil {
            return parseTokenPair(cached), nil
        }
        s.abortFamily(ctx, extractFid(oldRT))
        return nil, ErrReuseDetected
    }
    newPair := s.mintTokens(...)
    s.redis.Set(ctx, "rt_grace:"+hash(oldRT), serialize(newPair), 30*time.Second)
    s.redis.Del(ctx, key)
    s.redis.Set(ctx, "rt:"+hash(newPair.RT), ..., 30*24*time.Hour)
    return newPair, nil
}

客户端互斥锁:App 层面保证同一时刻只有一个请求在刷新,其他请求等待。大多数成熟 SDK(AWS Amplify、Auth0 SDK、MSAL)都内置这个逻辑。

两种方法通常组合使用:客户端尽力避免并发,服务端 grace window 兜底。


四、统一登出(SLO):Back-channel vs Front-channel

4.1 SLO 的真实难度

Single Logout(SLO)说起来简单:用户在任意一个应用点退出,所有 SSO 关联的应用都跟着退出。实际做起来是整个 OIDC 规范里最不稳定的部分,很多 IdP 直接标注 SLO 为「尽力而为,不保证」。

难度来自两个方向:

  1. 用户已经离开那个 App 的页面。点退出时用户在 App A,App B C D 的页面可能没打开。怎么让 B C D 知道该登出?
  2. 跨域的浏览器状态无法直接同步。App B 的 cookie 在 b.com 域下,A 的退出请求无法直接操作 b.com 的 cookie。

OIDC 规范给了三种模式:RP-Initiated Logout、Front-Channel Logout、Back-Channel Logout。三个不是互斥的,实际部署往往组合。

4.2 RP-Initiated Logout:用户主动触发

用户在某个 RP(Relying Party,依赖方)点「退出」,RP 通过重定向把浏览器带到 OP(OpenID Provider)的 end_session_endpoint

GET /oidc/logout?
    id_token_hint=eyJhbGciOi...
    &post_logout_redirect_uri=https://app.example.com/goodbye
    &state=af0ifjsldkj

参数含义:

OP 收到后执行:

  1. id_token_hint 定位 OP 端 session。
  2. 清除 OP 的 session cookie。
  3. 吊销 RT / AT(把 family 标记为 logged_out)。
  4. 触发 front-channel 或 back-channel 通知其他 RP。
  5. 重定向回 post_logout_redirect_uri

这一步只是「这个 RP 知道了用户要登出」。其他 RP 怎么同步,靠下面两种机制。

4.3 Front-Channel Logout

OP 在登出页面里嵌一堆隐藏 iframe,每个 iframe 的 src 指向各个 RP 在注册时填的 frontchannel_logout_uri

<iframe src="https://app-a.example.com/oidc/logout?sid=xxx&iss=..."></iframe>
<iframe src="https://app-b.example.com/oidc/logout?sid=xxx&iss=..."></iframe>
<iframe src="https://app-c.example.com/oidc/logout?sid=xxx&iss=..."></iframe>

每个 RP 在自己的 frontchannel_logout_uri 里清除自己的 session cookie。

优点:实现简单,不需要 RP 和 OP 之间建立 server-to-server 连接,跨云跨网络边界天然能工作。

缺点致命:

结论:2026 年不要把 front-channel 当作主方案,它只适合作为 back-channel 不可用时的降级。

4.4 Back-Channel Logout

OP 直接 server-to-server POST 一个 logout_token 到每个 RP 的 backchannel_logout_uri

POST /oidc/backchannel-logout HTTP/1.1
Host: app-a.example.com
Content-Type: application/x-www-form-urlencoded

logout_token=eyJhbGciOiJSUzI1NiIsImtpZCI6IjEifQ...

logout_token 是一个 JWT,必要字段:

{
  "iss": "https://op.example.com",
  "sub": "user-uuid",
  "aud": "app-a-client-id",
  "iat": 1713600000,
  "jti": "b4e8f1...",
  "events": {
    "http://schemas.openid.net/event/backchannel-logout": {}
  },
  "sid": "session-id"
}

RP 收到后:

  1. 验证 JWT 签名(用 OP 的 JWKS)、iss / aud / iat
  2. 检查 events claim 里有 backchannel-logout
  3. 禁止出现 nonce(规范要求)。
  4. sidsub 定位本地 session,清除。
  5. 幂等处理 jti,防重放。
  6. 返回 HTTP 200(空 body)或 400(失败)。

Back-channel 的处理器:

func backchannelLogout(w http.ResponseWriter, r *http.Request) {
    r.ParseForm()
    raw := r.FormValue("logout_token")
    tok, err := jwt.Parse(raw, jwks.Keyfunc)
    if err != nil || !tok.Valid {
        http.Error(w, "invalid token", 400); return
    }
    claims := tok.Claims.(jwt.MapClaims)
    if claims["iss"] != expectedIssuer {
        http.Error(w, "bad iss", 400); return
    }
    if claims["aud"] != clientID {
        http.Error(w, "bad aud", 400); return
    }
    if _, ok := claims["nonce"]; ok {
        http.Error(w, "nonce not allowed", 400); return
    }
    events, _ := claims["events"].(map[string]interface{})
    if _, ok := events["http://schemas.openid.net/event/backchannel-logout"]; !ok {
        http.Error(w, "missing event", 400); return
    }
    jti, _ := claims["jti"].(string)
    if redis.SetNX(ctx, "logout_jti:"+jti, "1", 10*time.Minute).Val() == false {
        w.WriteHeader(200); return
    }
    sid, _ := claims["sid"].(string)
    sub, _ := claims["sub"].(string)
    if sid != "" {
        destroySessionBySID(sid)
    } else if sub != "" {
        destroyAllSessionsForUser(sub)
    }
    w.WriteHeader(200)
}

优点:不依赖浏览器,server 间可靠传输,能做重试和死信队列。

缺点

4.5 三种模式对比

维度 RP-Initiated Front-Channel Back-Channel
触发方 用户点击 OP 浏览器 OP 服务器
传输 浏览器重定向 iframe 加载 server-to-server POST
可靠性 低(浏览器依赖) 高(可重试)
跨域 cookie 不需要 常被浏览器拦截 不涉及
延迟 毫秒级 秒级,取决于网络 秒级,取决于 RP 处理
需要 RP 公网可达 不需要 不需要 需要
OIDC 规范成熟度 Stable Stable 但问题多 Stable,推荐

2026 年推荐配置:RP-Initiated 作为入口,Back-Channel 作为主传播,Front-Channel 只在 back-channel 无法部署时兜底


五、OIDC Logout 端点与元数据

5.1 Discovery 暴露的端点

OP 的 /.well-known/openid-configuration 应该包含:

{
  "issuer": "https://op.example.com",
  "authorization_endpoint": "https://op.example.com/oauth2/authorize",
  "token_endpoint": "https://op.example.com/oauth2/token",
  "revocation_endpoint": "https://op.example.com/oauth2/revoke",
  "introspection_endpoint": "https://op.example.com/oauth2/introspect",
  "end_session_endpoint": "https://op.example.com/oidc/logout",
  "backchannel_logout_supported": true,
  "backchannel_logout_session_supported": true,
  "frontchannel_logout_supported": true,
  "frontchannel_logout_session_supported": true
}

backchannel_logout_session_supported 为 true 表示 logout_token 里会包含 sid,RP 可以精确定位 session 而不用按 sub 登出用户所有会话。

5.2 Client 注册时的关键字段

在 OP 注册 client 时必须登记:

{
  "client_id": "app-a",
  "redirect_uris": ["https://app-a.example.com/callback"],
  "post_logout_redirect_uris": ["https://app-a.example.com/goodbye"],
  "backchannel_logout_uri": "https://app-a.example.com/oidc/bc-logout",
  "backchannel_logout_session_required": true,
  "frontchannel_logout_uri": "https://app-a.example.com/oidc/fc-logout",
  "frontchannel_logout_session_required": true
}

5.3 /revoke 端点:RFC 7009

OAuth 2 Token Revocation(RFC 7009)定义了 revocation_endpoint,用于 RP 主动吊销单个 token(通常是 RT,AT 吊销意义不大因为 TTL 本来就短):

POST /oauth2/revoke HTTP/1.1
Host: op.example.com
Authorization: Basic base64(client_id:client_secret)
Content-Type: application/x-www-form-urlencoded

token=eyJhbGc...&token_type_hint=refresh_token

成功返回 200(无 body),无论 token 是否存在都返回 200,防止探测攻击。失败只有 400 / 401 / 503。

服务端实现要注意:

5.4 /introspect 端点:RFC 7662

Token Introspection 让 RS 向 auth server 查询 token 状态:

POST /oauth2/introspect
Authorization: Basic base64(rs_id:rs_secret)
token=eyJhbGc...

→ 200 {"active": true, "scope": "read write", "exp": 1713605000, "sub": "u-123", ...}
→ 200 {"active": false}

这是从 JWT 退回类 session 的机制:每次请求都问 auth server 一次,牺牲性能换实时吊销。

工程选择:

推荐混合路线。introspection 结果可以在 RS 本地缓存 5-30 秒,仍然比纯 JWT 的 TTL 短得多。


六、批量吊销场景

6.1 改密触发全设备吊销

最常见的场景。用户改密成功后,旧的所有 session 必须失效——因为改密往往意味着用户怀疑密码泄露。

实现思路是给用户维度加一个「吊销基线」:

user_rev:{uid} = <timestamp>   # 用户所有 token 在这个时间戳之前签发的全部无效

签发时把 iat 或一个新字段 session_start 写进 JWT。RS 验证时除了查 revoked:{jti},再查 user_rev:{uid},如果 token 的 iat < user_rev[uid],视为已吊销。

func validateToken(ctx context.Context, tok *jwt.Token) error {
    if revoked(ctx, tok.Claims.JTI) { return ErrRevoked }
    baseline, err := redis.Get(ctx, "user_rev:"+tok.Claims.Sub).Int64()
    if err == nil && tok.Claims.IAT < baseline {
        return ErrRevoked
    }
    return nil
}

user_rev:{uid} 的 TTL 应该 = AT 最长可能寿命(否则 token 过期后这个 key 不再需要保留)。

6.2 账号封禁

风控判定账号为恶意,封禁 = 改密 + 永久禁止登录。实现:

这里 user_rev 的 TTL 要设为 AT 最长 TTL(比如 15 分钟),15 分钟后即使这个 key 被淘汰也无所谓,因为所有 token 都已过期。

6.3 设备丢失与 device-bound token

用户手机丢了,要把那台设备上的 session 全部吊销,但保留其他设备(电脑、平板)。

前提是签发时把 device_id 记入 token,并在服务端维护 user_sessions:{uid}:{device_id} = {fid, jtis...} 的索引:

登录:
  签发 AT: {sub, device_id, jti, fid}
  存 device_sessions:{uid}:{did} → fid=F1

吊销设备:
  DEL device_sessions:{uid}:{did}
  SET family:F1.status = revoked
  SET revoked:{F1}:* 全部 jti
  PUBLISH revoke {type=device, uid, did}

更强的做法是 device-bound token(DPoP 或 mTLS):AT 在签发时绑定设备的公钥,RS 验签时要求客户端证明私钥持有。设备丢了但攻击者没拿到私钥(例如私钥存在 TEE / Secure Enclave),token 天然失效。这部分展开够另一篇文章。

6.4 租户注销(tenant offboarding)

B2B SaaS 场景,一个企业客户解约,要吊销该租户下所有员工的所有 token。

数据结构:

tenant_sessions:{tid} → SET of (uid, fid)

吊销:

func revokeTenant(ctx context.Context, tid string) error {
    entries, _ := redis.SMembers(ctx, "tenant_sessions:"+tid).Result()
    pipe := redis.Pipeline()
    now := time.Now().Unix()
    for _, e := range entries {
        uid, fid := splitEntry(e)
        pipe.Set(ctx, "user_rev:"+uid, now, 24*time.Hour)
        pipe.HSet(ctx, "family:"+fid, "status", "revoked")
    }
    pipe.Publish(ctx, "revoke", fmt.Sprintf(`{"type":"tenant","tid":"%s"}`, tid))
    pipe.Del(ctx, "tenant_sessions:"+tid)
    _, err := pipe.Exec(ctx)
    return err
}

条目数可能极大(一个企业 1 万员工 × 3 设备 = 3 万 session),要分批 pipeline,单批控制在 500-1000 条以内避免 Redis 阻塞。

6.5 吊销场景对比

场景 粒度 数据结构 TTL 策略 是否需广播
单 token 吊销 jti revoked:{jti} AT 剩余寿命
RT reuse family family:{fid}.status RT 剩余寿命
改密/登出所有设备 user user_rev:{uid} AT 最长 TTL
封号 user 用户表 + user_rev AT 最长 TTL
设备丢失 device device_sessions + family AT 最长 TTL
租户注销 tenant 批量 user 吊销 AT 最长 TTL 是(分批)

七、工程落地:Redis 键设计、Bloom filter、广播

7.1 Redis 键设计

规范化的 key 布局(prefix 可按环境加 env):

rt:{hash12(rt)}              HASH  {uid, fid, parent, used, iat, exp}       EX=RT_TTL
family:{fid}                 HASH  {uid, status, created_at, last_rt_jti}    EX=RT_TTL
family_jtis:{fid}            SET   all AT jti issued in this family          EX=RT_TTL
user_sessions:{uid}          SET   active fid list                            EX=RT_TTL
user_rev:{uid}               STRING baseline timestamp                       EX=AT_MAX_TTL
revoked:{jti}                STRING '1'                                       EX=AT_remaining
device_sessions:{uid}:{did}  HASH  {fid, last_seen}                          EX=RT_TTL
tenant_sessions:{tid}        SET   {uid}:{fid} entries                        EX=RT_TTL
compromised_families         SET   fids marked reused                        EX=24h
logout_jti:{jti}             STRING '1' (back-channel dedup)                  EX=10m

规模估算(1000 万 DAU,平均 2 session/用户):

总内存 5-10 GB,单 Redis 集群分片 3-6 个即可。

7.2 hash 策略

RT 千万不要以明文做 key。用 hash12 表示取 SHA-256 前 12 字节 hex(24 字符),碰撞概率在 2^-96 量级足够。完整 SHA-256 也可以,只是 key 更长。

func hashRT(rt string) string {
    h := sha256.Sum256([]byte(rt))
    return hex.EncodeToString(h[:16])
}

7.3 Bloom filter 快速路径

黑名单的绝对命中率极低(吊销事件稀疏),每请求都打一次 Redis 浪费。RS 本地维护 bloom filter:

参数设计需要回答两个问题:预计元素数 n、可容忍误判率 p。

计算公式:

m (位数)   = - n * ln(p) / (ln 2)^2
k (哈希数) = m / n * ln 2

假设:

代入:

m = -10^6 * ln(10^-4) / (ln 2)^2
  = -10^6 * (-9.21) / 0.4805
  ≈ 1.917 * 10^7 位
  ≈ 2.4 MB
k = m / n * ln 2 ≈ 19.17 * 0.693 ≈ 13

结论:约 2.4 MB 内存、13 个哈希函数,能在 100 万吊销规模下把误判率压到 0.01%。假阳性只会多一次 Redis 查询,不会错放真实吊销。

实现选择:

type RollingBloom struct {
    current *bloom.BloomFilter
    next    *bloom.BloomFilter
    mu      sync.RWMutex
}

func (b *RollingBloom) Test(key string) bool {
    b.mu.RLock(); defer b.mu.RUnlock()
    return b.current.TestString(key) || b.next.TestString(key)
}

func (b *RollingBloom) Rotate(ctx context.Context) {
    fresh := bloom.NewWithEstimates(1_000_000, 0.0001)
    rehydrateFromRedis(ctx, fresh)   // SCAN revoked:* and Add
    b.mu.Lock()
    b.current = b.next
    b.next = fresh
    b.mu.Unlock()
}

7.4 中间件示例(Go)

func AuthMiddleware(jwks *JWKSet, bloom *RollingBloom, rdb *redis.Client) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            raw := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
            if raw == "" { http.Error(w, "no token", 401); return }

            tok, err := jwt.Parse(raw, jwks.Keyfunc)
            if err != nil || !tok.Valid { http.Error(w, "bad token", 401); return }
            c := tok.Claims.(jwt.MapClaims)

            if exp, _ := c["exp"].(float64); int64(exp) < time.Now().Unix() {
                http.Error(w, "expired", 401); return
            }
            jti, _ := c["jti"].(string)
            sub, _ := c["sub"].(string)
            iat, _ := c["iat"].(float64)

            if bloom.Test("jti:"+jti) {
                if v, _ := rdb.Exists(r.Context(), "revoked:"+jti).Result(); v > 0 {
                    http.Error(w, "revoked", 401); return
                }
            }
            if baseline, err := rdb.Get(r.Context(), "user_rev:"+sub).Int64(); err == nil {
                if int64(iat) < baseline {
                    http.Error(w, "revoked", 401); return
                }
            }
            ctx := context.WithValue(r.Context(), "claims", c)
            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }
}

注意 user_rev:{sub} 的查询每次请求都发生,频繁用户会造成 Redis 热 key。优化:RS 本地缓存 user_rev 值 10-30 秒,过期再拉。

7.5 Pub/Sub 广播

Redis 的 PUBSUB 简单好用,但有两个坑:

  1. 消费者断线期间的消息会丢,重连后无法补消费。
  2. Redis 集群模式下 PUBSUB 是全集群广播,节点多时放大。

工程上推荐:

广播 payload 建议字段:

{
  "v": 1,
  "type": "jti|family|user|device|tenant",
  "id": "...",
  "not_before": 1713600000,
  "reason": "password_change|admin|abuse|logout",
  "emitted_at": 1713600001
}

消费端幂等处理:记录 (type, id, not_before) 三元组的最近值,重复事件直接忽略。

7.6 Python 版刷新与 reuse detection

import hashlib, json, time, secrets
import redis

r = redis.Redis()
RT_TTL = 30 * 24 * 3600
AT_TTL = 15 * 60

def hash_rt(rt: str) -> str:
    return hashlib.sha256(rt.encode()).hexdigest()[:24]

def mint_pair(uid: str, fid: str, parent_jti: str | None) -> dict:
    at_jti = secrets.token_urlsafe(16)
    rt = secrets.token_urlsafe(48)
    at = build_jwt({"sub": uid, "jti": at_jti, "fid": fid,
                    "exp": int(time.time()) + AT_TTL,
                    "iat": int(time.time())})
    r.hset(f"rt:{hash_rt(rt)}",
           mapping={"uid": uid, "fid": fid, "parent": parent_jti or "",
                    "used": 0, "exp": int(time.time()) + RT_TTL})
    r.expire(f"rt:{hash_rt(rt)}", RT_TTL)
    r.sadd(f"family_jtis:{fid}", at_jti); r.expire(f"family_jtis:{fid}", RT_TTL)
    return {"access_token": at, "refresh_token": rt}

def refresh(old_rt: str) -> dict:
    key = f"rt:{hash_rt(old_rt)}"
    entry = r.hgetall(key)
    if not entry:
        fid = extract_fid_from_jwt_or_log(old_rt)
        abort_family(fid, reason="reuse_unknown_rt")
        raise InvalidGrant("reuse detected")
    if int(entry[b"used"]):
        fid = entry[b"fid"].decode()
        abort_family(fid, reason="reuse_used_rt")
        raise InvalidGrant("reuse detected")
    fid = entry[b"fid"].decode()
    uid = entry[b"uid"].decode()

    with r.pipeline() as p:
        p.hset(key, "used", 1)
        p.expire(key, 30)
        new_pair = mint_pair(uid, fid, parent_jti=hash_rt(old_rt))
        p.execute()
    return new_pair

def abort_family(fid: str, reason: str):
    r.hset(f"family:{fid}", mapping={"status": "compromised", "reason": reason})
    for jti in r.smembers(f"family_jtis:{fid}"):
        r.set(f"revoked:{jti.decode()}", "1", ex=AT_TTL)
    r.publish("revoke", json.dumps({"type": "family", "id": fid,
                                    "not_before": int(time.time()), "reason": reason}))

八、Token Binding vs Token Introspection

8.1 问题本质

到此为止讨论的机制都无法解决一件事:如果 bearer token 被完整窃取,中间人或恶意客户端可以直接使用,系统看不出区别。黑名单只能在检测到异常后吊销,不能阻止首次被滥用。

两条增强路线:

8.2 Token Introspection 的工程权衡

维度 纯 JWT 本地验证 带黑名单 JWT Introspection
验证延迟 <1ms 1-3ms 5-30ms
实时吊销 只能等 exp 秒级(广播延迟) 即时
auth server 依赖 弱(失败可降级) 强(失败则拒绝请求)
auth server QPS 仅签发时 仅签发/吊销 每请求
适用场景 普通 API 大多数场景 高敏感操作

混合策略:普通接口走 JWT + 黑名单,高敏感接口(转账、改密、下载密钥)走 introspection。Introspection 响应可以本地缓存 5-30 秒降低 QPS。

8.3 Token Binding 现状

DPoP 示例请求:

GET /api/user HTTP/1.1
Authorization: DPoP eyJhbGci...
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIs...

DPoP proof JWT 载荷:

{
  "htm": "GET",
  "htu": "https://api.example.com/user",
  "iat": 1713600000,
  "jti": "proof-unique-id",
  "ath": "<base64url sha256 of access_token>"
}

RS 验证:

  1. 检查 DPoP JWT 签名由 header 里 jwk 的公钥签。
  2. htm / htu 匹配实际请求。
  3. iat 在 ± 60 秒内。
  4. jti 在 nonce store 里唯一(防重放)。
  5. AT 的 cnf.jkt == sha256(jwk) base64url。
  6. ath == sha256(AT) base64url。

DPoP 把 bearer token 从「持有即用」升级为「持有并证明」,token 泄露本身不够攻击者使用。代价是客户端要维护 EC 密钥、每请求多一次签名。

8.4 选型建议

场景 推荐
内网 B2B API mTLS-bound
公网浏览器 / SPA DPoP(2026 年主流浏览器 Web Crypto API 支持 EC 签名)
原生 App DPoP,密钥存 Keystore / Secure Enclave
高敏感操作 DPoP + introspection 双保险
老系统改造 bearer + 黑名单 + 短 TTL 组合已足够

九、工程坑点

9.1 广播消息丢失造成的吊销漏洞

Redis PUBSUB 消费者断线期间的消息永久丢失。如果某台 RS 断线 30 秒,期间有 10 个 token 被吊销,这台机器会继续放行这 10 个 token 到各自的 exp。

兜底:每个 RS 进程启动时全量 SCAN revoked:* 重建 bloom,运行期间每 60 秒增量刷新一次最近吊销事件。或者直接换 Kafka。

9.2 Clock Skew 与 not_before 语义

user_rev:{uid} 用了时间戳比较。如果 auth server 的时钟比 RS 快 5 秒,刚签发的 token 的 iat 可能 < RS 看到的 baseline,token 立刻失效。

做法:

9.3 Redis 单点故障导致「假吊销」或「假通过」

黑名单查 Redis 失败怎么办?两种降级策略:

现实选择:正常请求 fail-open(本地 bloom 已经过滤),高敏感操作 fail-closed。bloom filter 本身是 RS 本地的,Redis 挂了仍能工作,只是无法拿到最新吊销事件。

9.4 Refresh Token 并发刷新误杀 family

前面讲了 grace window。还有一个隐蔽场景:用户网络抖动,App 发起刷新请求后没收到响应就重试,第二个请求用的是同一个 RT。如果服务端没做 grace window,就会触发 reuse 把 family 杀掉。

grace window 务必实现,并建议设 30 秒以上。

9.5 Back-channel logout URL 不可达 / 返回 5xx

如果 RP 的 backchannel_logout_uri 宕机,OP 收到 5xx 或超时。OP 应该实现重试(指数退避,3-5 次),失败进死信队列告警,而不是直接放弃。

另一方向:RP 侧的 logout 处理器必须幂等。OP 可能因超时重发同一个 logout_token,RP 按 jti 去重。

9.6 id_token_hint 的过期问题

用户登录后使用 App 数周,id_token 早就过期。到了要登出的时候,RP 把过期的 id_token 当 id_token_hint 发给 OP,规范允许但要求 OP 只拿里面的 claims 做识别,不校验 exp。部分 OP 实现错误地校验 exp,导致用户登出失败。

客户端兜底:id_token 保存副本到登出时使用,exp 过期也不丢弃。

9.7 Bloom filter 的滚动窗口造成短暂假阴性

滚动 bloom 切换窗口时,current/next 切换那一瞬间,如果从 Redis SCAN 慢于切换,部分吊销事件会短暂不在任何 filter 里——bloom 说不存在,但 Redis 里明明有。

补救:切换用「先构建 next 完毕 → 原子替换」的顺序,保证任何时刻 current ∪ next 覆盖全部有效事件。如果构建 next 需要分钟级,应该提前启动。

9.8 Revoke endpoint 被用作 token 探测

攻击者拿到一串疑似 token,用 /revoke 端点探测是否有效:如果 /revoke 对不存在的 token 返回 404、对存在的返回 200,攻击者能区分。

修复:始终返回 200,无论 token 是否存在。RFC 7009 明确要求。

9.9 SLO 不能保证完全一致性

有些 RP 的 session 缓存在浏览器 sessionStorage 里,back-channel 登出只能清服务端 session cookie,不能清浏览器内存状态。用户可能在被登出后还能看到缓存的用户名(但下一次 API 请求会 401)。

这不是安全漏洞,但会造成困惑。前端要在 401 时彻底清 UI 状态、跳转登录。


十、落地建议

10.1 架构默认值

10.2 分阶段落地路径

阶段 目标 工作量
1. 基础 短 TTL + RT + 黑名单 Redis,RS 带中间件 1-2 人周
2. 安全 Rotation + reuse detection + family + 广播 2-3 人周
3. 性能 Bloom filter + 本地缓存 + 批量吊销优化 1-2 人周
4. SSO end_session + back-channel logout 对接 2-4 人周(每个 RP)
5. 强化 DPoP / mTLS-bound + introspection 混合 3-6 人周

不要一上来就冲第 5 阶段,绝大多数系统 1-2 阶段就能扛住主要风险。

10.3 监控指标

10.4 灾备预案

10.5 Don’t / Do 清单


十一、参考资料


上一篇JWT、JWS、JWE、JWKS 一次讲透

下一篇MFA、TOTP、WebAuthn、Passkey 工程实践

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-21 · architecture / security

【身份与访问控制工程】JWT、JWS、JWE、JWKS 一次讲透

JWT 是现代身份系统事实上的令牌格式,但围绕它的 JWS、JWE、JWK、JWKS 四个 RFC 常常被混为一谈。本文从标准归属、字段细节、算法选型、攻击面、密钥轮换到生产运维,把 JWT 相关的工程问题一次讲透

2026-04-21 · architecture / security

【身份与访问控制工程】企业单点登录:OIDC 与现代 SSO

B2B 大客户的安全团队只认自家 Okta,集团五条产品线要统一账号,合规团队要求所有入口都可审计——这些需求最终都落在同一个协议上:OpenID Connect。本文从一个真实的商业谈判场景切入,系统拆解 OIDC 在 OAuth 2.0 之上增加了什么、授权码流程的每一个字段为什么存在、企业集成里最常见的六类实现坑,以及 Discovery、JWKS、Dynamic Client Registration、mTLS Client Auth 的工程细节,帮助你把 SSO 从 demo 做到可以放进 SOC 2 审计报告里。


By .