在一套基于 OAuth2/OIDC 的 IAM 系统中,网关用 JWT 做 API 请求的准入控制。协议链路跑通之后,浏览器端的会话管理会变成一个独立的工程问题:JWT 发出后天然偏无状态,权限变更、主动登出、会话追踪都需要额外的治理手段。
这篇文章记录一种 HMAC 签名 Cookie + Redis SID 的双层会话设计。浏览器登录态和上层 JWT 共享同一个 Session ID,让浏览器会话、API 令牌和撤销信号进入同一套生命周期模型。
TL;DR
这篇文章记录一个浏览器会话治理方案,默认读者熟悉 OAuth2/JWT 的基本概念,重点放在架构取舍和关键设计决策。
核心思路:
- 浏览器用 HMAC 签名 Cookie 承载会话标识,无状态、防篡改
- Redis 中存储 Session ID(SID),有状态、可撤销
- SID 从浏览器 Cookie 流入 JWT,撤销某个 SID 即可让该浏览器会话及其派生令牌失效
- 双撤销信号:安全戳处理密码修改等账户级事件,权限版本号处理角色变更
JWT 在浏览器端的治理短板
网关对 API 请求做 JWT 校验,在协议层面没有问题。但系统需要以下能力时,纯 JWT 的无状态特性会带来额外治理成本:
- 会话撤销:管理员禁用用户后,已签发的 JWT 在过期前仍然有效
- 主动登出:前端清空 token 存储,但 JWT 本身不会失效,被截获仍可使用
- 权限变更生效:修改用户角色后,旧 JWT 中的权限信息仍然在使用
- 会话追踪:无法统计活跃会话数量,也无法踢出指定会话
这些问题的根源不是 JWT 错了,而是它本来就更适合无状态校验。需要撤销、踢出指定会话、追踪活跃会话时,需要在 JWT 之外补一层有状态的会话信号。
为什么不直接用传统 Session
传统服务端 Session 可以解决上述问题,但有两个顾虑:
- 每次请求都查存储:浏览器端的页面请求频率比 API 请求高,每个静态资源请求都触发一次 Session 查询,存储压力不理想
- 与 OAuth2 流程的集成:系统已经有完整的 OAuth2 授权码流程和 JWT 签发机制,需要的是一个与现有体系平行的浏览器会话层,而不是替换它
设计目标:在 JWT 体系之外,加一层轻量的浏览器会话管理,支持撤销和会话追踪,同时控制每次请求的额外开销。
双层架构
整体结构分三层校验:
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 中包含用户标识、Session ID、安全戳和过期时间,整段 payload 经过 HMAC-SHA256 签名。
第二层:Redis SID——有状态、可撤销、滑动续期。登录时生成随机 Session ID,存入 Redis 并设置 TTL。每次验证通过后重置 TTL,活跃用户不会因超时掉线。
第三层:数据库安全戳——账户级安全信号。每个用户有一个安全戳字段,密码修改、账户禁用等安全事件会更新这个值。验证时比对 Cookie 中的戳和数据库当前值,不匹配则不恢复认证状态。
三层中任何一层失败,都不能恢复有效认证状态。服务端可以在可判定为失效的场景下清除 Cookie,并按未认证处理。
Cookie 层的设计
Cookie 格式
所有信息编码到一个 Cookie 中,格式为:
1 | base64url(JSON payload) . base64url(HMAC-SHA256(payload)) |
点号分隔,前半部分是 payload 的 base64url 编码,后半部分是对前半部分的 HMAC-SHA256 签名。结构和 JWT 类似,但签名用对称的 HMAC 而非 RSA,验证更快,密钥管理也更简单。
Payload 内容
1 | { |
| 字段 | 用途 |
|---|---|
uid |
本地用户 ID |
sid |
Session ID,关联 Redis 中的会话记录 |
security_stamp |
安全戳,与数据库当前值比对 |
exp |
过期时间(epoch seconds),默认 12 小时 |
idp |
身份提供商标识 |
email / display_name |
用户基本信息 |
把 security_stamp 放在 Cookie 中是一个关键设计:每次校验不只验证签名,还要确认账户安全状态没有变化。
签名与验签
1 | // 签发:对 payload 做 HMAC-SHA256 |
签名比对用 MessageDigest.isEqual() 而不是 Arrays.equals()。前者是常量时间比较,防止通过响应耗时逐字节猜测签名内容(timing attack)。这个攻击面容易被忽略。
Cookie 属性
1 | ResponseCookie cookie = ResponseCookie.from(cookieName, value) |
HttpOnly:JavaScript 无法读取,降低 XSS 窃取风险Secure:仅 HTTPS 传输。用request.isSecure()动态判断,因为服务在 TLS 终止代理之后,需要一个高优先级 Filter 从X-Forwarded-Proto重写isSecure()的返回值SameSite=Lax:允许顶级导航携带,阻止跨站 POST,安全性和可用性的折中
Redis SID 层的设计
Session ID 生成
1 | byte[] bytes = new byte[16]; |
16 字节随机数提供 128 bit 熵,碰撞概率可忽略。
Redis 存储
Redis 中的存储非常精简,只存必要的用户标识。下面的 key 仅为示意,实际项目可以按自己的命名规范增加前缀和隔离维度:
1 | Key: session:sid:{sid} |
Session 的完整上下文没有存在 Redis 中,而是分散在 Cookie(用户基本信息)和数据库(权限、安全戳)中。Redis SID 层的唯一职责是标记 Session 是否仍然有效和滑动续期。
不做”胖 Session”的原因:
- 权限数据量大(可能有上百条授权记录),序列化开销和维护复杂度都不低
- 权限变更时只需更新数据库中的版本号,不需要同步更新所有 Session
- Cookie 已经携带了用户基本信息,Redis 不需要重复存储
滑动续期
1 | public boolean isSidActive(String sid) { |
每次验证 SID 时同时重置 TTL。活跃用户不会因超时掉线。
这里存在双过期机制:
- Cookie 内嵌的
exp是硬性上限(12 小时),签名后不可更改 - Redis SID 的 TTL 是弹性窗口,每次验证都重置
实际效果:Cookie exp 控制硬性上限,Redis TTL 控制活跃窗口。是否允许更长的滑动会话,要看业务对安全和体验的取舍。
三层校验流程
浏览器请求恢复登录态时经过三层校验:
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 是否被篡改 | 伪造 SID 或 exp |
| Redis SID | Session 是否仍然有效 | 登出、被踢、过期 |
| DB 安全戳 | 账户安全状态是否变化 | 密码修改、账户被禁用 |
任何一层失败都不能恢复有效认证状态。对已经能判定为失效的场景,服务端可以顺手清除 Cookie;对格式错误、签名错误等输入,直接按未认证处理即可。关键点是不做“只验 Cookie、跳过 SID”的降级。
SID 的生命周期传播
这个方案的核心设计:浏览器端的 SID 不是孤立的,它会传播到 JWT 中,让浏览器会话和 API 令牌使用同一个撤销信号。
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: 业务响应
传播过程涉及三个环节:
1. 登录时创建 SID
无论密码登录还是联合登录,成功后统一创建 SID:
1 | String sid = sessionStore.createAndActivate(userId); |
2. SID 流入 OAuth2 授权
浏览器带着 Cookie 发起 OAuth2 授权请求时,一个装饰器从 Cookie 提取 SID 并附加到 Authorization 对象:
1 | // OAuth2AuthorizationService 的装饰器 |
3. SID 写入 JWT
Token 签发时,从 Authorization 中取出 SID 写入 JWT claims:
1 | String sid = context.getAuthorization().getAttribute("sid"); |
JWT 中的 SID 和浏览器 Cookie 中的 SID 是同一个值。撤销 Redis 中的这个 SID 后,该浏览器会话以及通过这个会话签发的 JWT 都会在后续校验中失效。账户级的全部会话撤销,则更适合通过安全戳这类全局信号处理。
双撤销信号
系统中有两个独立的撤销信号,职责不同。
安全戳:账户级安全事件
安全戳存在用户表中,典型触发场景包括:
- 用户修改密码
- 管理员重置密码
- 账户禁用或其他需要强制重新认证的安全事件
- 其他需要强制所有会话失效的安全事件
校验时,Cookie 和 JWT 中的安全戳都与数据库当前值比对。不匹配则拒绝。
效果:修改密码后,旧 Cookie 和旧 JWT 中携带的安全戳都会过期。 不需要逐个查找 token,也不需要等 JWT 自然过期。
权限版本号:权限变更
permission_version 是用户表中的单调递增版本号,每次权限变更时 +1。只写入 JWT 的 access token,在网关鉴权时比对:
1 | // 策略决策引擎 |
权限变更后,旧 JWT 的版本号与当前版本不一致,请求被拒绝。客户端可以重新走授权流程获取新 JWT。
为什么拆成两个
| 维度 | 安全戳 | 权限版本号 |
|---|---|---|
| 触发场景 | 密码修改、账户禁用 | 角色调整、授权增删 |
| 影响范围 | Cookie + JWT 同时失效 | 主要影响 JWT |
| 校验位置 | Cookie 验证 + 网关鉴权 | 仅网关鉴权 |
| 更新频率 | 低(安全事件) | 中(权限变更) |
| 用户体验 | 需要重新登录 | 前端可自动重获 JWT |
权限变更比安全事件频繁得多。如果只用一个字段,每次改权限都可能强制重新登录。拆开后,改权限主要触发令牌刷新;改密码、撤销会话等安全事件才触发重新登录。
网关鉴权
API 请求通过网关的 forward-auth 机制走另一条校验路径:
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["业务服务"]
支持两种模式:
- entitled 模式(默认):完整 RBAC,包括用户状态、权限版本号、安全戳、应用授权
- authenticated 模式:只检查用户状态、权限版本号和安全戳,不做应用授权判断。适用于只需确认”请求是谁发的”的场景
两种模式的区别在 entitlement 检查,SID 和双信号校验都是必经之路。
策略缓存与降级
策略数据采用 Redis 主 + DB 备的模式:
1 | private UserPolicy getUserPolicy(Long uid) { |
Redis 和 DB 都不可用时,策略不可判定,决策结果应拒绝。这是安全优先于可用性的选择。
权限变更时主动删除缓存:
1 | redis.delete(policyCacheKey(uid)); |
下次请求触发缓存未命中,从 DB 重新加载。缓存 TTL 只是兜底,正常路径应依赖主动失效和权限版本号,让旧 token 尽快被拒绝。
登出
登出同时清理两层:
1 | public void logout(Request request, Response response, Authentication auth) { |
Redis SID 删除后:
- 浏览器后续请求:Cookie 验签能过,但 SID 检查失败,走 fail-closed
- 已签发的 JWT:网关鉴权时 SID 检查失败,请求被拒绝
- 对单个浏览器会话,不需要维护 token 黑名单,也不需要等 JWT 自然过期
得失分析
收益
- 会话级撤销:撤销一个 SID,浏览器会话和对应 API 令牌同时失效,不需要 token 黑名单
- 双信号分层:安全事件强制重新登录,权限变更只重获 JWT
- 与 OAuth2 自然集成:SID 从 Cookie 流入 OAuth2 再流入 JWT,不需要额外的令牌管理
- 滑动会话:Redis TTL 支持活跃窗口,Cookie
exp保留硬性上限
代价
- Redis 依赖:SID 校验依赖 Redis。设计上不建议做“HMAC 验过就跳过 SID”的降级,线上环境需要 Redis 高可用和清晰的故障语义
- 请求额外开销:浏览器登录态恢复和网关鉴权会多一次 SID 检查及续期
- 实现复杂度:比纯 JWT 多了 Cookie 签名、Redis SID、安全戳比对、SID 传播链路和策略缓存降级
适用场景
| 场景 | 判断 |
|---|---|
| 内部系统,需要撤销和踢人 | 适合 |
| 权限变更需要较快生效 | 适合 |
| 浏览器会话和 API 令牌需要统一治理 | 适合 |
| 纯公开 API,无会话概念 | 不适合,JWT 更简单 |
| 跨顶级域名 SSO | 不适合,Cookie 受限,需协议级方案 |
| 极高并发且无法承担集中式会话依赖 | 不适合,需要重新评估 Redis 架构和降级策略 |