13 KiB
13 KiB
refresh_session 表设计
日期:2026-04-21
1. 文档目的
这份文档用于完成 M2 的第三条任务:设计 refresh_session。
目标是把以下几件事固定到可编码级别:
- refresh cookie 与服务端 session 表的边界
refresh、logout、logout-all、sessions/:sessionId/revoke的失效语义refresh_session与user_account.token_version的职责切分- 会话列表、当前设备识别、轮换与吊销的数据结构
补充约束:
- 多端登录识别字段以 MULTI_DEVICE_SESSION_IDENTITY_DESIGN_2026-04-21.md 为准。
- 本文负责把这些字段正式并入
refresh_session结构、迁移规则与接口读模型。
2. 当前基线
当前 Node 后端已经存在一张 user_sessions 表,并且 refresh cookie 主链已经完整可用:
- 登录成功后创建随机 refresh token,并只把原始 token 放入 HttpOnly cookie
- 服务端只存
sha256(refresh_token)结果 /api/auth/refresh会轮换 refresh token,同时更新过期时间与last_seen_at/api/auth/logout会吊销当前 refresh session,并提升token_version/api/auth/logout-all会吊销当前账号全部 refresh session,并提升token_version/api/auth/sessions依赖会话表列出当前设备与远端设备/api/auth/sessions/:sessionId/revoke只吊销目标设备,不影响当前设备
当前 Node user_sessions 字段基线:
iduser_idrefresh_token_hashclient_typeuser_agentipexpires_atrevoked_atcreated_atupdated_atlast_seen_at
这说明:
- refresh session 已经是现有系统的既有真相源。
- Rust 重写时不需要重新发明另一套“session cache + cookie state”双轨模型。
- 只需要把当前语义更明确地迁入 SpacetimeDB,并把与
user_account的职责切开。 - 旧 Node 基线只有最小
client_type + user_agent + ip粒度,本轮需要升级为结构化客户端身份模型。
3. 边界定义
3.1 refresh_session 负责
- 设备级 refresh token hash 真相
- 设备级过期时间
- 设备级吊销状态
- 设备级最后活跃时间
- 会话列表所需的客户端信息
3.2 它不负责
- access token 签发
- access token 全局失效版本号
- 用户主状态
- provider 身份绑定
- 短信验证码与微信 OAuth
3.3 与 user_account 的职责切分
固定规则:
refresh_session负责“哪台设备还能继续 refresh”user_account.token_version负责“旧 access token 是否整体失效”
因此:
logout必须同时改两层logout-all必须同时改两层sessions/:sessionId/revoke只改refresh_session/refresh只改refresh_session,不改token_version
4. cookie 与表的边界
4.1 cookie 只存原始 token
浏览器侧固定继续存:
- cookie 名:
genarrative_refresh_session - 值:原始 refresh token
HttpOnlyPath=/api/auth- 默认
SameSite=Lax - 生产环境按配置决定
Secure
4.2 表里只存 hash
refresh_session 固定只存:
sha256(refresh_token)
禁止:
- 把原始 refresh token 落库
- 把原始 refresh token 写日志
- 把 cookie 配置字段冗余进表结构
4.3 当前设备识别方式
/api/auth/sessions 的 isCurrent 固定按以下规则判断:
- 从 cookie 读出原始 refresh token
- 计算 hash
- 与
refresh_session.refresh_token_hash比较
5. 表访问级别
refresh_session 固定为 private table。
原因:
- 包含 refresh token hash
- 包含客户端 UA 与 IP
- 包含设备级会话状态
前端不直接查询该表,只能通过 Axum / view 聚合后的 DTO 读取。
6. 字段设计
| 字段名 | 类型 | 必填 | 说明 |
|---|---|---|---|
session_id |
String |
是 | 主键,建议继续沿用 usess_* 前缀。 |
user_id |
String |
是 | 归属账号 ID,外键指向 user_account.user_id。 |
refresh_token_hash |
String |
是 | 当前生效 refresh token 的哈希值。 |
client_type |
String |
是 | 终端大类,固定枚举见多端会话身份设计。 |
client_runtime |
String |
是 | 具体运行时,例如 chrome、wechat_mini_program。 |
client_platform |
String |
是 | 平台类型,例如 windows、ios、android。 |
client_instance_id |
Option<String> |
否 | 客户端实例 ID,用于区分同设备不同浏览器或同浏览器不同安装实例。 |
device_fingerprint |
Option<String> |
否 | 服务端派生的设备聚类指纹,不作为安全凭据。 |
device_display_name |
String |
是 | 用于会话列表展示的统一端侧名称。 |
mini_program_app_id |
Option<String> |
否 | 小程序 appid。 |
mini_program_env |
Option<String> |
否 | 小程序环境,例如 develop、trial、release。 |
user_agent |
Option<String> |
否 | 请求头中的 User-Agent 原文。 |
ip |
Option<String> |
否 | 会话创建时采集的客户端 IP。 |
issued_by_provider |
String |
是 | 该会话是由哪种登录链路创建,枚举固定为 password、phone、wechat。 |
expires_at |
String |
是 | 当前 refresh token 过期时间,UTC RFC3339。 |
revoked_at |
Option<String> |
否 | 会话被吊销的时间。 |
revoked_reason_code |
Option<String> |
否 | 吊销原因码,例如 logout、logout_all、session_revoke、account_disabled。 |
created_at |
String |
是 | 会话首次创建时间。 |
updated_at |
String |
是 | 最近一次会话状态变更时间。 |
last_seen_at |
String |
是 | 最近一次 refresh 成功或创建时的活跃时间。 |
补充说明:
- 当前阶段时间字段统一继续使用 UTC RFC3339 字符串。
session_id在 refresh 轮换时保持不变,不创建新会话行。issued_by_provider不是为了做 provider 身份表,而是为了后续账号安全页和审计展示保留稳定字段。device_display_name由 Axum 应用层基于结构化字段派生,不直接信任前端自由文本。client_type、client_runtime、client_platform的具体枚举口径固定受多端会话身份设计文档约束。
7. 唯一约束与索引
7.1 必须具备的唯一约束
session_id主键唯一refresh_token_hash全局唯一
7.2 必须具备的查询索引
(user_id, revoked_at, expires_at, last_seen_at DESC)作用:列当前账号活跃会话(user_id, session_id)作用:按用户吊销指定会话(expires_at, revoked_at)作用:后续清理过期/已吊销会话refresh_token_hash作用:refresh、logout、current session 判断
8. 生命周期设计
8.1 创建
触发点:
- 密码登录成功
- 手机号登录成功
- 微信登录成功
- 微信绑定手机号成功后签发正式会话
写入规则:
- 生成原始 refresh token
- 计算
refresh_token_hash - 创建一条新
refresh_session last_seen_at = created_at
8.2 刷新
触发点:
POST /api/auth/refresh
写入规则:
- 先按
refresh_token_hash找当前 session - 校验
revoked_at == null - 校验
expires_at > now - 生成新的 refresh token
- 更新同一条 session 的
refresh_token_hash - 更新
expires_at - 更新
last_seen_at - 更新
updated_at
关键约束:
- refresh 是“同一会话轮换”,不是“新建第二条会话”。
session_id在轮换前后必须稳定,保证会话列表中的设备 ID 不跳变。
8.3 吊销当前会话
触发点:
POST /api/auth/logout
写入规则:
- 按当前 cookie 找 session
- 写
revoked_at = now - 写
revoked_reason_code = logout - 同时提升
user_account.token_version
8.4 吊销全部会话
触发点:
POST /api/auth/logout-all
写入规则:
- 按
user_id批量吊销全部未吊销 session revoked_reason_code = logout_all- 同时提升
user_account.token_version
8.5 吊销指定远端设备
触发点:
POST /api/auth/sessions/:sessionId/revoke
写入规则:
- 只允许吊销同一
user_id下的目标 session - 当前设备不允许通过该接口吊销自己
- 只改目标
refresh_session revoked_reason_code = session_revoke- 不提升
token_version
8.6 账号被禁用或并入
触发点:
user_account.account_status = disabled
写入规则:
- 该账号下所有未吊销 session 都必须被批量吊销
revoked_reason_code = account_disabled
9. 活跃态判断规则
一条 refresh_session 只有同时满足以下条件,才视为活跃:
revoked_at = nullexpires_at > now- 所属
user_account.account_status = active或允许 refresh 的待绑定状态
补充约束:
- 当前阶段
pending_bind_phone的微信壳账号允许 refresh,但只允许继续走绑定手机号相关接口。 disabled账号无论 session 本身是否过期,都不能继续 refresh。
10. 与现有接口的映射
10.1 POST /api/auth/refresh
依赖:
refresh_session.refresh_token_hashrefresh_session.expires_atrefresh_session.revoked_atuser_account.account_statususer_account.token_version
10.2 GET /api/auth/sessions
直接读取:
session_idclient_typeclient_runtimeclient_platformdevice_display_namemini_program_app_idmini_program_envuser_agentipcreated_atlast_seen_atexpires_at
前端 DTO 侧:
clientLabel当前阶段继续兼容保留,但固定与deviceDisplayName对齐。ipMasked、isCurrent继续在 Axum 侧派生。
10.3 POST /api/auth/logout
依赖:
- 当前 cookie 命中的
refresh_session user_account.token_version
10.4 POST /api/auth/logout-all
依赖:
- 当前
user_id下全部活跃refresh_session user_account.token_version
11. 与当前 Node user_sessions 的映射关系
Node user_sessions 列 |
新 refresh_session 字段 |
迁移规则 |
|---|---|---|
id |
session_id |
原样迁移。 |
user_id |
user_id |
原样迁移。 |
refresh_token_hash |
refresh_token_hash |
原样迁移。 |
client_type |
client_type |
browser 在迁移后统一归一为 web_browser。 |
user_agent |
user_agent |
原样迁移。 |
ip |
ip |
原样迁移。 |
expires_at |
expires_at |
原样迁移。 |
revoked_at |
revoked_at |
原样迁移。 |
created_at |
created_at |
原样迁移。 |
updated_at |
updated_at |
原样迁移。 |
last_seen_at |
last_seen_at |
原样迁移。 |
新增字段回填规则:
issued_by_provider初次迁移统一回填为password说明:这是保守回填值,后续只影响展示,不影响鉴权正确性revoked_reason_code初次迁移统一回填为nullclient_runtime初次迁移按User-Agent粗判,无法识别时回填unknownclient_platform初次迁移按User-Agent粗判,无法识别时回填unknownclient_instance_id初次迁移统一回填为nulldevice_fingerprint初次迁移按client_type + client_runtime + client_platform + normalized_user_agent派生device_display_name初次迁移由后端按多端会话身份规则派生mini_program_app_id初次迁移统一回填为nullmini_program_env初次迁移统一回填为null
12. reducer / service 落地约束
12.1 module-auth reducer 层
必须至少具备这些命令入口:
create_refresh_sessionrotate_refresh_sessionrevoke_refresh_sessionrevoke_refresh_sessions_by_userrevoke_refresh_session_by_user_and_sessiontouch_refresh_session_last_seen
12.2 Axum 应用层
固定负责:
- 生成原始 refresh token
- 计算 hash
- 读写 HttpOnly cookie
- 决定当前调用是创建、轮换还是吊销
- 把
revoked_reason_code映射到对应业务语义
13. 不允许的设计漂移
后续实现时禁止出现以下情况:
- refresh 轮换时新建第二条 session,而不是更新原 session
sessions/:sessionId/revoke顺手提升token_version,导致当前 access token 一起失效logout-all只提升token_version,却不吊销 refresh session- 原始 refresh token 直接入库
- 会话表开始承担
user_account状态职责
14. 本任务完成定义
当以下条件满足时,设计 refresh_session 视为完成:
- refresh cookie 与服务端 session hash 的边界已经明确。
- 轮换、当前设备吊销、全部设备吊销三种语义已经切开。
refresh_session与user_account.token_version的职责已明确。- 字段、唯一约束、索引与迁移规则已具体到可直接编码。
15. 依据文件
server-node/src/routes/authRoutes.tsserver-node/src/auth/authService.tsserver-node/src/auth/refreshSessionCookie.tsserver-node/src/repositories/userSessionRepository.tsserver-node/src/config.tsserver-node/src/db/migrations.tsserver-node/src/app.test.tsdocs/prd/ACCOUNT_SYSTEM_AND_LOGIN_ENTRY_PRD_2026-04-09.mddocs/technical/SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.mddocs/technical/SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.mddocs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md