From 5e90add1a043d789bfcfed9dcfc95a60448da71f Mon Sep 17 00:00:00 2001 From: Linghong Date: Thu, 3 Sep 2026 04:56:10 +0000 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8DAGC=E8=B5=84=E4=BA=A7?= =?UTF-8?q?=E8=AF=BB=E5=8F=96=E5=9F=8B=E7=82=B9=E7=94=A8=E6=88=B7=E5=BD=92?= =?UTF-8?q?=E5=B1=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 资产读取路由保留可选鉴权并传递已验证 Bearer 主体到响应扩展 补充资产读取主体传递与匿名边界测试 同步 Issue225 技术方案和项目决策记录 --- .../shared-memory/decision-log.md | 7 +- ...ssue225登录成功AGC用户归属修复方案-2026-09-03.md | 25 ++- server-rs/crates/api-server/src/assets.rs | 143 ++++++++++++++++-- 3 files changed, 156 insertions(+), 19 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index fcc000181..0da4920e4 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -20,9 +20,10 @@ - 背景:登录请求进入时尚未拥有 `AuthenticatedAccessToken`,通用 tracking middleware 无法从响应 extensions 归属登录成功用户;将 AGC marker 直接写入按用户/业务日幂等的 `daily_login` 又会受到不同来源登录顺序影响。 - 决策:密码登录和手机号登录 handler 在认证及 session 创建成功后,仅对合法 AGC marker 请求向响应 extensions 附加一次性 `TrackingLoginSubject`。tracking middleware 在现有 `ExternalApiPrincipal`、`AuthenticatedAccessToken` 之后使用该主体生成登录 route event 的 `user_id`、`owner_user_id` 和 User scope。`daily_login` 保持原有 event key、幂等键、业务日和 metadata 语义,不承担 AGC 来源归因。 - 安全边界:主体只来自后端认证服务返回的用户 ID;Header 仅决定是否进行 AGC 来源归因,不参与身份计算;不解析、不记录 access token、refresh token 或 Cookie。 -- 影响范围:`api-server` 登录 handler、tracking middleware、Issue225 技术方案和定向集成测试;不修改 SpacetimeDB schema、migration、bindings、OpenAPI、后台页面或认证响应协议。 -- 验证方式:密码/手机号登录真实 `build_router` 链路分别验证 AGC route event 的 marker、真实用户归属和 outbox 落盘;tracking identity 单测、`cargo check --locked -p api-server`、`cargo test --locked -p api-server agc_`、格式、编码和 diff 检查通过。 -- 关联文档:`docs/technical/【后端架构】Issue225登录成功AGC用户归属修复方案-2026-09-03.md`、Issue #225、`server-rs/crates/api-server/src/tracking.rs`、`server-rs/crates/api-server/src/app.rs`。 +- 影响范围:`api-server` 登录 handler、资产读取 handler、tracking middleware、Issue225 技术方案和定向集成测试;不修改 SpacetimeDB schema、migration、bindings、OpenAPI、后台页面或认证响应协议。 +- 资产读取边界:`/api/assets/read-url` 和 `/api/assets/read-bytes` 继续支持匿名公开读取;有效 Bearer 复用同一次可选鉴权结果,在成功响应 extensions 中传递 `AuthenticatedAccessToken`,供 AGC route tracking 归属用户。External API Key/Admin 路由继续使用各自主体和审计链路。 +- 验证方式:密码/手机号登录真实 `build_router` 链路分别验证 AGC route event 的 marker、真实用户归属和 outbox 落盘;资产读取主体保留、响应 extension 与匿名不附加单测;tracking identity 单测、`cargo check --locked -p api-server`、相关 `cargo test --locked -p api-server`、格式、编码和 diff 检查通过。 +- 关联文档:`docs/technical/【后端架构】Issue225登录成功AGC用户归属修复方案-2026-09-03.md`、Issue #225、`server-rs/crates/api-server/src/tracking.rs`、`server-rs/crates/api-server/src/app.rs`、`server-rs/crates/api-server/src/assets.rs`。 ## 2026-09-02 GDD 审批卡的后台 hydrate 不抢占已加载决定 diff --git a/docs/technical/【后端架构】Issue225登录成功AGC用户归属修复方案-2026-09-03.md b/docs/technical/【后端架构】Issue225登录成功AGC用户归属修复方案-2026-09-03.md index 39188559c..2b40d2606 100644 --- a/docs/technical/【后端架构】Issue225登录成功AGC用户归属修复方案-2026-09-03.md +++ b/docs/technical/【后端架构】Issue225登录成功AGC用户归属修复方案-2026-09-03.md @@ -166,6 +166,21 @@ ExternalApiPrincipal 本方案不把手机号登录改造成 AGC-only,也不扩大 #225 的 route coverage。 +### 6.4 可选鉴权的资产读取路由 + +`/api/assets/read-url` 和 `/api/assets/read-bytes` 同时支持匿名公开素材读取与已登录用户读取, +因此不能直接挂接 `require_bearer_auth`。这两个 handler 会继续使用 +`optional_access_token_from_headers` 完成 Bearer 校验和素材权限判断;如果校验成功, +则把同一个已验证的 `AuthenticatedAccessToken` 放入成功响应 extensions,供外层 tracking +middleware 读取真实用户主体。 + +该传递只发生在当前响应内: + +- 匿名公开读取不附加认证主体,仍按 `anonymous` 记录; +- 无效 Bearer 仍返回 `401`,不产生成功 route event; +- External API Key 和管理员资产读取继续使用各自的 `ExternalApiPrincipal` / 管理员审计链路; +- 不把 Authorization Header、access token、refresh token 或 Cookie 写入埋点。 + ## 7. 事件结果示例 ### 7.1 AGC 密码登录成功 @@ -244,6 +259,13 @@ HTTP request - `user_id`、`owner_user_id`、`scope_id` 为真实用户; - outbox metadata 不包含 access token、refresh token、Cookie、密码或验证码。 +资产读取的成功链路还必须满足: + +- AGC + 有效 Bearer 的 `/api/assets/read-url` 和 `/api/assets/read-bytes`,route event 的 + `user_id`、`owner_user_id`、`scope_id` 均为 Bearer 对应的真实用户; +- AGC + 无 Bearer 的公开素材读取仍保持匿名归属; +- 资产读取权限判断和 tracking 主体传递复用同一次 Bearer 校验结果,不重复解析或自行构造身份。 + ### 9.2 未标记回归 - 未标记手机号登录继续保持既有 route tracking 语义; @@ -299,7 +321,8 @@ CI 已覆盖的全量测试不要求本地重复运行。 - `server-rs/crates/api-server/src/tracking.rs` 增加 `TrackingLoginSubject`,并将其作为登录主体的最后兜底来源;既有 `ExternalApiPrincipal`、`AuthenticatedAccessToken` 优先级保持不变。 - `server-rs/crates/api-server/src/password_entry.rs` 和 `server-rs/crates/api-server/src/phone_auth.rs` 在认证与 session 创建成功后,仅对合法 AGC marker 请求向 response extensions 附加真实用户主体。 - `server-rs/crates/api-server/src/app.rs` 只在合法 AGC marker 存在时读取该主体并交给 route tracking;未标记请求不使用该新主体来源。 +- `server-rs/crates/api-server/src/assets.rs` 保留两个资产读取路由的可选鉴权语义,并在有效 Bearer 成功读取时把已验证主体放入响应 extensions,补齐 AGC 资产读取事件的用户归属;匿名读取和 External API Key/Admin 路由边界未改变。 - `daily_login` helper、event id 幂等、认证响应和 Cookie 语义未修改。 -- 已增加密码登录、手机号登录和主体解析测试;两条真实 `build_router → 登录 handler → response extension → tracking middleware → outbox` 链路均验证通过。 +- 已增加密码登录、手机号登录、资产读取主体传递和主体解析测试;登录链路与资产读取主体传递均验证通过。 本次实现没有修改 SpacetimeDB schema、migration、bindings、OpenAPI 或后台页面,也没有新增事件 key。 diff --git a/server-rs/crates/api-server/src/assets.rs b/server-rs/crates/api-server/src/assets.rs index f7d9de5cb..93449d2bd 100644 --- a/server-rs/crates/api-server/src/assets.rs +++ b/server-rs/crates/api-server/src/assets.rs @@ -3,7 +3,7 @@ use axum::{ body::Body, extract::{Extension, Query, State}, http::{HeaderMap, StatusCode, header}, - response::Response, + response::{IntoResponse, Response}, }; use module_assets::{ AssetObjectAccessPolicy, AssetObjectFieldError, INITIAL_ASSET_OBJECT_VERSION, @@ -71,6 +71,12 @@ struct AssetReadTarget { is_legacy_public_path: bool, } +#[derive(Debug)] +struct AssetReadAuthorizationContext { + authorization: AssetReadAuthorization, + authenticated: Option, +} + pub async fn create_direct_upload_ticket( State(state): State, Extension(request_context): Extension, @@ -180,15 +186,27 @@ pub async fn get_asset_read_url( Extension(request_context): Extension, headers: HeaderMap, Query(query): Query, -) -> Result, AppError> { - let authorization = resolve_public_asset_read_authorization( +) -> Result { + let authorization_context = resolve_public_asset_read_authorization( &state, &request_context, headers, "/api/assets/read-url", ) .await?; - get_asset_read_url_with_query(&state, &request_context, query, authorization).await + let response = get_asset_read_url_with_query( + &state, + &request_context, + query, + authorization_context.authorization, + ) + .await? + .into_response(); + + Ok(attach_asset_read_authentication( + response, + authorization_context.authenticated, + )) } pub(crate) async fn get_asset_read_url_with_query( @@ -313,16 +331,20 @@ pub async fn get_asset_read_bytes( })?; let target = resolve_asset_read_target(&query)?; - let authorization = resolve_public_asset_read_authorization( + let authorization_context = resolve_public_asset_read_authorization( &state, &request_context, headers, "/api/assets/read-bytes", ) .await?; - let access_scope = - authorize_asset_read_target(&state, oss_client.config_bucket(), &target, &authorization) - .await?; + let access_scope = authorize_asset_read_target( + &state, + oss_client.config_bucket(), + &target, + &authorization_context.authorization, + ) + .await?; let signed = oss_client .sign_get_object_url(OssSignedGetObjectUrlRequest { @@ -383,7 +405,7 @@ pub async fn get_asset_read_bytes( return Err(map_asset_read_bytes_too_large()); } - Response::builder() + let response = Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, content_type) .header(header::CACHE_CONTROL, "private, max-age=60") @@ -393,7 +415,12 @@ pub async fn get_asset_read_bytes( "provider": "asset-read-bytes", "message": format!("构造资源内容响应失败:{error}"), })) - }) + })?; + + Ok(attach_asset_read_authentication( + response, + authorization_context.authenticated, + )) } pub async fn get_asset_history( @@ -741,7 +768,7 @@ async fn resolve_public_asset_read_authorization( request_context: &RequestContext, headers: HeaderMap, path: &str, -) -> Result { +) -> Result { let authenticated = optional_access_token_from_headers( state, path.to_string(), @@ -749,11 +776,27 @@ async fn resolve_public_asset_read_authorization( request_context.request_id().to_string(), ) .await?; - Ok(authenticated + let authorization = authenticated + .as_ref() .map(|authenticated| { AssetReadAuthorization::Owner(authenticated.claims().user_id().to_string()) }) - .unwrap_or(AssetReadAuthorization::Anonymous)) + .unwrap_or(AssetReadAuthorization::Anonymous); + + Ok(AssetReadAuthorizationContext { + authorization, + authenticated, + }) +} + +fn attach_asset_read_authentication( + mut response: Response, + authenticated: Option, +) -> Response { + if let Some(authenticated) = authenticated { + response.extensions_mut().insert(authenticated); + } + response } async fn authorize_asset_read_target( @@ -1097,12 +1140,14 @@ mod tests { use axum::{ body::Body, - http::{Request, StatusCode}, + http::{HeaderMap, HeaderValue, Request, StatusCode, header::AUTHORIZATION}, + response::IntoResponse, }; use hmac::{Hmac, Mac}; use http_body_util::BodyExt; use platform_auth::{ AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token, + verify_access_token, }; use reqwest::{Method, multipart}; use serde_json::{Value, json}; @@ -1112,7 +1157,10 @@ mod tests { use tower::ServiceExt; use crate::tracking::TrackingClientMarker; - use crate::{app::build_router, config::AppConfig, state::AppState}; + use crate::{ + app::build_router, auth::AuthenticatedAccessToken, config::AppConfig, + request_context::RequestContext, state::AppState, + }; type HmacSha256 = Hmac; @@ -1174,6 +1222,71 @@ mod tests { assert_eq!(draft.metadata["client"], "agc"); } + #[tokio::test] + async fn public_asset_read_response_carries_verified_bearer_for_tracking() { + let state = AppState::new(AppConfig::default()).expect("state should build"); + let token = + seed_authenticated_token(&state, "13800138126", "sess_asset_read_tracking").await; + let request_context = RequestContext::new( + "request-225-asset-read".to_string(), + "GET /api/assets/read-url".to_string(), + std::time::Duration::ZERO, + false, + ); + let mut headers = HeaderMap::new(); + headers.insert( + AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {token}")) + .expect("authorization header should build"), + ); + + let authorization_context = super::resolve_public_asset_read_authorization( + &state, + &request_context, + headers, + "/api/assets/read-url", + ) + .await + .expect("valid bearer should authenticate"); + let expected_user_id = verify_access_token(&token, state.auth_jwt_config()) + .expect("test bearer should verify") + .user_id() + .to_string(); + assert!(matches!( + authorization_context.authorization, + super::AssetReadAuthorization::Owner(ref user_id) if user_id == &expected_user_id + )); + + let authenticated = authorization_context + .authenticated + .as_ref() + .expect("verified bearer should be retained for tracking"); + assert_eq!(authenticated.claims().user_id(), expected_user_id); + + let response = super::attach_asset_read_authentication( + StatusCode::OK.into_response(), + authorization_context.authenticated, + ); + let response_authenticated = response + .extensions() + .get::() + .expect("asset read response should expose verified bearer to tracking"); + assert_eq!(response_authenticated.claims().user_id(), expected_user_id); + } + + #[test] + fn anonymous_asset_read_response_does_not_add_authentication_extension() { + let response = + super::attach_asset_read_authentication(StatusCode::OK.into_response(), None); + + assert!( + response + .extensions() + .get::() + .is_none() + ); + } + fn asset_object_record( access_policy: module_assets::AssetObjectAccessPolicy, owner_user_id: Option<&str>,