diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index d502bc2af..dad81b194 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -26,7 +26,7 @@ ## 2026-08-27 短期认证状态进入共享 typed projection - 背景:短信验证码和微信 OAuth state 仍只存在 API 进程内 HashMap,多节点请求或 API 重启会直接丢失,无法满足无粘性会话的鉴权恢复要求。 -- 决策:`AuthStoreProjectionView` 增加 `phone_codes` 与 `wechat_states` typed 字段,由 `auth_store_projection_meta` 以 JSON 投影持久化;启动恢复、CAS 同步和失败后的权威刷新都覆盖这两类短期状态。验证码哈希使用部署级稳定盐(当前复用 `GENARRATIVE_JWT_SECRET`),各 API 节点必须一致;发码前先刷新权威投影并用占位验证码记录做一次 projection CAS,只有占用成功才调用短信 provider,避免跨节点冷却竞态;认证 handler 在发码、消费验证码、创建/消费微信 state 后都要完成 projection sync,失败即返回服务错误;所有会读取或变更本机认证工作集的认证主链路(登录、刷新、`/me`、会话管理、密码、绑定和微信 state)在领域操作前先从正式投影做一次受 CAS 保护的只读刷新,刷新失败时 fail closed,不能依赖粘性会话;同步遇到 CAS 冲突时,若本次尝试期间没有新的本地变更则恢复正式快照,若仍有待同步 revision 则由后续认证请求重试,避免节点永久卡在 pending。微信 OAuth state 设置有界活动数量,避免单个 JSON 投影无界膨胀。短期状态仍由 `module-auth` 内存工作集执行领域校验,但不再把本机 HashMap 当作持久化或跨节点真相。 +- 决策:`AuthStoreProjectionView` 增加 `phone_codes` 与 `wechat_states` typed 字段,由 `auth_store_projection_meta` 以 JSON 投影持久化;启动恢复、CAS 同步和失败后的权威刷新都覆盖这两类短期状态。验证码哈希使用部署级稳定盐(当前复用 `GENARRATIVE_JWT_SECRET`),各 API 节点必须一致;发码前先刷新权威投影并用占位验证码记录做一次 projection CAS,只有占用成功才调用短信 provider,避免跨节点冷却竞态;认证 handler 在发码、消费验证码、创建/消费微信 state 后都要完成 projection sync,失败即返回服务错误;所有会读取或变更本机认证工作集的认证主链路(登录、刷新、`/me`、会话管理、密码、绑定和微信 state)在领域操作前先从正式投影做一次受 CAS 保护的只读刷新,受保护 Bearer 中间件也会在进入业务 handler 前执行同样的刷新,刷新失败时 fail closed,不能依赖粘性会话;同步遇到 CAS 冲突时,若本次尝试期间没有新的本地变更则恢复正式快照,若仍有待同步 revision 则由后续认证请求重试,避免节点永久卡在 pending。微信 OAuth state 设置有界活动数量,避免单个 JSON 投影无界膨胀。短期状态仍由 `module-auth` 内存工作集执行领域校验,但不再把本机 HashMap 当作持久化或跨节点真相。 - 影响范围:`module-auth` projection、`spacetime-module` auth schema/procedure、`spacetime-client` bindings/facade、api-server 手机号 / 微信 handler、认证架构与运维文档。 - 验证方式:运行 module-auth projection roundtrip(验证码可跨恢复校验、微信 state 可跨恢复消费)、SpacetimeDB schema/runtime/DDD 门禁、api-server 定向测试、编码和 diff 检查。 diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index 49751fd92..5afb9ccf2 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -657,6 +657,10 @@ pub async fn admin_list_editor_assets( Extension(_admin): Extension, Query(query): Query, ) -> Result, AppError> { + state + .refresh_auth_store_from_spacetime() + .await + .map_err(map_admin_spacetime_error)?; let page_size = query .limit .unwrap_or(ADMIN_EDITOR_ASSET_DEFAULT_LIMIT) diff --git a/server-rs/crates/api-server/src/auth.rs b/server-rs/crates/api-server/src/auth.rs index e1c9639d0..7ccaf0e5f 100644 --- a/server-rs/crates/api-server/src/auth.rs +++ b/server-rs/crates/api-server/src/auth.rs @@ -146,6 +146,16 @@ pub async fn require_bearer_auth( let Some(authenticated) = authenticate_request(&state, headers, request_id).await? else { return Err(AppError::from_status(StatusCode::UNAUTHORIZED)); }; + // JWT 会话校验走 SpacetimeDB;随后刷新用户/身份投影,保证所有受保护路由在 + // 读取进程内工作集时都不会依赖粘性会话命中创建或更新它的 API 节点。 + state + .refresh_auth_store_from_spacetime() + .await + .map_err(|error| { + warn!(error = %error, "受保护请求刷新认证投影失败"); + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) + .with_message("认证状态服务暂不可用") + })?; request.extensions_mut().insert(authenticated.clone()); let mut response = next.run(request).await; diff --git a/server-rs/crates/api-server/src/auth_me.rs b/server-rs/crates/api-server/src/auth_me.rs index 74321a901..bab8c4349 100644 --- a/server-rs/crates/api-server/src/auth_me.rs +++ b/server-rs/crates/api-server/src/auth_me.rs @@ -17,15 +17,6 @@ pub async fn auth_me( Extension(authenticated): Extension, ) -> Result, AppError> { let user_id = authenticated.claims().user_id().to_string(); - // Bearer 已由 SpacetimeDB 校验;/me 返回的资料也必须先从同一正式投影恢复, - // 否则节点间会因为本机工作集滞后把有效用户返回为 401 或旧资料。 - state - .refresh_auth_store_from_spacetime() - .await - .map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) - .with_message(format!("刷新认证状态失败:{error}")) - })?; let user = state .password_entry_service() .get_user_by_id(&user_id) diff --git a/server-rs/crates/api-server/src/auth_sessions.rs b/server-rs/crates/api-server/src/auth_sessions.rs index 0ef62c01a..c9ba471d3 100644 --- a/server-rs/crates/api-server/src/auth_sessions.rs +++ b/server-rs/crates/api-server/src/auth_sessions.rs @@ -29,13 +29,6 @@ pub async fn auth_sessions( ) -> Result, AppError> { // 当前设备识别仍然依赖 refresh cookie 命中的原始 token,对旧前端行为保持兼容。 let user_id = authenticated.claims().user_id().to_string(); - state - .refresh_auth_store_from_spacetime() - .await - .map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) - .with_message(format!("刷新认证状态失败:{error}")) - })?; let current_refresh_token_hash = maybe_refresh_token.and_then(|token| { let token = token.0.token().trim(); if token.is_empty() { @@ -85,14 +78,6 @@ pub async fn revoke_auth_session( ); } - state - .refresh_auth_store_from_spacetime() - .await - .map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) - .with_message(format!("刷新认证状态失败:{error}")) - })?; - let revoke_result = state .refresh_session_service() .revoke_session_by_user_and_session( diff --git a/server-rs/crates/api-server/src/logout.rs b/server-rs/crates/api-server/src/logout.rs index 101b533e9..4de4f4efb 100644 --- a/server-rs/crates/api-server/src/logout.rs +++ b/server-rs/crates/api-server/src/logout.rs @@ -25,13 +25,6 @@ pub async fn logout( Extension(authenticated): Extension, maybe_refresh_token: Option>, ) -> Result { - state - .refresh_auth_store_from_spacetime() - .await - .map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) - .with_message(format!("刷新认证状态失败:{error}")) - })?; let refresh_token_hash = maybe_refresh_token.and_then(|token| { let token = token.0.token().trim().to_string(); if token.is_empty() { diff --git a/server-rs/crates/api-server/src/logout_all.rs b/server-rs/crates/api-server/src/logout_all.rs index 5bc545e1e..e56e1b775 100644 --- a/server-rs/crates/api-server/src/logout_all.rs +++ b/server-rs/crates/api-server/src/logout_all.rs @@ -23,13 +23,6 @@ pub async fn logout_all( Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result { - state - .refresh_auth_store_from_spacetime() - .await - .map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) - .with_message(format!("刷新认证状态失败:{error}")) - })?; state .auth_user_service() .logout_all_sessions( diff --git a/server-rs/crates/api-server/src/password_management.rs b/server-rs/crates/api-server/src/password_management.rs index 61b92fe98..068e65c7b 100644 --- a/server-rs/crates/api-server/src/password_management.rs +++ b/server-rs/crates/api-server/src/password_management.rs @@ -33,13 +33,6 @@ pub async fn change_password( Extension(authenticated): Extension, Json(payload): Json, ) -> Result { - state - .refresh_auth_store_from_spacetime() - .await - .map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) - .with_message(format!("刷新认证状态失败:{error}")) - })?; let result = state .password_entry_service() .change_password_and_revoke_all_sessions( diff --git a/server-rs/crates/api-server/src/profile_identity.rs b/server-rs/crates/api-server/src/profile_identity.rs index dac4f110c..fd91b8eb6 100644 --- a/server-rs/crates/api-server/src/profile_identity.rs +++ b/server-rs/crates/api-server/src/profile_identity.rs @@ -23,13 +23,6 @@ pub async fn update_profile_identity( Extension(authenticated): Extension, Json(payload): Json, ) -> Result, AppError> { - state - .refresh_auth_store_from_spacetime() - .await - .map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) - .with_message(format!("刷新认证状态失败:{error}")) - })?; if let Some(avatar_data_url) = payload.avatar_data_url.as_deref() { validate_avatar_data_url(avatar_data_url)?; } diff --git a/server-rs/crates/api-server/src/wechat/auth.rs b/server-rs/crates/api-server/src/wechat/auth.rs index 092c05e59..5f553f51f 100644 --- a/server-rs/crates/api-server/src/wechat/auth.rs +++ b/server-rs/crates/api-server/src/wechat/auth.rs @@ -102,13 +102,6 @@ pub async fn start_wechat_bind( if !state.config.wechat_auth_enabled { return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("微信登录暂未启用")); } - state - .refresh_auth_store_from_spacetime() - .await - .map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) - .with_message(format!("刷新微信认证状态失败:{error}")) - })?; let user_agent = headers .get("user-agent") .and_then(|value| value.to_str().ok()) @@ -348,13 +341,6 @@ pub async fn bind_wechat_phone( if !state.config.wechat_auth_enabled { return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("微信登录暂未启用")); } - state - .refresh_auth_store_from_spacetime() - .await - .map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) - .with_message(format!("刷新微信认证状态失败:{error}")) - })?; let result = if let Some(wechat_phone_code) = payload .wechat_phone_code .as_deref()