修复 AGC 登录丢失会话主体导致的身份无效

- auth_session.rs 新增 SessionIdentity:登录主体取自响应用户投影、续期主体取自已持久化凭据文件,漏传主体不再是能写出来的状态
- complete_login 改用 SessionIdentity::Login(payload.user),删掉原先写死的空 user_id 字符串
- commit_authenticated_session 在写 client-session.json 之前解析并校验主体,空主体失败关闭且不再留下半截会话文件
- 新增 validated_session_user_id:主体 trim 归一化,空值报「登录失败:登录服务未返回用户身份」
- 补 auth_session 定向用例三条:登录响应供主体、续期只带主体、空主体失败关闭
This commit is contained in:
kdletters
2026-09-29 12:14:59 +08:00
parent c2a3cafe77
commit 75ca16739a
@@ -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<AuthUserPayload>), 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<AuthUserPayload>,
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<String, String> {
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());
}
}