后端系统工程 14:会话存储与登录撤销
系列导航:上一篇 13 RPC 客户端的请求生命周期 | 本篇 14 | 下一篇 15 配置发布与客户端收敛
登录给出的是一份可撤回的授权。用户点击“退出”、管理员踢掉一台设备、用户修改口令后选择“退出所有设备”,这些操作都要在某个时刻之后让旧凭据失效。只有一个服务实例时,这件事只是删掉一条内存记录;有多个实例时,就要回答三个问题:哪一份记录说了算,撤销之后多久所有实例都会拒绝,以及一个与撤销同时到达的“续期”请求会不会把会话救回来。
本篇用 PostgreSQL 共享会话表和四个独立 JVM 进程的 HTTP 实例回答这三个问题,并与自包含签名令牌对照。代码在 examples/14-session-store/,输出在 evidence/14/output.txt。
需求契约
接口只有六个:/login、/whoami、/refresh、/logout、/logout-all、/admin/revoke-session。会话凭据放在 Cookie sid 中。会话在以下五个条件同时成立时才有效:
- 会话行存在,令牌哈希匹配;
revoked_at为空;- 会话的
epoch等于用户当前的session_epoch; - 当前时间早于
idle_expires_at(空闲期限); - 当前时间早于
absolute_expires_at(绝对期限)。
任何实例都按这五条判断,判断用的时间取数据库的 now(),实例本机时钟不参与会话期限。由此得出几条必须维持的不变量:撤销一旦提交,没有任何正确路径能把 revoked_at 写回空值;刷新只能推迟空闲期限,而且不能越过绝对期限;同一用户的活跃会话数不超过 max_sessions。撤销生效窗口另作规定:无缓存实例在撤销提交后的下一次请求即拒绝;启用本地缓存的实例,窗口上限等于缓存 TTL。
教学取值:空闲期限 3 秒、绝对期限 6 秒(为了让实验在几秒内结束;OWASP 会话管理备忘单给出的常见空闲期限是高价值应用 2–5 分钟、低风险应用 15–30 分钟),每用户最多 3 个活跃会话,缓存 TTL 1000ms。
图 14-1:多实例共享会话表的状态归属
1 | |
令牌与 Cookie
令牌是 SecureRandom 生成的 32 字节随机数,base64url 编码后 43 个字符。OWASP 会话管理备忘单要求会话标识至少有 64 位熵并由 CSPRNG 生成;对需要自行生成令牌的情况,它建议至少 128 位(OWASP Session Management Cheat Sheet)。同一份文档还指出,高熵只能防猜测,不能防泄露:能读到会话存储的人可以直接复用未过期的令牌,如果威胁模型包括存储被只读泄露,就应只存单向校验值。会话表因此只存 SHA-256(令牌),列上有 check (length(token_hash) = 32)。令牌本身是 256 位均匀随机数,用不加盐的快速哈希即可,口令那种慢哈希是为低熵输入准备的。
login_on_A_whoami_on_B 在实例 A 登录、在实例 B 识别,并检查数据库:
1 | |
Cookie 属性的含义各有边界。RFC 6265 §4.1.2.6 规定 HttpOnly 让用户代理在通过“非 HTTP”接口(例如向脚本暴露 Cookie 的浏览器 API)访问 Cookie 时省略它;§4.1.2.5 规定 Secure 只让 Cookie 在安全通道上发送,并说明它只保护机密性,主动攻击者仍可能从不安全通道覆写它(RFC 6265)。SameSite 不在 RFC 6265 中,OWASP 要求会话 Cookie 显式设置 Strict 或 Lax,并把它当作 CSRF 的纵深防御,而不是 CSRF 令牌的替代。Cookie 不带 Max-Age 和 Expires,是浏览器会话 Cookie;会话何时失效由服务端期限决定,OWASP 也明确提醒不能依赖浏览器关闭来结束会话。
本地实验是 http,测试客户端手工带上 Cookie 头,不执行任何浏览器策略。实验只证明这些属性被设置了,浏览器是否在 http://127.0.0.1 上发送 Secure Cookie,取决于浏览器对“安全通道”的判定,本篇没有验证。
撤销的三种粒度
单会话登出把 revoked_at 置为当前时间;管理员撤销按 session_id 做同样的事;两者都带 revoked_at is null 条件,重复执行返回 revoked=0,第一次的撤销时间和原因不会被覆盖。全设备登出只做一件事:
1 | |
这是一行更新,与该用户有多少会话无关。旧会话行一行都没改,它们的 epoch 不再等于用户的 session_epoch,于是都不满足有效条件。
1 | |
三个会话分布在 A、B 两个实例上,全设备登出后在两个实例上各查一次,6 次全部以 epoch_mismatch 拒绝,库里被写成已撤销的会话行为 0;之后的新登录读到新的 epoch,正常有效。代价是会话行上看不到“为什么失效”,审计需要从用户表的 epoch 变化记录中重建。全设备登出前,实例要直接查库确认调用者的会话仍然有效,不走缓存,否则一个刚被撤销的会话还能借缓存去执行敏感操作。
刷新与撤销的并发
滑动续期是最容易出错的地方。直观写法是先读出会话行,在内存里判断有效、改掉 idle_expires_at,再把整行写回去,很多对象映射框架的“加载、修改、保存”默认就是这样做的。撤销如果恰好在“读”与“写回”之间提交,写回会把读到的 revoked_at = NULL 一并写回去。
实现采用条件 UPDATE,把有效性判断和写入放进同一条语句,并且语句只写两列:
1 | |
PostgreSQL 16 文档描述了 READ COMMITTED 下的行为:UPDATE 找到的目标行若已被并发事务修改,它会等待前一个更新者提交或回滚;前者提交后,“the WHERE clause is re-evaluated to see if the updated version of the row still matches the search condition”(PostgreSQL 16 事务隔离)。于是两种先后顺序都有确定结果:刷新先拿到行锁,撤销等待,提交后撤销照常生效;撤销先拿到行锁,刷新等待,撤销提交后刷新对新版本重新判断,revoked_at is null 不再成立,命中 0 行。这条语句从不写 revoked_at 和 epoch,所以无论谁先谁后,都不可能撤销掉撤销。
实验用两条真实 JDBC 连接控制交错,用 pg_stat_activity.wait_event_type = 'Lock' 确认后到的一方确实在等行锁,测试执行的是服务端代码里的同一份 SQL:
图 14-2:条件 UPDATE 与读改写在同一交错下的结果
sequenceDiagram
participant R as 刷新事务 c1
participant DB as t14_sessions 行
participant V as 撤销 c2
rect rgb(235,245,235)
Note over R,V: 条件 UPDATE,撤销先持锁
V->>DB: UPDATE set revoked_at=now()(未提交,持行锁)
R->>DB: UPDATE ... WHERE revoked_at is null ...(等待行锁)
V->>DB: COMMIT
DB-->>R: 对新版本重新求值 WHERE → 0 行,返回 401
end
rect rgb(250,235,235)
Note over R,V: 读改写,READ COMMITTED 事务
R->>DB: SELECT 整行(revoked_at = NULL,不加锁)
V->>DB: UPDATE set revoked_at=now();COMMIT(无需等待)
R->>DB: UPDATE 整行 set revoked_at = NULL ...;COMMIT
Note over DB: 会话复活:whoami 返回 200
end
1 | |
负向对照里有一个细节:撤销提交后、写回之前,B 实例对这个会话返回 401(whoamiBetween=401),写回之后又返回 200。撤销确实生效过,随后被一次刷新抹掉,日志里看不到任何错误。读改写还有两种修法,实验都验证了:读的时候加 SELECT … FOR UPDATE,撤销就得等刷新事务结束,之后照常生效;或者把事务提到 REPEATABLE READ,写回时 PostgreSQL 报 40001(could not serialize access due to concurrent update),刷新失败、撤销保留,调用方要处理这个错误并决定是否重试。条件 UPDATE 不需要额外加锁,也不需要重试循环,代价是业务规则写在 SQL 里,测试需要直接覆盖这条语句。
交错实验之后,再用 HTTP 制造真实并发:每轮新建一个会话,同时向 A 发刷新、向 B 发管理员撤销,结束后查询会话是否仍有效。
1 | |
条件 UPDATE 下,60 轮中刷新有 41 轮返回 200,但撤销后仍有效的会话为 0。实例 U 的读改写实现在读写之间停顿 30ms,用来模拟两次数据库往返之间的应用处理时间;撤销在刷新发出后 10ms 发出,60 轮全部复活。这个复活率由注入的时间窗口决定,不代表真实系统的发生频率;它说明的是,窗口存在时,这种实现没有任何机制阻止复活。
并发登录上限
每用户最多 3 个活跃会话。新登录超出上限时有两种策略:拒绝新登录,或淘汰最旧的会话。OWASP 把是否允许同时登录列为应用的设计决定,并要求在不允许时于每次认证后采取有效动作。本实现选择淘汰最旧的会话,理由是用户换设备时不会被锁在门外,被淘汰的设备下一次请求就会收到 401;拒绝策略适合同时在线本身就是风险信号的场景,但需要给用户一个“踢掉其他设备”的入口,否则丢失旧设备的用户无法登录。
计数和插入必须串行,否则并发登录会各自数到同一个旧计数。实现先锁用户行,再计数、淘汰、插入,整个过程在一个事务里:
1 | |
READ COMMITTED 下每条语句取新快照,所以拿到行锁后的计数能看到前一个持锁者已提交的插入。用户行锁也让登录与全设备登出串行:先登录的会话会被随后的 epoch 递增作废,后登录的会话读到新 epoch,保持有效。
1 | |
8 个并发登录分布在两个实例上,加锁后恰好剩 3 个活跃会话,淘汰 5 个。不加锁的对照在计数与插入之间停顿 30ms,结束时有 6 个活跃会话,超出上限一倍;各次登录的淘汰数是 0、0、1、2、2、0、2、0,说明它们各自依据过期的计数做了决定。
期限
1 | |
空闲超过 3 秒后,会话被拒绝,刷新也救不回来,因为刷新的 WHERE 要求当前仍在空闲期限内。每秒刷新一次的会话,在第 7 秒被拒绝,原因是 absolute_expired。6.0 秒那次刷新仍返回 200,是因为会话在测试计时开始后才创建,绝对期限比计时零点晚几毫秒。least(..., absolute_expires_at) 保证空闲期限从不越过绝对期限,表约束 idle_expires_at <= absolute_expires_at 又在库这一层兜底。OWASP 要求两种超时都有:只有空闲期限时,窃得会话的人可以靠周期性请求让它永远不过期。
撤销生效窗口与本地缓存
每次请求查一次库,是共享会话表方案的固定成本。降低它最直接的办法是在实例内缓存校验结果,代价是撤销对这个实例不再立即生效。缓存只存“有效”的结果,命中时不访问数据库;本实例上的登出会清掉自己的缓存项,其他实例的缓存不受影响。本实现没有跨实例失效通知,于是撤销窗口的上限就是 TTL。
测量方法:在 A 上登录,在 C(TTL 1000ms)上请求一次以填充缓存,随机等待 0 到 900ms,在 A 上登出;从收到登出响应开始,B 请求一次,C 每 10ms 请求一次直到 401。
1 | |
6 次测量中,B 每次都在第一次请求时拒绝;C 在撤销提交后继续接受了 287 到 940ms,取决于缓存项在撤销时已经存在了多久。这些是观察值,只说明结果落在 TTL 以内,不是窗口分布,也不能推到别的负载上。RFC 7009 §2.1 在令牌撤销的语境下承认同样的现实:撤销立即生效,但实践中可能存在传播延迟,部分服务器已经知道、另一些还不知道,实现应尽量缩小这个窗口(RFC 7009)。是否接受一个窗口,取决于被保护的操作:读取个人资料可以接受 1 秒,修改口令、转账这类操作应绕过缓存直接查库,就像本实现的 /logout-all 那样。
对照:自包含签名令牌
另一种做法是把会话信息签进令牌,实例只验签、看过期时间,不查库。对照实现用 JDK 的 HmacSHA256 生成 JWS 紧凑形式 header.payload.signature,载荷含 sub、jti、exp。RFC 7519 §4.1.4 规定,在 exp 所示时间及之后,JWT 不得被接受(RFC 7519)。验证端只接受本服务签发的固定头部 {"alg":"HS256","typ":"JWT"},对应 RFC 8725 §3.1 的要求:调用方指定允许的算法集合,库不得使用其他算法(RFC 8725)。
RFC 7519 和 RFC 8725 都没有规定撤销机制。RFC 7009 §3 讨论了自包含访问令牌:资源服务器本地校验时,若要立即撤销,需要授权服务器与资源服务器之间某种(目前未标准化的)后端交互;另一种做法是签发短寿命令牌,用刷新令牌续期,以此限制撤销后令牌仍可使用的时间。实验对两种做法各取一种最小形态:不查任何名单,或者每次请求查一次 jti 撤销名单。
1 | |
| 维度 | 服务端会话查表 | 签名令牌,不查名单 | 签名令牌 + 撤销名单 |
|---|---|---|---|
| 撤销生效 | 下一次请求(无缓存);有缓存时 ≤ TTL | 直到 exp;实验中撤销后又被接受 48 次、约 2.6 秒 |
下一次请求 |
| 每请求数据库访问(50 次请求) | 50 | 0 | 50 |
| 服务端存储 | 每个会话一行 | 无(只有签名密钥) | 每个被撤销且未过期的 jti 一行 |
| 全设备登出 | 一行 epoch 更新 | 无法表达,只能等过期或轮换密钥 | 要列出该用户所有未过期 jti,或另加按用户的版本号 |
| 期限所用时钟 | 数据库 now() |
各实例本机时钟 | 各实例本机时钟 |
查名单的令牌在每请求成本上与会话查表相同,却多了一份需要和令牌寿命同步清理的名单;不查名单的令牌免掉了数据库访问,代价是撤销窗口等于剩余寿命。缩短寿命就是缩短窗口,但会增加续期频率,而续期本身又需要一个可撤销的长期凭据,问题回到了会话表。耗时 482/421/522ms 是单次回环运行的观察值,三者差距在测量噪声以内,可比的是数据库访问次数。篡改载荷得到 bad_signature,alg=none 的头部得到 bad_header。
用例与证据
| 用例 | 类型 | 观察值(evidence/14/output.txt) |
|---|---|---|
login_on_A_whoami_on_B |
正常 | 跨实例识别;库存 32 字节哈希,原令牌行数 0 |
reject_missing_unknown_badpassword |
拒绝 | 无 Cookie、随机令牌、错误口令均 401 |
logout_on_A_rejected_on_B / admin_revoke_session |
撤销 | B 下一次请求 401 |
logout_all_by_epoch |
撤销 | 6/6 次 epoch_mismatch,未写会话行 |
session_limit_lock_user_row / 无锁对照 |
并发 | 活跃 3 / 活跃 6 |
idle_expiry_3s / absolute_expiry_6s_despite_refresh |
边界 | 空闲与绝对期限都由服务端执行 |
interleave_* 两种顺序 |
并发 | 观察到行锁等待,最终都是 revoked |
negative_control_rmw_revives_session |
负向对照 | 撤销后被写回,whoami 200 |
rmw_with_select_for_update / rmw_repeatable_read_fails |
修正 | 不复活 / 40001 |
stress_* |
真实并发 | 条件 UPDATE 复活 0;读改写复活 60/60 |
revocation_window_cache_ttl |
窗口 | 无缓存首查即拒;TTL 1000ms 时 287–940ms |
signed_token_vs_session_table |
方案对比 | 见上表 |
运行入口为 examples/14-session-store/run.sh,每次运行用 setup.sql 重建表。环境记录在 evidence/14/env.txt(PostgreSQL 16.15、OpenJDK 21.0.12.1、pgJDBC 42.7.4),实例日志为 evidence/14/instance-*.log,证据边界见 evidence/14/LIMITS.md。
结论边界
实验支持以下结论:以 PostgreSQL 会话表为唯一权威,多个实例对登出、管理员撤销和全设备登出的判断,在下一次请求时一致;刷新写成只改期限、带全部有效性条件的 UPDATE 后,无论与撤销谁先提交,都不会让会话复活,而读改写整行写回在 READ COMMITTED 下会复活;锁住用户行后,并发登录不会超出会话上限;本地缓存把撤销窗口从“下一次请求”放宽到 TTL,签名令牌不查名单时则放宽到剩余寿命。
实验没有覆盖浏览器对 Cookie 属性的实际执行(本地是 http,客户端手工带 Cookie 头),没有覆盖主备切换和从只读副本读取会话时复制延迟对撤销窗口的影响,没有实现会话标识轮换、CSRF 令牌和登录限速。窗口与耗时都是单机观察值。
会话表回答的是“这个凭据现在是否有效”。实例怎样拿到一份同时生效的配置(例如缓存 TTL 本身),以及配置变更怎样在所有实例上收敛,是 15 配置发布与客户端收敛 的内容。






