0%

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

在一套基于 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 可以解决上述问题,但有两个顾虑:

  1. 每次请求都查存储:浏览器端的页面请求频率比 API 请求高,每个静态资源请求都触发一次 Session 查询,存储压力不理想
  2. 与 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 中,格式为:

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

点号分隔,前半部分是 payload 的 base64url 编码,后半部分是对前半部分的 HMAC-SHA256 签名。结构和 JWT 类似,但签名用对称的 HMAC 而非 RSA,验证更快,密钥管理也更简单。

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 Session ID,关联 Redis 中的会话记录
security_stamp 安全戳,与数据库当前值比对
exp 过期时间(epoch seconds),默认 12 小时
idp 身份提供商标识
email / display_name 用户基本信息

security_stamp 放在 Cookie 中是一个关键设计:每次校验不只验证签名,还要确认账户安全状态没有变化。

签名与验签

1
2
3
4
5
6
7
8
9
10
11
12
// 签发:对 payload 做 HMAC-SHA256
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secretKey, "HmacSHA256"));
byte[] signature = mac.doFinal(encodedPayload.getBytes(UTF_8));
String cookieValue = base64url(encodedPayload) + "." + base64url(signature);

// 验签:重算签名,常量时间比对
byte[] expected = mac.doFinal(receivedPayload.getBytes(UTF_8));
byte[] actual = base64urlDecode(receivedSignature);
if (!MessageDigest.isEqual(expected, actual)) {
return null; // 签名不匹配
}

签名比对用 MessageDigest.isEqual() 而不是 Arrays.equals()。前者是常量时间比较,防止通过响应耗时逐字节猜测签名内容(timing attack)。这个攻击面容易被忽略。

1
2
3
4
5
6
7
ResponseCookie cookie = ResponseCookie.from(cookieName, value)
.httpOnly(true)
.secure(request.isSecure()) // 动态判断,依赖 X-Forwarded-Proto
.sameSite("Lax")
.path("/")
.maxAge(Duration.ofSeconds(43200))
.build();
  • HttpOnly:JavaScript 无法读取,降低 XSS 窃取风险
  • Secure:仅 HTTPS 传输。用 request.isSecure() 动态判断,因为服务在 TLS 终止代理之后,需要一个高优先级 Filter 从 X-Forwarded-Proto 重写 isSecure() 的返回值
  • SameSite=Lax:允许顶级导航携带,阻止跨站 POST,安全性和可用性的折中

Redis SID 层的设计

Session ID 生成

1
2
3
byte[] bytes = new byte[16];
SecureRandom.getInstanceStrong().nextBytes(bytes);
String sid = HexFormat.of().formatHex(bytes); // 32 字符 hex

16 字节随机数提供 128 bit 熵,碰撞概率可忽略。

Redis 存储

Redis 中的存储非常精简,只存必要的用户标识。下面的 key 仅为示意,实际项目可以按自己的命名规范增加前缀和隔离维度:

1
2
3
Key:   session:sid:{sid}
Value: {"uid": 100}
TTL: 43200 秒(12 小时,可配置)

Session 的完整上下文没有存在 Redis 中,而是分散在 Cookie(用户基本信息)和数据库(权限、安全戳)中。Redis SID 层的唯一职责是标记 Session 是否仍然有效滑动续期

不做”胖 Session”的原因:

  • 权限数据量大(可能有上百条授权记录),序列化开销和维护复杂度都不低
  • 权限变更时只需更新数据库中的版本号,不需要同步更新所有 Session
  • Cookie 已经携带了用户基本信息,Redis 不需要重复存储

滑动续期

1
2
3
4
5
6
7
8
public boolean isSidActive(String sid) {
Boolean exists = redis.hasKey(key(sid));
if (Boolean.TRUE.equals(exists)) {
redis.expire(key(sid), ttlSeconds, TimeUnit.SECONDS); // 重置 TTL
return true;
}
return false;
}

每次验证 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public SessionClaims resolveActiveClaims(Request request, Response response) {
// ① 解析 Cookie + HMAC 验签 + 检查 exp
SessionClaims claims = decodeAndVerifyCookie(request);
if (claims == null || claims.sid() == null || claims.securityStamp() == null) {
return null;
}

// ② Redis SID 存活检查 + 滑动续期
if (sessionStore.isSidActive(claims.sid())) {
// ③ DB 用户状态 + 安全戳匹配
User user = userRepo.findActiveById(claims.userId());
if (user != null && claims.securityStamp().equals(user.getSecurityStamp())) {
return claims;
}
}

// 任何一层失败,都不能恢复有效认证状态
clearCookie(request, response);
return null;
}

三层各有侧重:

层级 检查内容 防御的威胁
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
2
String sid = sessionStore.createAndActivate(userId);
cookieService.writeSignedCookie(request, response, auth, sid, userId);

2. SID 流入 OAuth2 授权

浏览器带着 Cookie 发起 OAuth2 授权请求时,一个装饰器从 Cookie 提取 SID 并附加到 Authorization 对象:

1
2
3
4
5
6
7
8
9
10
11
// OAuth2AuthorizationService 的装饰器
public void save(OAuth2Authorization authorization) {
String sid = authorization.getAttribute("sid");
if (sid == null) {
sid = cookieService.resolveSidFromCurrentRequest();
authorization = OAuth2Authorization.from(authorization)
.attribute("sid", sid)
.build();
}
delegate.save(authorization);
}

3. SID 写入 JWT

Token 签发时,从 Authorization 中取出 SID 写入 JWT claims:

1
2
3
4
5
6
7
String sid = context.getAuthorization().getAttribute("sid");
context.getClaims().claim("sid", sid);
context.getClaims().claim("security_stamp", user.getSecurityStamp());
// 仅 access token 写入权限版本号
if (isAccessToken) {
context.getClaims().claim("token_version", user.getPermissionVersion());
}

JWT 中的 SID 和浏览器 Cookie 中的 SID 是同一个值。撤销 Redis 中的这个 SID 后,该浏览器会话以及通过这个会话签发的 JWT 都会在后续校验中失效。账户级的全部会话撤销,则更适合通过安全戳这类全局信号处理。

双撤销信号

系统中有两个独立的撤销信号,职责不同。

安全戳:账户级安全事件

安全戳存在用户表中,典型触发场景包括:

  • 用户修改密码
  • 管理员重置密码
  • 账户禁用或其他需要强制重新认证的安全事件
  • 其他需要强制所有会话失效的安全事件

校验时,Cookie 和 JWT 中的安全戳都与数据库当前值比对。不匹配则拒绝。

效果:修改密码后,旧 Cookie 和旧 JWT 中携带的安全戳都会过期。 不需要逐个查找 token,也不需要等 JWT 自然过期。

权限版本号:权限变更

permission_version 是用户表中的单调递增版本号,每次权限变更时 +1。只写入 JWT 的 access token,在网关鉴权时比对:

1
2
3
4
// 策略决策引擎
if (!currentPolicy.getPermissionVersion().equals(tokenVersion)) {
return denied(TOKEN_VERSION_MISMATCH);
}

权限变更后,旧 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private UserPolicy getUserPolicy(Long uid) {
// 优先 Redis
try {
UserPolicy cached = redis.get(policyCacheKey(uid));
if (cached != null) return cached;
} catch (Exception e) {
log.warn("Redis unavailable, fallback to DB");
}

// 降级 DB
User user = userRepo.findActiveById(uid);
if (user != null) {
UserPolicy policy = UserPolicy.from(user);
// 尝试回填 Redis
try { redis.set(policyCacheKey(uid), policy, 300, SECONDS); }
catch (Exception ex) { log.warn("Cache refill failed"); }
return policy;
}

return null; // Redis + DB 都不可用
}

Redis 和 DB 都不可用时,策略不可判定,决策结果应拒绝。这是安全优先于可用性的选择。

权限变更时主动删除缓存:

1
redis.delete(policyCacheKey(uid));

下次请求触发缓存未命中,从 DB 重新加载。缓存 TTL 只是兜底,正常路径应依赖主动失效和权限版本号,让旧 token 尽快被拒绝。

登出

登出同时清理两层:

1
2
3
4
5
public void logout(Request request, Response response, Authentication auth) {
String sid = cookieService.resolveSid(request);
sessionStore.invalidateSid(sid); // 删除 Redis SID
cookieService.clearCookie(request, response); // 清除 Cookie
}

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 架构和降级策略

延伸阅读