From cc84656a1fb3d495f226777571aa280a5a1c6c10 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 7 Jun 2026 20:54:35 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=A4=9A=E7=AB=AF=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E4=BA=92=E7=9B=B8=E9=A1=B6=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 单设备退出只撤销当前 refresh session,不再提升账号级 token_version 认证中间件和 refresh 接口在本进程未命中会话时按需刷新 SpacetimeDB 认证工作集 补充多端登录与跨进程会话补水回归测试 同步项目文档和 Hermes 共享决策记录 --- .hermes/shared-memory/decision-log.md | 10 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 3 +- ...项目基线】当前产品与工程约束-2026-05-15.md | 1 + server-rs/crates/api-server/src/app.rs | 105 ++++++++++++ server-rs/crates/api-server/src/auth.rs | 152 ++++++++++++++---- .../crates/api-server/src/refresh_session.rs | 53 ++++-- server-rs/crates/api-server/src/state.rs | 43 ++++- .../crates/module-auth/src/application.rs | 6 + server-rs/crates/module-auth/src/lib.rs | 145 +++++++++++++++-- 9 files changed, 463 insertions(+), 55 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 87868a02..7b88c272 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -32,6 +32,14 @@ - 验证方式:`npm run test -- src/components/auth/AuthGate.test.tsx`。 - 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 +## 2026-06-07 多端登录以 refresh session 为粒度互不顶号 + +- 背景:同一账号在多端登录后,若单设备退出或请求被打到尚未见过该 session 的 api-server 进程,旧设备会被误判为登录态失效。 +- 决策:普通登录只新增当前设备 refresh session,不撤销其它 active session;`POST /api/auth/logout` 只撤销当前 refresh session,不再提升账号级 `token_version`;`POST /api/auth/logout-all`、改密和重置密码继续吊销全端 session 并提升 `token_version`。api-server 鉴权和 refresh cookie 轮换在本进程工作集未命中 session 时,先从 SpacetimeDB 正式认证表按需刷新一次工作集再复查,支持多实例和滚动重启下的新会话被所有进程识别。 +- 影响范围:`module-auth` refresh session 语义、`api-server` Bearer 鉴权和 `/api/auth/refresh`、账号安全页多端会话。 +- 验证方式:`cargo test -p module-auth logout_current_session --manifest-path server-rs/Cargo.toml`、`cargo test -p module-auth refresh_from_snapshot_json_merges_session_created_by_another_process --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server logout_current_device_keeps_other_device_session_alive --manifest-path server-rs/Cargo.toml`。 +- 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 + ## 2026-06-07 跳一跳排行榜展示名禁止泄露内部身份键 - 背景:跳一跳排行榜曾在结果页和运行态失败弹窗里直接展示 `playerId` / `user_id`,用户可见内容暴露了内部身份键。 @@ -723,7 +731,7 @@ ## 2026-05-13 refresh_session 会话组后端聚合与远端踢下线 - 背景:账号安全页中同设备同 IP 的多条 active `refresh_session` 会重复展示;退出登录没有稳定撤销当前 refresh session;前端“踢下线”只做本地状态变化,未真正让远端设备失效。 -- 决策:`GET /api/auth/sessions` 由后端按“同设备 + 同 IP”聚合 active refresh sessions,响应保留代表 `sessionId` 并新增 `sessionIds/sessionCount`;组内包含当前 refresh hash 或 Bearer `sid` 时整组视为当前设备组,前端不展示踢下线。新增 `POST /api/auth/sessions/{session_id}/revoke`,只允许撤销当前用户自己的非当前会话,不递增 `token_version`,但认证中间件会校验 access token `sid` 对应 active refresh session,使被踢设备立即失效。`/api/auth/logout` 在 refresh cookie 缺失时回退用 Bearer `sid` 撤销当前 session,并继续递增 `token_version`。 +- 决策:`GET /api/auth/sessions` 由后端按“同设备 + 同 IP”聚合 active refresh sessions,响应保留代表 `sessionId` 并新增 `sessionIds/sessionCount`;组内包含当前 refresh hash 或 Bearer `sid` 时整组视为当前设备组,前端不展示踢下线。新增 `POST /api/auth/sessions/{session_id}/revoke`,只允许撤销当前用户自己的非当前会话,不递增 `token_version`,但认证中间件会校验 access token `sid` 对应 active refresh session,使被踢设备立即失效。`/api/auth/logout` 在 refresh cookie 缺失时回退用 Bearer `sid` 撤销当前 session;自 2026-06-07 起单设备退出也不再递增 `token_version`,避免误伤其它设备,只有退出全部设备和改密类安全动作提升账号级版本。 - 影响范围:`module-auth` refresh session service、`api-server` auth middleware/logout/sessions route、`shared-contracts`/TS auth contract、`AuthGate`、`AccountModal`、认证会话技术文档和路由/埋点索引。 - 验证方式:执行 `cargo test -p module-auth --manifest-path server-rs/Cargo.toml refresh_session`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml auth_sessions -- --nocapture`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml revoke_auth_session -- --nocapture`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml logout_succeeds_without_refresh_cookie_when_bearer_token_is_valid -- --nocapture`、`npm run test -- AuthGate.test.tsx AccountModal.test.tsx authService.test.ts`、`npm run check:encoding`、`git diff --check`,并用 `npm run dev:api-server` 检查 `/healthz`。 - 关联文档:`docs/technical/AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md`、`docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md`、`docs/technical/SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md`。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 2b996168..e031ab73 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -78,6 +78,7 @@ npm run check:server-rs-ddd - `AuthUserPayload` / `AuthUser` 只保留前端当前会用到的身份与绑定展示字段:`id`、`publicUserCode`、`displayName`、`avatarUrl`、`phoneNumber`、`phoneNumberMasked`、`loginMethod`、`bindingStatus`、`wechatBound`、`wechatDisplayName`、`wechatAccount`。账号信息面板展示微信绑定时优先使用 `wechatDisplayName`;该字段只能来自微信平台 profile、历史已保存的微信身份资料,或小程序原生 `input type="nickname"` 提交的 `displayName`,不得用系统账号显示名或“微信旅人”这类假昵称兜底。小程序 `/api/auth/wechat/miniprogram-login` 与 `/api/auth/wechat/bind-phone` 可接收 `displayName`;`/api/auth/wechat/miniprogram-login` 额外返回 `created`,供小程序壳在快捷登录后判断是否需要补采集微信昵称。`jscode2session` 无法直接返回微信昵称或个人微信号,只能稳定拿到小程序维度 `openid`,后端以 `wechatAccount` 下发可区分的绑定账号标识,前端在缺少真实昵称时展示账号尾号。 - `AuthSessionSummaryPayload` / `AuthSessionSummary` 只保留设备卡片与撤销需要的摘要字段:`sessionId`、`sessionIds`、`sessionCount`、`clientLabel`、`ipMasked`、`isCurrent`、`createdAt`、`lastSeenAt`、`expiresAt`。 - 设备诊断信息(例如原始 `clientType` / `clientRuntime` / `clientPlatform` / `userAgent` / `miniProgramAppId` / `miniProgramEnv` / `deviceDisplayName`)不再默认下发到前端;若未来确需展示,优先单独加窄 DTO,而不是把账号 / 会话快照恢复为全量对象。 +- 多端登录语义以 `refresh_session` 为粒度:同一账号可保留多个 active session,普通登录不会撤销旧设备;`POST /api/auth/logout` 只撤销当前 refresh session,不提升 `token_version`;`POST /api/auth/logout-all`、改密、重置密码才吊销全端 session 并提升 `token_version`。鉴权中间件仍校验 Bearer `sid` 对应的 refresh session 是否 active,单独踢下线或当前设备退出可以让目标设备立即失效而不误伤其它设备。 ## api-server 模块化演进规则 @@ -244,7 +245,7 @@ npm run check:server-rs-ddd - Rust 结构体:`AuthStoreSnapshot` - 源码:`server-rs/crates/spacetime-module/src/auth/tables.rs` -认证恢复策略:`api-server` 启动时只从 SpacetimeDB 正式认证表(`user_account` / `auth_identity` / `refresh_session`)投影恢复进程内认证工作集;`auth_store_snapshot` 只保留行级快照备查,不再作为启动兜底来源。`module-auth` 只保留内存工作集和 JSON 导入 / 导出能力,不再写本地持久化文件;`auth-store.json` / `GENARRATIVE_AUTH_STORE_PATH` 不再是兼容恢复源,也不得在启动时回写覆盖 `auth_identity` / `user_account`。认证创建、登录会话、刷新、退出、改密、重置密码、绑定和资料变更等写操作必须在返回客户端前成功同步 SpacetimeDB 正式认证表;同步失败时接口返回错误,不允许把只存在于当前进程内存的账号或会话当成成功结果。新用户注册奖励、邀请码绑定和登录埋点必须排在认证同步成功之后,避免认证没落库时先写出钱包或邀请关系。若启动恢复阶段 SpacetimeDB 不可连接或超时,`api-server` 进入依赖不可用模式并对请求返回 `503 SERVICE_UNAVAILABLE`,直到运维恢复 SpacetimeDB 并重启服务。 +认证恢复策略:`api-server` 启动时只从 SpacetimeDB 正式认证表(`user_account` / `auth_identity` / `refresh_session`)投影恢复进程内认证工作集;运行中若 Bearer `sid` 或 refresh cookie 在本进程工作集内未命中,会先从 SpacetimeDB 正式认证表按需刷新一次认证工作集再复查,避免多实例或滚动重启时新登录设备只被签发它的进程认识。`auth_store_snapshot` 只保留行级快照备查,不再作为启动兜底来源。`module-auth` 只保留内存工作集和 JSON 导入 / 导出能力,不再写本地持久化文件;`auth-store.json` / `GENARRATIVE_AUTH_STORE_PATH` 不再是兼容恢复源,也不得在启动时回写覆盖 `auth_identity` / `user_account`。认证创建、登录会话、刷新、退出、改密、重置密码、绑定和资料变更等写操作必须在返回客户端前成功同步 SpacetimeDB 正式认证表;同步失败时接口返回错误,不允许把只存在于当前进程内存的账号或会话当成成功结果。新用户注册奖励、邀请码绑定和登录埋点必须排在认证同步成功之后,避免认证没落库时先写出钱包或邀请关系。若启动恢复阶段 SpacetimeDB 不可连接或超时,`api-server` 进入依赖不可用模式并对请求返回 `503 SERVICE_UNAVAILABLE`,直到运维恢复 SpacetimeDB 并重启服务。 `auth_store_snapshot` 禁止再写单行 `snapshot_id = "default"` 聚合 JSON。认证同步入口收到 `module-auth` 整份快照后必须拆成行级记录写入同一张表,当前行键前缀包括:`meta/next_user_id`、`user/`、`phone/`、`session/`、`session_hash/`、`wechat/`、`union/`。SpacetimeDB 模块只保留 `import_auth_store_snapshot_json` 与 `export_auth_store_snapshot_from_tables` 两个认证快照过程;旧 `get_auth_store_snapshot`、`upsert_auth_store_snapshot`、`import_auth_store_snapshot` 兼容入口已删除。导入正式表时只按主键 upsert 本次快照包含的用户、身份和会话,避免过期快照把其他用户整表删除。 diff --git a/docs/【项目基线】当前产品与工程约束-2026-05-15.md b/docs/【项目基线】当前产品与工程约束-2026-05-15.md index 88304754..884383dd 100644 --- a/docs/【项目基线】当前产品与工程约束-2026-05-15.md +++ b/docs/【项目基线】当前产品与工程约束-2026-05-15.md @@ -51,6 +51,7 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台。当 8. 小程序 `web-view` 外壳运行时通过 `wx.getAccountInfoSync().miniProgram.envVersion` 自动识别版本:线上版 `release` 使用 `www.genarrative.world`,体验版 `trial` 与开发版 `develop` 使用 `dev.genarrative.world`;传给后端的 `x-mini-program-env` 分别为 `release`、`trial`、`dev`。 9. 账号信息面板只展示 `账号信息` 标题;绑定手机号和绑定微信以紧凑模块展示当前绑定状态,已绑定手机号展示完整手机号,已绑定微信优先展示微信平台实际返回并由后端保存的 `wechatDisplayName`。小程序 `jscode2session` 不能直接返回微信昵称或个人微信号,只能稳定拿到当前小程序维度的 `openid`,并在满足微信开放平台条件时拿到 `unionid`;小程序昵称来自快捷登录后按需展示的原生 `input type="nickname"` 提交的 `displayName`。后端下发 `wechatAccount` 作为绑定账号标识,前端在没有真实昵称时展示微信账号尾号,不展示裸“已绑定”。换绑入口放在对应模块右上角,退出登录和退出全部设备固定放在面板内容最底部。 10. H5 登录态从未登录变为已登录,或从已登录变为未登录后,必须刷新当前页面一次,确保推荐运行态、作品架、个人缓存和私有 query 都按新身份重新初始化;普通 access token 续期、账号资料更新和同一登录态内的设置变化不得触发整页刷新。 +11. 同一账号允许多端同时在线。新增登录和单设备退出只影响对应 refresh session,不得提升账号级 `tokenVersion` 让其它设备的 access token 失效;只有“退出全部设备”、修改密码、重置密码等明确安全动作才吊销全端 refresh session 并提升 `tokenVersion`。 ## 账户与充值 diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 339abb1a..05f4f6d1 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -3844,6 +3844,111 @@ mod tests { assert_eq!(me_response.status(), StatusCode::UNAUTHORIZED); } + #[tokio::test] + async fn logout_current_device_keeps_other_device_session_alive() { + let state = AppState::new(AppConfig::default()).expect("state should build"); + seed_phone_user_with_password(&state, "13800138031", TEST_PASSWORD).await; + let app = build_router(state); + + let first_login_response = password_login_request_with_client( + app.clone(), + "13800138031", + TEST_PASSWORD, + "logout-current-device", + "203.0.113.41", + ) + .await; + let first_refresh_cookie = first_login_response + .headers() + .get("set-cookie") + .and_then(|value| value.to_str().ok()) + .expect("first refresh cookie should exist") + .to_string(); + let first_login_body = first_login_response + .into_body() + .collect() + .await + .expect("first login body should collect") + .to_bytes(); + let first_access_token = read_access_token(&first_login_body); + + let second_login_response = password_login_request_with_client( + app.clone(), + "13800138031", + TEST_PASSWORD, + "logout-other-device", + "203.0.113.42", + ) + .await; + let second_refresh_cookie = second_login_response + .headers() + .get("set-cookie") + .and_then(|value| value.to_str().ok()) + .expect("second refresh cookie should exist") + .to_string(); + let second_login_body = second_login_response + .into_body() + .collect() + .await + .expect("second login body should collect") + .to_bytes(); + let second_access_token = read_access_token(&second_login_body); + + let logout_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/logout") + .header("authorization", format!("Bearer {first_access_token}")) + .header("cookie", first_refresh_cookie) + .body(Body::empty()) + .expect("logout request should build"), + ) + .await + .expect("logout request should succeed"); + assert_eq!(logout_response.status(), StatusCode::OK); + + let first_me_response = app + .clone() + .oneshot( + Request::builder() + .uri("/api/auth/me") + .header("authorization", format!("Bearer {first_access_token}")) + .body(Body::empty()) + .expect("first me request should build"), + ) + .await + .expect("first me request should succeed"); + assert_eq!(first_me_response.status(), StatusCode::UNAUTHORIZED); + + let second_me_response = app + .clone() + .oneshot( + Request::builder() + .uri("/api/auth/me") + .header("authorization", format!("Bearer {second_access_token}")) + .body(Body::empty()) + .expect("second me request should build"), + ) + .await + .expect("second me request should succeed"); + assert_eq!(second_me_response.status(), StatusCode::OK); + + let second_refresh_response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/refresh") + .header("cookie", second_refresh_cookie) + .body(Body::empty()) + .expect("second refresh request should build"), + ) + .await + .expect("second refresh request should succeed"); + assert_eq!(second_refresh_response.status(), StatusCode::OK); + } + #[tokio::test] async fn logout_succeeds_without_refresh_cookie_when_bearer_token_is_valid() { let state = AppState::new(AppConfig::default()).expect("state should build"); diff --git a/server-rs/crates/api-server/src/auth.rs b/server-rs/crates/api-server/src/auth.rs index 75626047..93380d4d 100644 --- a/server-rs/crates/api-server/src/auth.rs +++ b/server-rs/crates/api-server/src/auth.rs @@ -135,7 +135,10 @@ pub async fn require_bearer_auth( mut request: Request, next: Next, ) -> Result { - let Some(authenticated) = authenticate_request(&state, &request)? else { + let path = request.uri().path().to_string(); + let headers = request.headers().clone(); + let request_id = request_id_from_request(&request); + let Some(authenticated) = authenticate_request(&state, path, headers, request_id).await? else { return Err(AppError::from_status(StatusCode::UNAUTHORIZED)); }; request.extensions_mut().insert(authenticated.clone()); @@ -151,7 +154,11 @@ pub async fn require_runtime_principal_auth( mut request: Request, next: Next, ) -> Result { - let Some(principal) = authenticate_runtime_principal(&state, &request)? else { + let path = request.uri().path().to_string(); + let headers = request.headers().clone(); + let request_id = request_id_from_request(&request); + let Some(principal) = authenticate_runtime_principal(&state, path, headers, request_id).await? + else { return Err(AppError::from_status(StatusCode::UNAUTHORIZED)); }; request.extensions_mut().insert(principal.clone()); @@ -162,24 +169,21 @@ pub async fn require_runtime_principal_auth( Ok(response) } -fn authenticate_runtime_principal( +async fn authenticate_runtime_principal( state: &AppState, - request: &Request, + path: String, + headers: HeaderMap, + request_id: String, ) -> Result, AppError> { - if !request.headers().contains_key(AUTHORIZATION) { + if !headers.contains_key(AUTHORIZATION) { return Ok(None); } - match authenticate_request(state, request) { + match authenticate_request(state, path, headers.clone(), request_id.clone()).await { Ok(Some(authenticated)) => Ok(Some(RuntimePrincipal::User(authenticated))), Ok(None) => Ok(None), Err(_) => { - let bearer_token = extract_bearer_token(request.headers())?; - let request_id = request - .extensions() - .get::() - .map(|context| context.request_id().to_string()) - .unwrap_or_else(|| "unknown".to_string()); + let bearer_token = extract_bearer_token(&headers)?; let claims = verify_runtime_guest_token(&bearer_token, state.auth_jwt_config()) .map_err(|error| { warn!( @@ -202,26 +206,23 @@ fn authenticate_runtime_principal( } } -fn authenticate_request( +async fn authenticate_request( state: &AppState, - request: &Request, + path: String, + headers: HeaderMap, + request_id: String, ) -> Result, AppError> { - if allows_internal_forwarded_auth(request.uri().path()) { - if let Some(claims) = try_build_internal_forwarded_claims(&state, request.headers()) { + if allows_internal_forwarded_auth(&path) { + if let Some(claims) = try_build_internal_forwarded_claims(state, &headers) { return Ok(Some(AuthenticatedAccessToken::new(claims))); } } - if !request.headers().contains_key(AUTHORIZATION) { + if !headers.contains_key(AUTHORIZATION) { return Ok(None); } - let bearer_token = extract_bearer_token(request.headers())?; - let request_id = request - .extensions() - .get::() - .map(|context| context.request_id().to_string()) - .unwrap_or_else(|| "unknown".to_string()); + let bearer_token = extract_bearer_token(&headers)?; let claims = verify_access_token(&bearer_token, state.auth_jwt_config()).map_err(|error| { warn!( %request_id, @@ -230,7 +231,7 @@ fn authenticate_request( ); AppError::from_status(StatusCode::UNAUTHORIZED) })?; - let current_user = state + let mut current_user = state .auth_user_service() .get_user_by_id(claims.user_id()) .map_err(|error| { @@ -240,15 +241,52 @@ fn authenticate_request( "Bearer JWT 用户快照读取失败" ); AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) - })? - .ok_or_else(|| { - warn!( - %request_id, - user_id = %claims.user_id(), - "Bearer JWT 对应用户不存在" - ); - AppError::from_status(StatusCode::UNAUTHORIZED) })?; + if current_user.is_none() { + warn!( + %request_id, + user_id = %claims.user_id(), + "Bearer JWT 对应用户不存在,准备刷新认证工作集后复查" + ); + if refresh_auth_store_for_stale_bearer(state, &request_id, claims.user_id()).await { + current_user = state + .auth_user_service() + .get_user_by_id(claims.user_id()) + .map_err(|error| { + warn!( + %request_id, + error = %error, + "Bearer JWT 用户快照刷新后读取失败" + ); + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + })?; + } + } + let Some(mut current_user) = current_user else { + warn!( + %request_id, + user_id = %claims.user_id(), + "Bearer JWT 对应用户不存在" + ); + return Err(AppError::from_status(StatusCode::UNAUTHORIZED)); + }; + if current_user.token_version != claims.token_version() { + if refresh_auth_store_for_stale_bearer(state, &request_id, claims.user_id()).await + && let Some(refreshed_user) = state + .auth_user_service() + .get_user_by_id(claims.user_id()) + .map_err(|error| { + warn!( + %request_id, + error = %error, + "Bearer JWT 用户版本刷新后读取失败" + ); + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + })? + { + current_user = refreshed_user; + } + } if current_user.token_version != claims.token_version() { warn!( %request_id, @@ -261,7 +299,7 @@ fn authenticate_request( .with_message("当前登录态已失效,请重新登录")); } - let session_is_active = state + let mut session_is_active = state .refresh_session_service() .is_session_active_for_user( claims.user_id(), @@ -278,6 +316,27 @@ fn authenticate_request( ); AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) })?; + if !session_is_active + && refresh_auth_store_for_stale_bearer(state, &request_id, claims.user_id()).await + { + session_is_active = state + .refresh_session_service() + .is_session_active_for_user( + claims.user_id(), + claims.session_id(), + OffsetDateTime::now_utc(), + ) + .map_err(|error| { + warn!( + %request_id, + user_id = %claims.user_id(), + session_id = %claims.session_id(), + error = %error, + "Bearer JWT refresh session 刷新后状态读取失败" + ); + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + })?; + } if !session_is_active { warn!( %request_id, @@ -292,6 +351,33 @@ fn authenticate_request( Ok(Some(AuthenticatedAccessToken::new(claims))) } +fn request_id_from_request(request: &Request) -> String { + request + .extensions() + .get::() + .map(|context| context.request_id().to_string()) + .unwrap_or_else(|| "unknown".to_string()) +} + +async fn refresh_auth_store_for_stale_bearer( + state: &AppState, + request_id: &str, + user_id: &str, +) -> bool { + match state.refresh_auth_store_from_spacetime().await { + Ok(refreshed) => refreshed, + Err(error) => { + warn!( + %request_id, + user_id = %user_id, + error = %error, + "刷新认证工作集失败,继续按本进程现有状态处理" + ); + false + } + } +} + pub async fn inspect_auth_claims( Extension(request_context): Extension, Extension(authenticated): Extension, diff --git a/server-rs/crates/api-server/src/refresh_session.rs b/server-rs/crates/api-server/src/refresh_session.rs index 2c45a948..331f2ea7 100644 --- a/server-rs/crates/api-server/src/refresh_session.rs +++ b/server-rs/crates/api-server/src/refresh_session.rs @@ -7,6 +7,7 @@ use module_auth::{RefreshSessionError, RotateRefreshSessionInput}; use platform_auth::hash_refresh_session_token; use shared_contracts::auth::RefreshSessionResponse; use time::OffsetDateTime; +use tracing::warn; use crate::{ api_response::json_success_body, @@ -39,16 +40,48 @@ pub async fn refresh_session( let next_refresh_token = platform_auth::create_refresh_session_token(); let next_refresh_token_hash = hash_refresh_session_token(&next_refresh_token); - let rotated = state - .refresh_session_service() - .rotate_session( - RotateRefreshSessionInput { - refresh_token_hash, - next_refresh_token_hash, - }, - OffsetDateTime::now_utc(), - ) - .map_err(|error| map_refresh_error_with_clear_cookie(&state, error))?; + let rotated = match state.refresh_session_service().rotate_session( + RotateRefreshSessionInput { + refresh_token_hash: refresh_token_hash.clone(), + next_refresh_token_hash: next_refresh_token_hash.clone(), + }, + OffsetDateTime::now_utc(), + ) { + Ok(rotated) => rotated, + Err(RefreshSessionError::SessionNotFound) => { + match state.refresh_auth_store_from_spacetime().await { + Ok(true) => {} + Ok(false) => { + return Err(map_refresh_error_with_clear_cookie( + &state, + RefreshSessionError::SessionNotFound, + )); + } + Err(error) => { + warn!( + request_id = request_context.request_id(), + error = %error, + "refresh session 本地未命中后刷新认证工作集失败" + ); + return Err(map_refresh_error_with_clear_cookie( + &state, + RefreshSessionError::SessionNotFound, + )); + } + } + state + .refresh_session_service() + .rotate_session( + RotateRefreshSessionInput { + refresh_token_hash, + next_refresh_token_hash, + }, + OffsetDateTime::now_utc(), + ) + .map_err(|error| map_refresh_error_with_clear_cookie(&state, error))? + } + Err(error) => return Err(map_refresh_error_with_clear_cookie(&state, error)), + }; let access_token = sign_access_token_for_user( &state, &rotated.user, diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index d4bbb445..51cab967 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -12,7 +12,8 @@ use axum::extract::FromRef; use module_ai::{AiTaskService, InMemoryAiTaskStore}; use module_auth::{ AuthUserService, InMemoryAuthStore, PasswordEntryService, PhoneAuthService, - RefreshSessionService, WechatAuthService, WechatAuthStateService, + RefreshAuthStoreSnapshotResult, RefreshSessionService, WechatAuthService, + WechatAuthStateService, }; use module_runtime::RuntimeSnapshotRecord; #[cfg(test)] @@ -660,6 +661,46 @@ impl AppState { Ok(()) } + pub fn refresh_auth_store_from_snapshot_json( + &self, + snapshot_json: &str, + ) -> Result { + self.auth_store + .refresh_from_snapshot_json(snapshot_json) + .map_err(SpacetimeClientError::Runtime) + } + + pub async fn refresh_auth_store_from_spacetime(&self) -> Result { + #[cfg(test)] + { + return Ok(false); + } + + #[cfg(not(test))] + { + let snapshot = self + .spacetime_client + .export_auth_store_snapshot_from_tables() + .await?; + let Some(snapshot_json) = snapshot + .snapshot_json + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return Ok(false); + }; + let result = self.refresh_auth_store_from_snapshot_json(snapshot_json)?; + info!( + user_count = result.user_count, + session_count = result.session_count, + updated_at_micros = snapshot.updated_at_micros, + "已按需刷新本进程认证工作集" + ); + Ok(true) + } + } + pub async fn try_restore_auth_store_from_spacetime( config: AppConfig, ) -> Result { diff --git a/server-rs/crates/module-auth/src/application.rs b/server-rs/crates/module-auth/src/application.rs index 132b9995..c5910211 100644 --- a/server-rs/crates/module-auth/src/application.rs +++ b/server-rs/crates/module-auth/src/application.rs @@ -111,6 +111,12 @@ pub struct LogoutCurrentSessionResult { pub user: AuthUser, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RefreshAuthStoreSnapshotResult { + pub user_count: usize, + pub session_count: usize, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct LogoutAllSessionsResult { pub user: AuthUser, diff --git a/server-rs/crates/module-auth/src/lib.rs b/server-rs/crates/module-auth/src/lib.rs index c5195388..b3f4db23 100644 --- a/server-rs/crates/module-auth/src/lib.rs +++ b/server-rs/crates/module-auth/src/lib.rs @@ -863,6 +863,12 @@ impl AuthUserService { input: LogoutCurrentSessionInput, now: OffsetDateTime, ) -> Result { + let user = self + .store + .find_by_user_id(&input.user_id) + .map_err(map_password_error_to_logout_error)? + .ok_or(LogoutError::UserNotFound)? + .user; let revoked_by_hash = if let Some(refresh_token_hash) = input .refresh_token_hash .as_ref() @@ -889,12 +895,6 @@ impl AuthUserService { .map_err(map_refresh_error_to_logout_error)?; } - let user = self - .store - .increment_user_token_version(&input.user_id) - .map_err(map_password_error_to_logout_error)? - .ok_or(LogoutError::UserNotFound)?; - Ok(LogoutCurrentSessionResult { user }) } @@ -989,6 +989,16 @@ impl InMemoryAuthStoreState { } } + fn apply_persistent_state(&mut self, next_state: Self) { + self.next_user_id = next_state.next_user_id; + self.users_by_username = next_state.users_by_username; + self.phone_to_user_id = next_state.phone_to_user_id; + self.sessions_by_id = next_state.sessions_by_id; + self.session_id_by_refresh_token_hash = next_state.session_id_by_refresh_token_hash; + self.wechat_identity_by_provider_uid = next_state.wechat_identity_by_provider_uid; + self.user_id_by_provider_union_id = next_state.user_id_by_provider_union_id; + } + fn to_persistent_snapshot(&self) -> PersistentAuthStoreSnapshot { PersistentAuthStoreSnapshot { next_user_id: self.next_user_id, @@ -1013,6 +1023,26 @@ impl InMemoryAuthStore { }) } + pub fn refresh_from_snapshot_json( + &self, + snapshot_json: &str, + ) -> Result { + let snapshot = serde_json::from_str::(snapshot_json) + .map_err(|error| format!("解析认证快照失败:{error}"))?; + let next_state = InMemoryAuthStoreState::from_persistent_snapshot(snapshot); + let result = RefreshAuthStoreSnapshotResult { + user_count: next_state.users_by_username.len(), + session_count: next_state.sessions_by_id.len(), + }; + let mut state = self + .inner + .lock() + .map_err(|_| "认证仓储锁已中毒".to_string())?; + state.apply_persistent_state(next_state); + + Ok(result) + } + pub fn export_snapshot_json(&self) -> Result { let state = self .inner @@ -2857,6 +2887,68 @@ mod tests { assert_eq!(rotated.user.id, user.id); } + #[tokio::test] + async fn refresh_from_snapshot_json_merges_session_created_by_another_process() { + let source_store = InMemoryAuthStore::default(); + let user = create_phone_login_user(source_store.clone(), "13800138033").await; + let source_refresh_service = build_refresh_service(source_store.clone()); + let source_session = source_refresh_service + .create_session( + CreateRefreshSessionInput { + user_id: user.id.clone(), + refresh_token_hash: hash_refresh_session_token("remote-process-token"), + issued_by_provider: AuthLoginMethod::Password, + client_info: build_client_info(), + }, + OffsetDateTime::now_utc(), + ) + .expect("source session should create"); + let snapshot_json = source_store + .export_snapshot_json() + .expect("source snapshot should export"); + + let local_store = InMemoryAuthStore::default(); + let local_phone_service = build_phone_service(local_store.clone()); + let local_now = OffsetDateTime::now_utc(); + local_phone_service + .send_code( + SendPhoneCodeInput { + phone_number: "13800138034".to_string(), + scene: PhoneAuthScene::Login, + }, + local_now, + ) + .await + .expect("local transient phone code should send"); + let refreshed = local_store + .refresh_from_snapshot_json(&snapshot_json) + .expect("local store should refresh"); + + assert_eq!(refreshed.user_count, 1); + assert_eq!(refreshed.session_count, 1); + assert!( + build_refresh_service(local_store) + .is_session_active_for_user( + &user.id, + &source_session.session.session_id, + OffsetDateTime::now_utc() + Duration::minutes(1) + ) + .expect("refreshed session active check should succeed") + ); + assert!(matches!( + local_phone_service + .send_code( + SendPhoneCodeInput { + phone_number: "13800138034".to_string(), + scene: PhoneAuthScene::Login, + }, + local_now + Duration::seconds(5), + ) + .await, + Err(PhoneAuthError::SendCoolingDown { .. }) + )); + } + #[tokio::test] async fn snapshot_json_drops_orphan_phone_index_before_phone_login() { let snapshot = PersistentAuthStoreSnapshot { @@ -3124,12 +3216,13 @@ mod tests { } #[tokio::test] - async fn logout_current_session_revokes_session_and_increments_token_version() { + async fn logout_current_session_revokes_only_current_session_without_token_version_bump() { let store = build_store(); let user = create_phone_login_user(store.clone(), "13800138005").await; let refresh_service = build_refresh_service(store.clone()); let user_service = build_user_service(store); let refresh_token_hash = hash_refresh_session_token("logout-token"); + let other_refresh_token_hash = hash_refresh_session_token("logout-token-other"); refresh_service .create_session( CreateRefreshSessionInput { @@ -3141,6 +3234,21 @@ mod tests { OffsetDateTime::now_utc(), ) .expect("session should create"); + let other_session = refresh_service + .create_session( + CreateRefreshSessionInput { + user_id: user.id.clone(), + refresh_token_hash: other_refresh_token_hash.clone(), + issued_by_provider: AuthLoginMethod::Password, + client_info: RefreshSessionClientInfo { + client_runtime: "firefox".to_string(), + device_display_name: "Windows / Firefox".to_string(), + ..build_client_info() + }, + }, + OffsetDateTime::now_utc() + Duration::seconds(1), + ) + .expect("other session should create"); let result = user_service .logout_current_session( @@ -3153,7 +3261,7 @@ mod tests { ) .expect("logout should succeed"); - assert_eq!(result.user.token_version, 2); + assert_eq!(result.user.token_version, user.token_version); let refresh_error = refresh_service .rotate_session( @@ -3165,6 +3273,25 @@ mod tests { ) .expect_err("revoked session should fail"); assert_eq!(refresh_error, RefreshSessionError::SessionNotFound); + assert!( + refresh_service + .is_session_active_for_user( + &user.id, + &other_session.session.session_id, + OffsetDateTime::now_utc() + Duration::minutes(2) + ) + .expect("other session active check should succeed") + ); + let rotated_other = refresh_service + .rotate_session( + RotateRefreshSessionInput { + refresh_token_hash: other_refresh_token_hash, + next_refresh_token_hash: hash_refresh_session_token("logout-token-other-next"), + }, + OffsetDateTime::now_utc() + Duration::minutes(2), + ) + .expect("other session should still rotate"); + assert_eq!(rotated_other.user.id, user.id); } #[tokio::test] @@ -3286,7 +3413,7 @@ mod tests { ) .expect("logout should succeed"); - assert_eq!(result.user.token_version, user.token_version + 1); + assert_eq!(result.user.token_version, user.token_version); assert!( !refresh_service .is_session_active_for_user(