diff --git a/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs b/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs index 904d56f8d..2b0e3e940 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs @@ -570,12 +570,33 @@ async fn request_auth( refresh_cookie: captured, }) } +/// 一次会话写入的身份来源。 +/// +/// 登录路径的主体只能来自本次登录响应的用户投影,续期路径只能来自已持久化的凭据文件; +/// 把来源编码进类型后,「漏传主体」不再是能写出来的状态:`resolve` 是唯一入口。 +enum SessionIdentity { + /// 登录:主体与展示字段都取自响应里的用户投影。 + Login(AuthUserPayload), + /// 续期:只有已持久化的主体,展示字段随后用新凭据复核。 + Persisted(String), +} + +impl SessionIdentity { + /// 拆出会话主体与展示用的用户投影:空主体在这里失败关闭,既不能写凭据文件,也不能 + /// 装进本进程会话。 + fn resolve(self) -> Result<(String, Option), String> { + match self { + Self::Login(user) => Ok((validated_session_user_id(&user.id)?, Some(user))), + Self::Persisted(user_id) => Ok((validated_session_user_id(&user_id)?, None)), + } + } +} + /// 持久化新凭据并安装本进程会话。 async fn commit_authenticated_session( app: &tauri::AppHandle, origin: &str, - known_user: Option, - user_id: String, + identity: SessionIdentity, token: String, refresh_cookie: (String, String), identity_change: bool, @@ -583,6 +604,8 @@ async fn commit_authenticated_session( if token.chars().count() > MAX_SECRET_CHARS { return Err("登录服务返回的凭据无效".to_string()); } + // 主体先于凭据落盘解析:没有主体就不写 client-session.json,避免留下半截会话文件。 + let (user_id, known_user) = identity.resolve()?; let session = ClientSessionFile { schema_version: session_schema_version(), api_base_url: origin.to_string(), @@ -662,6 +685,18 @@ fn phone_is_valid(phone: &str) -> bool { && phone.chars().all(|character| character.is_ascii_digit()) } +/// 归一化会话主体:凭据文件与进程快照都靠它判定「是谁」。 +/// +/// 主体缺失时不能退化成空串,否则本机凭据文件不完整、安装会话还会以「陶泥儿登录用户 +/// 身份无效」失败关闭;这里先给出登录语义的明确原因。 +fn validated_session_user_id(user_id: &str) -> Result { + let user_id = user_id.trim(); + if user_id.is_empty() { + return Err("登录失败:登录服务未返回用户身份".to_string()); + } + Ok(user_id.to_string()) +} + /// 等锁期间别的调用者已经轮换过凭据:复用最新会话,不再轮换第二次。 fn credential_rotated_elsewhere( before: &ClientSessionFile, @@ -775,8 +810,7 @@ async fn refresh_session_inner( commit_authenticated_session( app, &session.api_base_url, - None, - session.user_id.clone(), + SessionIdentity::Persisted(session.user_id.clone()), install_token, new_cookie, false, @@ -959,8 +993,8 @@ async fn complete_login( let user = commit_authenticated_session( app, origin, - Some(payload.user), - String::new(), + // 会话主体只能来自本次登录响应:空主体既不能写进凭据文件,也不能装进本进程会话。 + SessionIdentity::Login(payload.user), payload.token, cookie, true, @@ -1348,4 +1382,84 @@ mod tests { None ); } + + #[test] + fn session_subject_is_required_and_normalized() { + assert_eq!( + validated_session_user_id("user-1").expect("user id"), + "user-1" + ); + // 服务端主体前后的空白不能带进凭据文件或进程快照。 + assert_eq!( + validated_session_user_id(" user-1 ").expect("user id"), + "user-1" + ); + // 空主体必须在登录路径就以登录语义失败:安装会话时的「陶泥儿登录用户身份无效」 + // 是内部不变式,不是用户能理解的登录失败原因。 + assert_eq!( + validated_session_user_id(" ").unwrap_err(), + "登录失败:登录服务未返回用户身份" + ); + } + + #[test] + fn login_response_supplies_the_session_subject() { + // 回归:登录响应解析出的 user.id 就是本机会话主体,安装会话时不能丢掉它。 + let payload: TokenUserResponse = serde_json::from_value(serde_json::json!({ + "token": "token-a", + "user": { + "id": "user-2", + "publicUserCode": "tn-2", + "displayName": "测试", + "avatarUrl": null, + "phoneNumber": null, + "phoneNumberMasked": null, + "loginMethod": "password", + "bindingStatus": "active", + "wechatBound": false, + "wechatDisplayName": null, + "wechatAccount": null + } + })) + .expect("login response fixture"); + let (user_id, known_user) = SessionIdentity::Login(payload.user) + .resolve() + .expect("login identity"); + assert_eq!(user_id, "user-2"); + // 登录路径同时带回展示投影;续期路径只有主体,展示字段随后用新凭据复核。 + assert_eq!(known_user.map(|user| user.id).as_deref(), Some("user-2")); + let (user_id, known_user) = SessionIdentity::Persisted(" user-3 ".to_string()) + .resolve() + .expect("refresh identity"); + assert_eq!(user_id, "user-3"); + assert!(known_user.is_none()); + } + + #[test] + fn session_identity_rejects_a_missing_subject() { + let payload: TokenUserResponse = serde_json::from_value(serde_json::json!({ + "token": "token-a", + "user": { + "id": " ", + "publicUserCode": "tn-2", + "displayName": "测试", + "avatarUrl": null, + "phoneNumber": null, + "phoneNumberMasked": null, + "loginMethod": "password", + "bindingStatus": "active", + "wechatBound": false, + "wechatDisplayName": null, + "wechatAccount": null + } + })) + .expect("login response fixture"); + // 没有主体的登录响应在写凭据文件之前就以登录语义失败关闭。 + assert_eq!( + SessionIdentity::Login(payload.user).resolve().unwrap_err(), + "登录失败:登录服务未返回用户身份" + ); + // 续期路径的空主体同样失败关闭:缺字段的凭据文件本来就读不出来,不能在这里被复活。 + assert!(SessionIdentity::Persisted(String::new()).resolve().is_err()); + } }