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:
@@ -232,13 +232,28 @@ export function readRequestSessionToken(request: Request) {
|
|||||||
/**
|
/**
|
||||||
* 会话还在就续期并返回 true,已登出或已过期返回 false。
|
* 会话还在就续期并返回 true,已登出或已过期返回 false。
|
||||||
*
|
*
|
||||||
* 用 EXPIRE 一条命令同时完成「判断存在」和「续期」,比 GET + EXPIRE 少一趟往返。
|
* 用 EXPIRE 同时完成「判断存在」和「续期」,比 GET + EXPIRE 少一趟往返;两条 EXPIRE
|
||||||
* 续期这件事本身也是要的:HTTP 请求会走 getUserByToken 里的 redis.expire 续期,
|
* 走一次 pipeline,仍然只有一趟。
|
||||||
|
*
|
||||||
|
* 续期这件事本身是要的:HTTP 请求会走 getUserByToken 里的 redis.expire 续期,
|
||||||
* 而只开着页面挂 WebSocket 的人一次请求都不发,不该因此被算成不活跃踢下线。
|
* 而只开着页面挂 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
|
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) {
|
async function getStoredSession(c: Context) {
|
||||||
|
|||||||
@@ -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")
|
ws.close(1008, "Session expired")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ export async function sweepSessions() {
|
|||||||
let alive = checked.get(token)
|
let alive = checked.get(token)
|
||||||
if (alive === undefined) {
|
if (alive === undefined) {
|
||||||
try {
|
try {
|
||||||
alive = await touchSession(token)
|
alive = await touchSession(token, ws.data.userId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Redis 抖一下不该把全班踢下线:这一轮直接放弃,下一轮再说
|
// Redis 抖一下不该把全班踢下线:这一轮直接放弃,下一轮再说
|
||||||
console.error("Failed to verify websocket sessions", error)
|
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")
|
ws.close(1008, "Session expired")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user