统一受保护请求认证投影刷新
Project CI / Frontend tests (pull_request) Successful in 3m36s
Project CI / Repository checks (pull_request) Successful in 3m43s
Project CI / Backend tests (pull_request) Successful in 8m20s
Project CI / Native shell tests (pull_request) Successful in 14m34s

将 Bearer 路由刷新收口到鉴权中间件

补齐后台素材筛选跨节点用户解析并同步决策记录
This commit is contained in:
2026-08-27 21:09:05 +08:00
parent 347eca2056
commit 061f8f32d3
10 changed files with 15 additions and 67 deletions
@@ -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 检查。
+4
View File
@@ -657,6 +657,10 @@ pub async fn admin_list_editor_assets(
Extension(_admin): Extension<AuthenticatedAdmin>,
Query(query): Query<AdminEditorAssetListQuery>,
) -> Result<Json<Value>, 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)
+10
View File
@@ -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;
@@ -17,15 +17,6 @@ pub async fn auth_me(
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<serde_json::Value>, 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)
@@ -29,13 +29,6 @@ pub async fn auth_sessions(
) -> Result<Json<serde_json::Value>, 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(
@@ -25,13 +25,6 @@ pub async fn logout(
Extension(authenticated): Extension<AuthenticatedAccessToken>,
maybe_refresh_token: Option<Extension<RefreshSessionToken>>,
) -> Result<impl IntoResponse, AppError> {
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() {
@@ -23,13 +23,6 @@ pub async fn logout_all(
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<impl IntoResponse, AppError> {
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(
@@ -33,13 +33,6 @@ pub async fn change_password(
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<PasswordChangeRequest>,
) -> Result<impl IntoResponse, AppError> {
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(
@@ -23,13 +23,6 @@ pub async fn update_profile_identity(
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<ProfileUpdateRequest>,
) -> Result<Json<serde_json::Value>, 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)?;
}
@@ -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()