fix(会话): touchSession 续期时一并续反向索引,否则会话吊销不掉

WebSocket 巡检走的 touchSession 只 EXPIRE `session:<token>`,不碰
`user-sessions:<uid>`。而这条路径存在的理由恰恰是「只开着页面挂 WebSocket、一次
HTTP 请求都不发的人」—— 这种连接碰不到 getUserByToken 里那两条并排的 expire。

于是索引先到期、会话却被巡检一直续着。之后改密码 / 禁用账号走 revokeUserSessions
就 SMEMBERS 不到这张 token:WebSocket 那边还有 publishSessionRevoked 按 userId
兜底能断掉,但 HTTP 一侧拿着那张 cookie 照用不误 —— 而改密码要的恰恰是让 HTTP
立刻失效(学生密码是明文存着给老师查的,改密码是密码泄露后唯一的补救手段)。

签名加一个 userId,两条 EXPIRE 走一次 pipeline,仍然只有一趟往返,原来「比
GET + EXPIRE 少一趟」的理由保住了。三个调用点都有现成的 ws.data.userId。

返回值只看会话那条:反向索引是 498fc1c 才加的,在那之前签发的会话本来就没有索引
键,续不到是正常的,不能因此判定会话已死。

实跑验过四种情况:正常会话两边都续到 7 天;无索引键的存量会话仍判活;会话已删返回
false;空 token 返回 false。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XvmqDsZNyUo9P3sFQtoWVB
This commit is contained in:
2026-09-07 07:40:58 -06:00
parent 25b86ec17e
commit 22a7700b89
3 changed files with 22 additions and 7 deletions

View File

@@ -232,13 +232,28 @@ export function readRequestSessionToken(request: Request) {
/**
* 会话还在就续期并返回 true已登出或已过期返回 false。
*
* 用 EXPIRE 一条命令同时完成「判断存在」和「续期」,比 GET + EXPIRE 少一趟往返
* 续期这件事本身也是要的HTTP 请求会走 getUserByToken 里的 redis.expire 续期,
* 用 EXPIRE 同时完成「判断存在」和「续期」,比 GET + EXPIRE 少一趟往返;两条 EXPIRE
* 走一次 pipeline仍然只有一趟。
*
* 续期这件事本身是要的HTTP 请求会走 getUserByToken 里的 redis.expire 续期,
* 而只开着页面挂 WebSocket 的人一次请求都不发,不该因此被算成不活跃踢下线。
*
* **反向索引必须跟着一起续。** 走到这里的正是那种一次 HTTP 请求都不发的连接,
* 它碰不到 getUserByToken 里那两条并排的 expire。只续会话不续索引的话索引先到期、
* 会话却被巡检一直续着,之后改密码 / 禁用账号走 revokeUserSessions 就 SMEMBERS
* 不到这张 token —— WebSocket 那边还有 publishSessionRevoked 按 userId 兜底能断掉,
* 但 HTTP 一侧拿着那张 cookie 照用不误,而改密码要的恰恰是让 HTTP 立刻失效。
*/
export async function touchSession(token: string) {
export async function touchSession(token: string, userId: number) {
if (!token) return false
return (await redis.expire(sessionKey(token), config.sessionTtlSeconds)) === 1
const results = await redis
.pipeline()
.expire(sessionKey(token), config.sessionTtlSeconds)
.expire(userSessionsKey(userId), config.sessionTtlSeconds)
.exec()
// 索引那条的返回值不看:存量会话(反向索引上线之前签发的)本来就没有索引键,
// 续不到很正常,不能因此判定会话已死
return results?.[0]?.[1] === 1
}
async function getStoredSession(c: Context) {

View File

@@ -202,7 +202,7 @@ export async function handleCollabMessage(ws: CollabSocket, raw: string) {
}
// 握手时校验过一次不算数 —— 这条连接能挂几个小时
if (!(await touchSession(ws.data.token))) {
if (!(await touchSession(ws.data.token, ws.data.userId))) {
ws.close(1008, "Session expired")
return
}

View File

@@ -178,7 +178,7 @@ export async function sweepSessions() {
let alive = checked.get(token)
if (alive === undefined) {
try {
alive = await touchSession(token)
alive = await touchSession(token, ws.data.userId)
} catch (error) {
// Redis 抖一下不该把全班踢下线:这一轮直接放弃,下一轮再说
console.error("Failed to verify websocket sessions", error)
@@ -299,7 +299,7 @@ async function handleMessage(
// 会话可能在连接期间就失效了:用户在别的标签页登出,或者会话自己到期。
// 握手时校验过一次不算数 —— 这条连接能挂几个小时。
if (!(await touchSession(ws.data.token))) {
if (!(await touchSession(ws.data.token, ws.data.userId))) {
ws.close(1008, "Session expired")
return
}