0%

HMAC Cookie + Redis SID 双层会话:OAuth2/JWT 体系下的浏览器会话治理

🌐 语言: 中文版 | 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 的值做成两段结构:

1
base64url(JSON payload) . base64url(HMAC-SHA256(payload))

一个 payload 示例:

1
2
3
4
5
6
7
8
9
{
"uid": 100,
"email": "user@example.com",
"display_name": "张三",
"sid": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"idp": "EMAIL",
"security_stamp": "abc123def456",
"exp": 1745577600
}
字段 作用
uid 本地用户 ID
sid 当前浏览器会话 ID,对应 Redis key
security_stamp 账户级安全戳,用于强制失效旧会话
exp Cookie 硬过期时间
idp 身份提供商标识
email / display_name 页面渲染常用的用户基本信息

签名比对使用常量时间比较,例如 MessageDigest.isEqual()。普通逐字节比较可能暴露 timing attack 风险。

HMAC 密钥应由安全配置服务、密钥管理系统或环境变量注入,不能写进代码仓库。密钥轮换策略也需要提前定义,否则 Cookie 签名机制会变成长期固定密钥依赖。

Cookie 属性保持基本约束即可:HttpOnlySecureSameSite=LaxPath=/。如果服务部署在 TLS 终止代理之后,Secure 的判断要和真实外部 HTTPS 入口对齐。

Redis SID 只做一件事

Redis 中的 Session 不做成”胖 Session”。一个很薄的结构就够了:

1
2
3
Key:   session:sid:{sid}
Value: {"uid": 100}
TTL: 43200 秒

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public SessionClaims resolveActiveClaims(Request request, Response response) {
SessionClaims claims = decodeAndVerifyCookie(request);

if (claims == null || claims.sid() == null || claims.securityStamp() == null) {
return null;
}

if (sessionStore.isSidActive(claims.sid())) {
User user = userRepo.findActiveById(claims.userId());

if (user != null && claims.securityStamp().equals(user.getSecurityStamp())) {
return claims;
}
}

clearCookie(request, response);
return null;
}

三个判断对应三类系统语义:

层级 检查内容 解决的问题
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
2
3
4
5
6
7
8
String sid = context.getAuthorization().getAttribute("sid");

context.getClaims().claim("sid", sid);
context.getClaims().claim("security_stamp", user.getSecurityStamp());

if (isAccessToken) {
context.getClaims().claim("token_version", user.getPermissionVersion());
}

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 流程:

  1. 客户端携带 Bearer JWT 请求 API 网关
  2. 网关把 Authorization 头转发给认证服务
  3. 认证服务解码 JWT,读取 uidsidsecurity_stamptoken_version
  4. 检查 Redis SID 是否仍然存在
  5. 读取用户策略,比较安全戳和权限版本号
  6. 鉴权通过后,网关注入用户头信息,再转发给业务服务

这里可以支持两种模式:

  • authenticated:只确认请求是谁发的,检查用户状态、SID、安全戳、权限版本号
  • entitled:在 authenticated 基础上继续做 RBAC、应用授权、资源授权

两种模式的差别在授权深度,不在会话校验。SID 和安全戳仍然是共同的基础层。

Redis 不可用时怎么处理

这个设计引入了 Redis 依赖,因此必须定义故障语义。

策略缓存不可用,可以回 DB。因为策略数据有权威来源,Redis 只是缓存。

SID 存活检查不一样。SID 是会话是否仍然有效的状态源。Redis 不可用时,系统并不知道这个会话有没有被撤销。继续放行,会破坏”登出立即失效””管理员踢人立即生效”的承诺。

因此 SID 校验路径更适合 fail-closed:Redis 不可用时拒绝会话恢复和 API 鉴权。

这也意味着 Redis 高可用不是可选项。主从复制结合 Sentinel、Redis Cluster,或者其他符合部署条件的高可用方案,需要作为会话基础设施的一部分来设计,而不是上线后再补。

登出和踢人

登出时同时清两层:

1
2
3
4
5
6
public void logout(Request request, Response response) {
String sid = cookieService.resolveSid(request);

sessionStore.invalidateSid(sid);
cookieService.clearCookie(request, 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”,而是来自失效语义被拆到了合适的粒度。

延伸阅读