统一受保护请求认证投影刷新
将 Bearer 路由刷新收口到鉴权中间件 补齐后台素材筛选跨节点用户解析并同步决策记录
This commit is contained in:
@@ -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 检查。
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user