修复 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:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user