🌐 语言: 中文版 | English Version
在 OAuth2/OIDC 体系里,JWT 很适合做请求准入凭证。网关拿到 token 后可以本地验签、检查过期时间、解析用户和权限信息,服务端不用为每个请求查 Session,水平扩展也简单。
但浏览器登录态还有另一组问题:主动登出、管理员踢人、权限变更即时生效、活跃会话追踪。这些都是有状态治理问题,和 JWT 的无状态准入不是同一类职责。
这篇记录的是一次会话治理设计:在现有 OAuth2/JWT 体系旁边补一层 Redis SID。浏览器侧用 HMAC 签名 Cookie 承载 SID,服务端用 Redis 维护 SID 存活状态,再把同一个 SID 传播进 JWT。浏览器会话和 API 令牌共享同一套撤销信号。
TL;DR
核心思路:
- 浏览器用 HMAC 签名 Cookie 承载会话标识,无状态、防篡改
- Redis 中存储 Session ID(SID),有状态、可撤销
- SID 从浏览器 Cookie 流入 JWT,撤销某个 SID 即可让该浏览器会话及其派生令牌失效
- 双撤销信号:安全戳处理密码修改等账户级事件,权限版本号处理角色变更
问题边界
JWT 的优势在于无状态校验。这个优势不需要否定。
问题出现在系统开始要求有状态能力的时候:
- 用户主动退出后,旧 JWT 是否还能继续访问 API
- 管理员禁用用户后,旧 JWT 是否立即失效
- 用户角色调整后,旧 JWT 里的权限是否还可信
- 是否能看到某个用户有多少活跃浏览器会话
- 是否能只踢掉某一台设备或某一个浏览器会话
这些能力不属于”验签是否通过”的范畴。JWT 解决的是请求准入,浏览器会话治理解决的是登录态生命周期。把这两件事混在一起,系统最后通常会走向两种极端:要么只能等 token 自然过期,要么重新退回全量服务端 Session。
这里选择的是第三种方式:保留 OAuth2/JWT 的请求准入模型,只给浏览器会话补一个可撤销的 SID。
为什么不是传统 Session
传统服务端 Session 可以处理撤销、踢人和会话追踪,但在这个场景里,它不是最合适的主模型。
第一,系统已经有完整的 OAuth2 授权码流程和 JWT 签发机制。浏览器会话层需要补足撤销能力,而不是替换现有 token 体系。
第二,浏览器页面请求和静态资源请求频率高。如果所有请求都依赖集中式 Session 读取,存储压力和故障影响面都会变大。
第三,业务 API 已经由网关基于 JWT 做准入控制。会话治理层更适合提供一个清晰的撤销信号,而不是重新承接全部认证上下文。
所以这套设计的边界是:
- JWT 做 API 请求准入
- Cookie 承载浏览器登录态
- Redis SID 提供会话级撤销信号
- 数据库安全戳处理账户级强制失效
- 权限版本号处理授权变更同步
这个边界比”Session 和 Token 选哪个”更重要。
方案取舍先放在前面
补 SID 之后,系统换来的是三类能力。
第一,会话级撤销。删除一个 SID,就能让对应浏览器会话以及通过它签发的 JWT 同时失效,不需要维护 token 黑名单。
第二,失效语义更细。登出和踢人走 SID;密码修改、账户禁用走安全戳;角色调整、授权增删走权限版本号。不同事件不再挤到一个字段里。
第三,OAuth2 流程不用推翻。SID 从 Cookie 流入 OAuth2 Authorization,再写入 JWT,原有授权链路仍然成立。
代价也很直接。
Redis 不再只是普通缓存,而是会话基础设施。SID 存活不可判定时,系统不能继续放行,否则”登出立即失效”和”管理员踢人立即生效”这两个承诺会破掉。
每次恢复浏览器登录态、每次网关鉴权,也会多一次 SID 检查。实现上还需要处理 Cookie 签名、SID 传播、安全戳和权限版本号几条链路。
这不是免费的复杂度。它适合需要会话撤销、权限快速生效、后台管理踢人的系统;不适合没有浏览器会话概念的纯公开 API。
双层会话结构
整体结构可以看成三次判断。
flowchart LR
subgraph Browser["浏览器"]
Cookie["签名 Cookie\n(HMAC-SHA256)"]
end
subgraph Server["服务端"]
Redis["Redis\nsession:sid:{sid}"]
DB["数据库\nuser.security_stamp"]
end
Cookie -->|"① HMAC 验签 + exp 检查"| Redis
Redis -->|"② SID 存活 + 滑动续期"| DB
DB -->|"③ security_stamp 匹配"| Cookie
第一层是 HMAC 签名 Cookie。
Cookie 中包含用户标识、SID、安全戳和过期时间。服务端可以先做无状态验签和过期检查。签名通过只说明 Cookie 没被篡改,不代表这个会话仍然有效。
第二层是 Redis SID。
登录成功时生成一个随机 SID,写入 Redis,并设置 TTL。后续请求只要 SID 还存在,就说明这个浏览器会话仍然有效。登出、踢人、过期,都可以通过删除 SID 或等待 TTL 过期完成。
第三层是数据库安全戳。
用户修改密码、管理员重置密码、账户禁用等安全事件,会更新用户表里的 security_stamp。Cookie 和 JWT 中也携带签发时的安全戳。校验时发现不一致,就拒绝恢复认证状态。
三层里任何一层失败,都按未认证处理。
Cookie 只承载必要信息
Cookie 的值做成两段结构:
1 | base64url(JSON payload) . base64url(HMAC-SHA256(payload)) |
一个 payload 示例:
1 | { |
| 字段 | 作用 |
|---|---|
uid |
本地用户 ID |
sid |
当前浏览器会话 ID,对应 Redis key |
security_stamp |
账户级安全戳,用于强制失效旧会话 |
exp |
Cookie 硬过期时间 |
idp |
身份提供商标识 |
email / display_name |
页面渲染常用的用户基本信息 |
签名比对使用常量时间比较,例如 MessageDigest.isEqual()。普通逐字节比较可能暴露 timing attack 风险。
HMAC 密钥应由安全配置服务、密钥管理系统或环境变量注入,不能写进代码仓库。密钥轮换策略也需要提前定义,否则 Cookie 签名机制会变成长期固定密钥依赖。
Cookie 属性保持基本约束即可:HttpOnly、Secure、SameSite=Lax、Path=/。如果服务部署在 TLS 终止代理之后,Secure 的判断要和真实外部 HTTPS 入口对齐。
Redis SID 只做一件事
Redis 中的 Session 不做成”胖 Session”。一个很薄的结构就够了:
1 | Key: session:sid:{sid} |
Redis SID 只负责两件事:
- 标记这个 Session 是否仍然有效
- 在活跃访问时滑动续期
用户基本信息已经在 Cookie 中。权限、安全状态、账户状态仍然以数据库和策略缓存为准。把大量权限数据塞进 Redis Session,会让权限变更后的同步问题更复杂,也会放大会话存储的维护成本。
Cookie exp 和 Redis TTL 代表两类边界:
- Cookie
exp是浏览器持有凭证的硬上限,签名后不能被浏览器自己修改 - Redis TTL 是服务端会话的活跃窗口,访问时可以滑动续期
如果业务需要保持活跃用户持续在线,服务端需要刷新 Cookie 的 exp 字段,并通过 Set-Cookie 返回新的签名 Cookie。否则 Redis 续期只能保证 SID 不过期,Cookie 到硬上限后仍然会失效。
这个取舍属于会话安全策略,不只是用户体验配置。
校验流程
恢复浏览器登录态时,流程大致如下:
flowchart TD
A["请求到达"] --> B{"解析签名 Cookie"}
B -->|"不存在"| Z["未认证"]
B -->|"存在"| C{"HMAC 验签"}
C -->|"不匹配"| Z
C -->|"通过"| D{"检查 exp"}
D -->|"已过期"| Z
D -->|"未过期"| E{"Redis SID 存活?"}
E -->|"不存在"| Z
E -->|"存在 + 续期"| F{"DB 安全戳匹配?"}
F -->|"不匹配"| Z
F -->|"匹配"| G["认证通过"]
Z --> H["未认证 / 可清除 Cookie"]
1 | public SessionClaims resolveActiveClaims(Request request, Response response) { |
三个判断对应三类系统语义:
| 层级 | 检查内容 | 解决的问题 |
|---|---|---|
| HMAC Cookie | 是否被篡改、是否过期 | 防止伪造 UID、SID、exp |
| Redis SID | 会话是否仍然有效 | 支持登出、踢人、会话过期 |
| DB 安全戳 | 账户安全状态是否变化 | 支持密码修改、账户禁用后强制失效 |
对格式错误、签名错误这类输入,直接按未认证处理。对已经能判定失效的请求,可以清 Cookie。HMAC 验签通过后跳过 SID 检查,会把”可撤销会话”退回成”不可撤销 Cookie”。
SID 如何进入 JWT
浏览器 Cookie 里的 SID 不是孤立存在的,它会传播到 OAuth2 Authorization,再进入 JWT。
sequenceDiagram
participant B as 浏览器
participant Auth as 认证服务
participant Redis as Redis
participant GW as API 网关
B->>Auth: 登录(密码 / 联合登录)
Auth->>Redis: 创建 SID
Auth-->>B: Set-Cookie: 签名 Cookie(含 sid)
B->>Auth: OAuth2 授权请求(带 Cookie)
Auth->>Auth: 从 Cookie 提取 sid
Auth->>Auth: 将 sid 附加到 OAuth2 Authorization
Auth->>Auth: 签发 JWT(写入 sid + 安全戳 + 权限版本号)
Auth-->>B: 返回 JWT
B->>GW: API 请求(Bearer JWT)
GW->>Auth: 鉴权回调
Auth->>Auth: 解码 JWT,提取 sid
Auth->>Redis: 检查 SID 存活
Auth->>Auth: 策略决策
Auth-->>GW: 200 + 用户头信息
GW-->>B: 业务响应
登录成功时,认证服务创建 SID,并写入签名 Cookie。
浏览器随后发起 OAuth2 授权请求时,认证服务从 Cookie 中读取 SID,并把它附加到 OAuth2 Authorization 对象。
Token 签发时,再把 SID 写入 JWT claims:
1 | String sid = context.getAuthorization().getAttribute("sid"); |
Cookie 中的 SID 和 JWT 中的 SID 是同一个值。撤销 Redis 里的这个 SID 后,浏览器页面请求和 API 请求都会在后续校验中失效。
token 黑名单的粒度是 token,SID 的粒度是会话。一个浏览器会话里签发过多少 access token,并不影响撤销动作;只要它们共享同一个 SID,就可以用一个会话信号统一失效。
两类撤销信号
会话治理不适合把所有失效场景都压到一个字段上。这里拆成两个信号:
security_stamp:账户级安全事件permission_version:权限变更事件
security_stamp 存在用户表中,通常在用户修改密码、管理员重置密码、账户禁用等事件发生时更新。安全戳同时写入 Cookie 和 JWT。数据库里的当前值一变,旧 Cookie 和旧 JWT 都会失效。
permission_version 也是用户表中的字段,每次角色调整、授权增删时递增。它主要写入 access token,在网关鉴权时和当前策略版本比较。旧 access token 中的版本号落后,网关拒绝请求,客户端重新走授权流程拿新 token。
两者拆开后,用户体验会更可控:
| 维度 | 安全戳 | 权限版本号 |
|---|---|---|
| 触发场景 | 密码修改、账户禁用 | 角色调整、授权增删 |
| 影响范围 | Cookie + JWT | 主要是 access token |
| 用户体感 | 通常需要重新登录 | 通常重新获取 token |
| 更新频率 | 低 | 中 |
如果只用一个字段,每次改权限都可能导致全端重新登录。权限变更的频率通常高于安全事件,拆开以后,失效范围更接近真实业务事件。
网关鉴权链路
API 请求走网关时,JWT 校验不只看签名和过期时间,还要检查 SID 和策略版本。
flowchart LR
Client["客户端"] -->|"Bearer JWT"| GW["API 网关"]
GW -->|"forward-auth\n转发 Authorization 头"| Auth["认证服务"]
Auth -->|"① 解码 JWT"| Auth
Auth -->|"② SID 存活检查"| Redis["Redis"]
Auth -->|"③ 策略决策"| Policy["策略缓存 / DB"]
Auth -->|"200 + 用户头"| GW
GW -->|"注入请求头"| Svc["业务服务"]
一个典型的 forward-auth 流程:
- 客户端携带
Bearer JWT请求 API 网关 - 网关把 Authorization 头转发给认证服务
- 认证服务解码 JWT,读取
uid、sid、security_stamp、token_version - 检查 Redis SID 是否仍然存在
- 读取用户策略,比较安全戳和权限版本号
- 鉴权通过后,网关注入用户头信息,再转发给业务服务
这里可以支持两种模式:
authenticated:只确认请求是谁发的,检查用户状态、SID、安全戳、权限版本号entitled:在 authenticated 基础上继续做 RBAC、应用授权、资源授权
两种模式的差别在授权深度,不在会话校验。SID 和安全戳仍然是共同的基础层。
Redis 不可用时怎么处理
这个设计引入了 Redis 依赖,因此必须定义故障语义。
策略缓存不可用,可以回 DB。因为策略数据有权威来源,Redis 只是缓存。
SID 存活检查不一样。SID 是会话是否仍然有效的状态源。Redis 不可用时,系统并不知道这个会话有没有被撤销。继续放行,会破坏”登出立即失效””管理员踢人立即生效”的承诺。
因此 SID 校验路径更适合 fail-closed:Redis 不可用时拒绝会话恢复和 API 鉴权。
这也意味着 Redis 高可用不是可选项。主从复制结合 Sentinel、Redis Cluster,或者其他符合部署条件的高可用方案,需要作为会话基础设施的一部分来设计,而不是上线后再补。
登出和踢人
登出时同时清两层:
1 | public void logout(Request request, Response response) { |
删除 Redis SID 后,影响会同时覆盖两条链路:
- 浏览器请求:Cookie 可能仍能验签,但 SID 检查失败
- API 请求:JWT 可能仍未过期,但 JWT 中的 SID 检查失败
踢掉某个指定浏览器会话,本质上也是删除对应 SID。
让一个用户的所有会话失效,则更适合更新 security_stamp。这会让该用户旧 Cookie 和旧 JWT 中的安全戳全部失配。
适用边界
| 场景 | 判断 |
|---|---|
| 内部系统,需要登出立即失效 | 适合 |
| 管理后台,需要管理员踢人 | 适合 |
| 权限调整需要快速生效 | 适合 |
| 浏览器会话和 API 令牌需要统一治理 | 适合 |
| 纯公开 API,没有浏览器会话概念 | 不太适合 |
| 跨顶级域名 SSO | 需要协议级方案,不能只靠 Cookie |
| 极高并发且不能承担集中式会话依赖 | 需要重新评估 Redis 架构 |
这个设计不是为了证明 Cookie 比 JWT 更好,也不是为了证明 Session 比 Token 更安全。它做的是职责分离:
- JWT 做请求准入凭证
- Cookie 做浏览器登录态承载
- Redis SID 做会话撤销信号
- 安全戳做账户级强制失效
- 权限版本号做授权变更同步
这些信号拆开以后,每个字段影响什么、在哪里校验、故障时怎么处理,都会更清楚。架构上的收益不只来自”多加了一层 Redis”,而是来自失效语义被拆到了合适的粒度。