//! AGC 认证会话:凭据持有、登录/续期/登出 HTTP 与本地运行时会话安装。 //! //! 渲染层只表达「用哪个手机号/密码/验证码登录」「哪个 origin」这类用户意图;access token //! 只在 Rust 内存与本进程会话快照里,refresh 凭据只写在 AppData 私有文件里。换号、登出或 //! origin 变化都会让旧身份的在途请求失败关闭;同一身份的凭据轮换不改变身份代次。 use crate::http_client::agc_main_site_client_builder; use crate::platform_session::{current_platform_session, PlatformSessionSnapshot}; use reqwest::{header::SET_COOKIE, Method, StatusCode}; use serde::{Deserialize, Serialize}; use serde_json::Value; use shared_contracts::auth::AuthUserPayload; use std::fs::{self, OpenOptions}; use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::Mutex; use std::time::Duration; use tauri::{Emitter, Manager}; use url::Url; pub(crate) const CLIENT_AUTH_STATE_CHANGED_EVENT: &str = "agc-client-auth-state-changed"; const SESSION_FILE_NAME: &str = "client-session.json"; const SESSION_SCHEMA_VERSION: &str = "agc-client-session.v1"; const RELEASE_ORIGIN: &str = "https://www.genarrative.world"; const DEVELOPMENT_ORIGIN: &str = "https://dev.genarrative.world"; const HTTP_TIMEOUT: Duration = Duration::from_secs(30); const MAX_ORIGIN_CHARS: usize = 2_048; const MAX_PHONE_CHARS: usize = 32; const MAX_SECRET_CHARS: usize = 8_192; const API_RESPONSE_ENVELOPE_HEADER: &str = "x-genarrative-response-envelope"; const API_RESPONSE_ENVELOPE_VERSION: &str = "v1"; const AGC_CLIENT_MARKER_HEADER: &str = "x-genarrative-client"; const AGC_CLIENT_MARKER_VALUE: &str = "agc"; const AUTH_NETWORK_ERROR: &str = "network-error: 无法连接登录服务,请确认配套后端或 API 代理已启动后重试"; const AUTH_NETWORK_TIMEOUT: &str = "network-error: 登录服务响应超时,请检查服务器地址和网络后重试"; const AUTH_AUTHORITY_ERROR: &str = "authentication-required: 登录状态已失效,请重新登录"; /// 认证态投影:只含状态、用户展示字段与 origin,不含 token 或 refresh 凭据。 #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct ClientAuthStateView { /// `authenticated` / `unauthenticated` / `unavailable`。 pub(crate) status: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) user: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) api_base_url: Option, /// 失败分类:`network` / `authority` / `contract`;成功或未登录时为 `None`。 #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) error_kind: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) error_message: Option, } #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct ClientLoginCodeView { pub(crate) cooldown_seconds: u64, pub(crate) expires_in_seconds: u64, } /// 续期结果。`stale` 表示续期期间身份已经变化,调用方不得重放旧身份请求。 #[derive(Clone, Debug, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct ClientAuthRefreshView { /// `refreshed` / `unauthenticated` / `stale` / `failed`。 pub(crate) status: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) user: Option, #[serde(default)] pub(crate) authoritative: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) error_message: Option, } #[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct ClientSessionFile { #[serde(default = "session_schema_version")] schema_version: String, #[serde(default)] api_base_url: String, #[serde(default)] user_id: String, #[serde(default)] refresh_cookie_name: String, #[serde(default)] refresh_cookie_value: String, } impl ClientSessionFile { fn is_complete(&self) -> bool { !self.api_base_url.is_empty() && !self.user_id.is_empty() && !self.refresh_cookie_name.is_empty() && !self.refresh_cookie_value.is_empty() } } fn session_schema_version() -> String { SESSION_SCHEMA_VERSION.to_string() } /// 进程内的会话代次与最近一次登录身份。 /// /// access token 不在这里:它只在 `platform_session` 快照中,避免出现两份凭据副本。 #[derive(Default)] struct AuthState { session: Option, identity_generation: u64, revision: u64, counters_seeded: bool, /// 当前 access token 的签发时间(本进程视角),用于按到期时间自助续期。 access_token_issued_at: Option, } static AUTH_STATE: std::sync::OnceLock> = std::sync::OnceLock::new(); static REFRESH_LOCK: std::sync::OnceLock> = std::sync::OnceLock::new(); static AUTH_APP_HANDLE: std::sync::OnceLock = std::sync::OnceLock::new(); /// 会话保活:Direct 回合可能横跨图片生成、构建和浏览器验证,出站调用前必须保证 access /// token 仍然新鲜。保持时间比旧的「渲染层每 5 分钟刷新一次」更短,避免边界抖动。 const CLIENT_SESSION_REFRESH_AFTER_MILLIS: u64 = 4 * 60 * 1_000; const CLIENT_SESSION_KEEPALIVE_TICK: Duration = Duration::from_secs(30); /// 保存 AppHandle,供 Rust 自己发起的会话保活使用(渲染层不再参与保活调度)。 pub(crate) fn initialize_auth_session(app: &tauri::AppHandle) { let _ = AUTH_APP_HANDLE.set(app.clone()); } fn unix_millis_now() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64) .unwrap_or_default() } /// 是否需要为当前会话续期:不知道签发时间时按「需要」处理。 fn session_needs_refresh(access_token_issued_at: Option, now_millis: u64) -> bool { match access_token_issued_at { None => true, Some(issued_at) => { now_millis.saturating_sub(issued_at) >= CLIENT_SESSION_REFRESH_AFTER_MILLIS } } } fn client_session_needs_refresh() -> bool { let issued_at = auth_state() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) .access_token_issued_at; // 没有本进程会话(未登录 / 已登出)时不触发续期。 current_platform_session().is_some() && session_needs_refresh(issued_at, unix_millis_now()) } /// Direct 回合期间由 Rust 持有的会话保活任务;drop 即停止。 #[derive(Debug)] pub(crate) struct ClientSessionKeepalive { task: tauri::async_runtime::JoinHandle<()>, } impl Drop for ClientSessionKeepalive { fn drop(&mut self) { self.task.abort(); } } /// 启动 Rust 侧会话保活;没有 AppHandle(未初始化)时返回 `None`。 pub(crate) fn spawn_client_session_keepalive() -> Option { let app = AUTH_APP_HANDLE.get()?.clone(); let task = tauri::async_runtime::spawn(async move { let mut ticker = tokio::time::interval(CLIENT_SESSION_KEEPALIVE_TICK); loop { ticker.tick().await; if !client_session_needs_refresh() { continue; } let _ = refresh_session_inner(&app, None).await; } }); Some(ClientSessionKeepalive { task }) } fn auth_state() -> &'static Mutex { AUTH_STATE.get_or_init(|| Mutex::new(AuthState::default())) } fn refresh_lock() -> &'static tokio::sync::Mutex<()> { REFRESH_LOCK.get_or_init(|| tokio::sync::Mutex::new(())) } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct TokenUserResponse { token: String, user: AuthUserPayload, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct TokenResponse { token: String, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct MeResponse { #[serde(default)] user: Option, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct SendCodeResponse { #[serde(default)] cooldown_seconds: u64, #[serde(default)] expires_in_seconds: u64, } fn session_file_path(app: &tauri::AppHandle) -> Result { app.path() .app_data_dir() .map(|root| root.join(SESSION_FILE_NAME)) .map_err(|error| format!("无法读取 AGC 应用数据目录:{error}")) } /// 校验并归一化平台服务 origin。 /// /// 只接受纯 origin:不允许凭据、路径、查询或 fragment;非本机必须 HTTPS。发布构建额外 /// 限制在已知渠道 origin 内,避免调试期写入的自定义地址在正式包里被继续使用。 pub(crate) fn validate_client_api_base_url(value: &str) -> Result { let trimmed = value.trim().trim_end_matches('/'); if trimmed.is_empty() || trimmed.chars().count() > MAX_ORIGIN_CHARS { return Err("陶泥儿服务地址无效".to_string()); } let parsed = Url::parse(trimmed).map_err(|_| "陶泥儿服务地址无效".to_string())?; if !parsed.username().is_empty() || parsed.password().is_some() { return Err("陶泥儿服务地址不能包含凭据".to_string()); } if !matches!(parsed.path(), "" | "/") || parsed.query().is_some() || parsed.fragment().is_some() { return Err("陶泥儿服务地址必须是纯地址,不能带路径或参数".to_string()); } let host = parsed.host_str().unwrap_or_default().to_ascii_lowercase(); match parsed.scheme() { "https" => {} "http" if matches!(host.as_str(), "localhost" | "127.0.0.1" | "[::1]") => {} "http" => return Err("非本机服务器必须使用 HTTPS".to_string()), _ => return Err("陶泥儿服务地址必须是 HTTP(S) 地址".to_string()), } if !cfg!(debug_assertions) && !matches!(trimmed, RELEASE_ORIGIN | DEVELOPMENT_ORIGIN) && !matches!(host.as_str(), "localhost" | "127.0.0.1" | "[::1]") { return Err("服务器地址不在当前构建渠道范围内".to_string()); } Ok(trimmed.to_string()) } fn read_session_file_at(path: &Path) -> Option { let metadata = fs::symlink_metadata(path).ok()?; if metadata.file_type().is_symlink() || !metadata.is_file() { return None; } let content = fs::read_to_string(path).ok()?; let file = serde_json::from_str::(&content).ok()?; file.is_complete().then_some(file) } fn write_session_file_at(path: &Path, session: Option<&ClientSessionFile>) -> Result<(), String> { let Some(session) = session else { if fs::symlink_metadata(path).is_ok() { fs::remove_file(path).map_err(|error| format!("清除客户端登录凭据失败:{error}"))?; } return Ok(()); }; let parent = path .parent() .ok_or_else(|| "客户端登录凭据缺少父目录".to_string())?; crate::ensure_game_creator_private_directory_tree(parent, "客户端登录凭据目录")?; let content = serde_json::to_string_pretty(session) .map_err(|error| format!("序列化客户端登录凭据失败:{error}"))?; let temp_path = path.with_file_name(format!( ".{}.tmp.{}.{}", path.file_name() .and_then(|name| name.to_str()) .unwrap_or(SESSION_FILE_NAME), std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_nanos() )); let mut options = OpenOptions::new(); options.create_new(true).write(true); #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; options.mode(0o600); } #[cfg(windows)] { use std::os::windows::fs::OpenOptionsExt; options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); } let mut handle = options .open(&temp_path) .map_err(|error| format!("创建客户端登录凭据临时文件失败:{error}"))?; if let Err(error) = crate::harden_new_game_creator_private_path(&temp_path, false, "客户端登录凭据") { drop(handle); let _ = fs::remove_file(&temp_path); return Err(error); } let write_result = handle .write_all(format!("{content}\n").as_bytes()) .and_then(|_| handle.sync_all()); drop(handle); if let Err(error) = write_result { let _ = fs::remove_file(&temp_path); return Err(format!("写入客户端登录凭据失败:{error}")); } if fs::symlink_metadata(path).is_ok() { crate::prepare_game_creator_private_path_for_read(path, false, "客户端登录凭据")?; #[cfg(windows)] fs::remove_file(path).map_err(|error| { let _ = fs::remove_file(&temp_path); format!("替换客户端登录凭据失败:{error}") })?; } if let Err(error) = fs::rename(&temp_path, path) { let _ = fs::remove_file(&temp_path); return Err(format!("提交客户端登录凭据失败:{error}")); } Ok(()) } fn require_app_session(app: &tauri::AppHandle) -> Result, String> { let path = session_file_path(app)?; let mut state = auth_state() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); if state.session.is_none() { state.session = read_session_file_at(&path); } Ok(state.session.clone()) } /// 推进下一次原生写入代次:换号与登出推进身份代次,同一身份续期只推进 revision。 fn reserve_session_write(identity_change: bool) -> (u64, u64) { let mut state = auth_state() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); if !state.counters_seeded { let floor = crate::platform_session::current_platform_session_write_state(); state.identity_generation = state.identity_generation.max(floor.identity_generation); state.revision = state.revision.max(floor.revision); state.counters_seeded = true; } state.revision = state.revision.saturating_add(1); if identity_change { state.identity_generation = state.identity_generation.saturating_add(1); } (state.identity_generation, state.revision) } fn current_session_origin() -> Option { current_platform_session().map(|snapshot| snapshot.api_base_url) } fn endpoint(origin: &str, route: &str) -> Result { let mut url = Url::parse(&format!("{}/", origin.trim_end_matches('/'))) .map_err(|_| "陶泥儿服务地址无效".to_string())?; { let mut segments = url .path_segments_mut() .map_err(|_| "陶泥儿服务地址无效".to_string())?; for segment in route.trim_start_matches('/').split('/') { if segment.is_empty() { continue; } segments.push(segment); } } Ok(url.to_string()) } fn build_client() -> Result { agc_main_site_client_builder() .connect_timeout(Duration::from_secs(10)) .timeout(HTTP_TIMEOUT) .build() .map_err(|_| "创建登录网络客户端失败".to_string()) } fn network_error_message(error: &reqwest::Error) -> String { if error.is_timeout() { AUTH_NETWORK_TIMEOUT.to_string() } else { AUTH_NETWORK_ERROR.to_string() } } fn error_message(body: &str) -> Option { let value = serde_json::from_str::(body).ok()?; let error = value.get("error").unwrap_or(&value); error .get("message") .and_then(Value::as_str) .map(str::trim) .filter(|message| !message.is_empty()) .map(ToString::to_string) } /// 认证路由语义:会话路由的 401/403 是权威失效,登录路由的 401/403 是用户可修正的输入问题。 #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum AuthRouteKind { Login, Session, } fn auth_route_kind(route: &str) -> AuthRouteKind { if route.ends_with("/me") || route.ends_with("/refresh") { AuthRouteKind::Session } else { AuthRouteKind::Login } } /// 把一次认证 HTTP 响应归类成稳定文案。 /// /// 会话路由的 `401/403` 带 `authentication-required` / `permission-denied` 前缀,调用方 /// 可以据此清会话;登录路由保留服务端原因(「手机号或密码错误」),不能被改写成登录失效。 /// 网络、5xx 与契约异常必须保留会话。 fn map_auth_failure(status: StatusCode, body: &str, fallback: &str, kind: AuthRouteKind) -> String { crate::platform_maintenance::watch_platform_response(status.as_u16(), body); if status == StatusCode::UNAUTHORIZED { return match (kind, error_message(body)) { (AuthRouteKind::Session, Some(message)) => { format!("authentication-required: {message}") } (AuthRouteKind::Session, None) => AUTH_AUTHORITY_ERROR.to_string(), (AuthRouteKind::Login, Some(message)) => message, (AuthRouteKind::Login, None) => fallback.to_string(), }; } if status == StatusCode::FORBIDDEN { return match (kind, error_message(body)) { (AuthRouteKind::Session, Some(message)) => format!("permission-denied: {message}"), (AuthRouteKind::Session, None) => { "permission-denied: 当前陶泥儿账号没有执行此操作的权限".to_string() } (AuthRouteKind::Login, Some(message)) => message, (AuthRouteKind::Login, None) => fallback.to_string(), }; } let detail = error_message(body).unwrap_or_else(|| format!("HTTP {}", status.as_u16())); format!("{fallback}:{detail}") } fn is_authority_failure(message: &str) -> bool { message.starts_with("authentication-required") || message.starts_with("permission-denied") } fn response_data(body: &str, fallback: &str) -> Result { let value: Value = serde_json::from_str(body).map_err(|_| format!("{fallback}:登录服务响应不是合法 JSON"))?; if value.get("ok").and_then(Value::as_bool) == Some(false) { return Err(format!( "{fallback}:{}", error_message(body).unwrap_or_else(|| "登录服务请求失败".to_string()) )); } Ok(value.get("data").cloned().unwrap_or(value)) } /// 从响应的 `Set-Cookie` 里取 refresh 凭据。 /// /// 服务端在登出时下发空值清理 cookie;空值表示「没有 refresh 凭据」,不能当成有效凭据。 fn refresh_cookie_from_response(response: &reqwest::Response) -> Option<(String, String)> { for value in response.headers().get_all(SET_COOKIE) { let Ok(raw) = value.to_str() else { continue }; let Some((name, remainder)) = raw.split_once('=') else { continue; }; let name = name.trim(); if name.is_empty() { continue; } let cookie_value = remainder.split(';').next().unwrap_or_default().trim(); if cookie_value.is_empty() { return None; } return Some((name.to_string(), cookie_value.to_string())); } None } #[derive(Clone, Copy)] enum CookiePolicy { Capture, Require, Ignore, } struct AuthResponse { data: Value, refresh_cookie: Option<(String, String)>, } async fn request_auth( client: &reqwest::Client, origin: &str, route: &str, body: Option, bearer: Option<&str>, refresh_cookie: Option<&(String, String)>, policy: CookiePolicy, fallback: &str, ) -> Result { let method = match policy { // 读取类路由用 GET;写入类路由是 POST。 CookiePolicy::Ignore if route.ends_with("/me") => Method::GET, _ => Method::POST, }; let mut request = client .request(method, endpoint(origin, route)?) .header(AGC_CLIENT_MARKER_HEADER, AGC_CLIENT_MARKER_VALUE) .header(API_RESPONSE_ENVELOPE_HEADER, API_RESPONSE_ENVELOPE_VERSION) .header(reqwest::header::ACCEPT, "application/json"); if let Some(bearer) = bearer { request = request.bearer_auth(bearer); } if let Some((name, value)) = refresh_cookie { request = request.header(reqwest::header::COOKIE, format!("{name}={value}")); } if let Some(body) = body { request = request.json(&body); } let response = request .send() .await .map_err(|error| network_error_message(&error))?; let status = response.status(); let captured = match policy { CookiePolicy::Capture | CookiePolicy::Require => refresh_cookie_from_response(&response), CookiePolicy::Ignore => None, }; let text = response .text() .await .map_err(|_| format!("{fallback}:读取响应失败"))?; if !status.is_success() { return Err(map_auth_failure( status, &text, fallback, auth_route_kind(route), )); } if matches!(policy, CookiePolicy::Require) && captured.is_none() { return Err("result-unknown: 登录服务未返回新的续期凭据,已停止使用旧凭据".to_string()); } Ok(AuthResponse { data: response_data(&text, fallback)?, 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, identity: SessionIdentity, token: String, refresh_cookie: (String, String), identity_change: bool, ) -> Result { 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(), user_id: user_id.clone(), refresh_cookie_name: refresh_cookie.0, refresh_cookie_value: refresh_cookie.1, }; let path = session_file_path(app)?; write_session_file_at(&path, Some(&session))?; { let mut state = auth_state() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); state.session = Some(session); state.access_token_issued_at = Some(unix_millis_now()); } let (identity_generation, revision) = reserve_session_write(identity_change); crate::commands::install_client_session_locally( user_id.clone(), token, origin.to_string(), identity_generation, revision, ) .await?; // 续期路径只知道 user_id:展示字段随后会用新 token 通过 /api/auth/me 复核。 Ok(known_user.unwrap_or_else(|| AuthUserPayload { id: user_id, public_user_code: String::new(), display_name: String::new(), avatar_url: None, phone_number: None, phone_number_masked: None, login_method: String::new(), binding_status: String::new(), wechat_bound: false, wechat_display_name: None, wechat_account: None, })) } async fn clear_authenticated_session(app: &tauri::AppHandle) -> Result<(), String> { let path = session_file_path(app)?; write_session_file_at(&path, None)?; { let mut state = auth_state() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); state.session = None; state.access_token_issued_at = None; } let (identity_generation, revision) = reserve_session_write(true); crate::commands::clear_client_session_locally(identity_generation, revision).await } fn auth_state_view( status: &str, user: Option, api_base_url: Option, ) -> ClientAuthStateView { ClientAuthStateView { status: status.to_string(), user, api_base_url, error_kind: None, error_message: None, } } fn emit_auth_state(app: &tauri::AppHandle, view: &ClientAuthStateView) { let _ = app.emit(CLIENT_AUTH_STATE_CHANGED_EVENT, view.clone()); } fn phone_is_valid(phone: &str) -> bool { !phone.is_empty() && phone.chars().count() <= MAX_PHONE_CHARS && 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, latest: Option<&ClientSessionFile>, ) -> bool { latest.is_some_and(|latest| { latest.api_base_url == before.api_base_url && latest.user_id == before.user_id && latest.refresh_cookie_value != before.refresh_cookie_value }) } async fn fetch_current_user( client: &reqwest::Client, snapshot: &PlatformSessionSnapshot, ) -> Result, String> { let response = request_auth( client, &snapshot.api_base_url, "/api/auth/me", None, Some(&snapshot.access_token), None, CookiePolicy::Ignore, "读取当前用户失败", ) .await?; let me: MeResponse = serde_json::from_value(response.data) .map_err(|_| "读取当前用户失败:响应格式无效".to_string())?; Ok(me.user) } /// 用当前 refresh 凭据换一次新 access token,并刷新本进程会话。 /// /// 并发调用复用同一次续期:等锁期间身份代次已经推进时直接返回最新会话,不再轮换第二次 /// refresh 凭据。 async fn refresh_session_inner( app: &tauri::AppHandle, expected_user_id: Option<&str>, ) -> Result { let Some(session) = require_app_session(app)? else { return Ok(ClientAuthRefreshView { status: "unauthenticated".to_string(), user: None, authoritative: true, error_message: None, }); }; let _guard = refresh_lock().lock().await; let client = build_client()?; // 双检:等锁期间另一个调用者可能已经完成续期,此时直接复用新会话, // 不再用刚轮换过的凭据再换一次。 if credential_rotated_elsewhere(&session, require_app_session(app)?.as_ref()) { if let Some(snapshot) = current_platform_session() { if snapshot.api_base_url == session.api_base_url && snapshot.user_id == session.user_id { if let Ok(Some(user)) = fetch_current_user(&client, &snapshot).await { return Ok(ClientAuthRefreshView { status: "refreshed".to_string(), user: Some(user), authoritative: false, error_message: None, }); } } } } let cookie = ( session.refresh_cookie_name.clone(), session.refresh_cookie_value.clone(), ); let refreshed = request_auth( &client, &session.api_base_url, "/api/auth/refresh", None, None, Some(&cookie), CookiePolicy::Require, "刷新登录状态失败", ) .await; let refreshed = match refreshed { Ok(response) => response, Err(error) => { if is_authority_failure(&error) { clear_authenticated_session(app).await?; let view = auth_state_view("unauthenticated", None, None); emit_auth_state(app, &view); return Ok(ClientAuthRefreshView { status: "unauthenticated".to_string(), user: None, authoritative: true, error_message: None, }); } return Ok(ClientAuthRefreshView { status: "failed".to_string(), user: None, authoritative: false, error_message: Some(error), }); } }; let token: TokenResponse = serde_json::from_value(refreshed.data) .map_err(|_| "刷新登录状态失败:凭据响应格式无效".to_string())?; let install_token = token.token.clone(); let new_cookie = refreshed .refresh_cookie .ok_or_else(|| "刷新登录状态失败:缺少新的续期凭据".to_string())?; commit_authenticated_session( app, &session.api_base_url, SessionIdentity::Persisted(session.user_id.clone()), install_token, new_cookie, false, ) .await?; // 续期只换凭据:立刻用新 token 复核身份展示字段,避免把空投影当成用户信息。 let resolved = match current_platform_session() { Some(snapshot) => fetch_current_user(&client, &snapshot).await, None => Ok(None), }; match resolved { Ok(Some(user)) => { if expected_user_id.is_some_and(|expected| expected != user.id) { return Ok(ClientAuthRefreshView { status: "stale".to_string(), user: None, authoritative: false, error_message: None, }); } let view = auth_state_view( "authenticated", Some(user.clone()), Some(session.api_base_url.clone()), ); emit_auth_state(app, &view); Ok(ClientAuthRefreshView { status: "refreshed".to_string(), user: Some(user), authoritative: false, error_message: None, }) } Ok(None) => { clear_authenticated_session(app).await?; let view = auth_state_view("unauthenticated", None, None); emit_auth_state(app, &view); Ok(ClientAuthRefreshView { status: "unauthenticated".to_string(), user: None, authoritative: true, error_message: None, }) } Err(error) if is_authority_failure(&error) => { clear_authenticated_session(app).await?; let view = auth_state_view("unauthenticated", None, None); emit_auth_state(app, &view); Ok(ClientAuthRefreshView { status: "unauthenticated".to_string(), user: None, authoritative: true, error_message: None, }) } Err(error) => Ok(ClientAuthRefreshView { status: "failed".to_string(), user: None, authoritative: false, error_message: Some(error), }), } } /// 读取登录态投影:没有凭据就直接报告未登录,有凭据则先续期再复核当前用户。 #[tauri::command] pub(crate) async fn read_client_auth_state( app: tauri::AppHandle, expected_api_base_url: Option, ) -> Result { let Some(session) = require_app_session(&app)? else { if let Some(snapshot) = current_platform_session() { // 凭据文件缺失但本进程仍有会话(例如同一次启动内刚登录):以会话为准。 let client = build_client()?; if let Ok(Some(user)) = fetch_current_user(&client, &snapshot).await { return Ok(auth_state_view( "authenticated", Some(user), Some(snapshot.api_base_url), )); } } return Ok(auth_state_view("unauthenticated", None, None)); }; let expected = match expected_api_base_url .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) { Some(value) => validate_client_api_base_url(value)?, None => session.api_base_url.clone(), }; if expected != session.api_base_url { // 凭据属于另一个 origin:不跨 origin 续期,也不删除文件(用户可能只是切了渠道)。 return Ok(auth_state_view("unauthenticated", None, None)); } match refresh_session_inner(&app, Some(session.user_id.as_str())).await { Ok(refresh) => match refresh.status.as_str() { "refreshed" => Ok(auth_state_view( "authenticated", refresh.user, Some(session.api_base_url), )), "stale" | "unauthenticated" => Ok(auth_state_view("unauthenticated", None, None)), _ => Ok(ClientAuthStateView { status: "unavailable".to_string(), user: None, api_base_url: Some(session.api_base_url), error_kind: Some("network".to_string()), error_message: refresh.error_message, }), }, Err(error) => Ok(ClientAuthStateView { status: "unavailable".to_string(), user: None, api_base_url: Some(session.api_base_url), error_kind: Some("network".to_string()), error_message: Some(error), }), } } #[tauri::command] pub(crate) async fn refresh_client_auth_session( app: tauri::AppHandle, expected_user_id: Option, ) -> Result { let expected = expected_user_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string); refresh_session_inner(&app, expected.as_deref()).await } #[tauri::command] pub(crate) async fn send_client_phone_login_code( api_base_url: String, phone: String, ) -> Result { let origin = validate_client_api_base_url(&api_base_url)?; let phone = phone.trim(); if !phone_is_valid(phone) { return Err("请输入正确的手机号".to_string()); } let client = build_client()?; let response = request_auth( &client, &origin, "/api/auth/phone/send-code", Some(serde_json::json!({ "countryCode": "86", "purePhoneNumber": phone, "scene": "login", })), None, None, CookiePolicy::Ignore, "发送验证码失败", ) .await?; let payload: SendCodeResponse = serde_json::from_value(response.data) .map_err(|_| "发送验证码失败:响应格式无效".to_string())?; Ok(ClientLoginCodeView { cooldown_seconds: payload.cooldown_seconds, expires_in_seconds: payload.expires_in_seconds, }) } async fn complete_login( app: &tauri::AppHandle, origin: &str, response: AuthResponse, ) -> Result { let payload: TokenUserResponse = serde_json::from_value(response.data) .map_err(|_| "登录失败:登录服务响应格式无效".to_string())?; let cookie = response .refresh_cookie .ok_or_else(|| "登录失败:登录服务未返回续期凭据".to_string())?; let user = commit_authenticated_session( app, origin, // 会话主体只能来自本次登录响应:空主体既不能写进凭据文件,也不能装进本进程会话。 SessionIdentity::Login(payload.user), payload.token, cookie, true, ) .await?; let view = auth_state_view( "authenticated", Some(user.clone()), Some(origin.to_string()), ); emit_auth_state(app, &view); Ok(user) } #[tauri::command] pub(crate) async fn login_client_with_password( app: tauri::AppHandle, api_base_url: String, phone: String, password: String, ) -> Result { let origin = validate_client_api_base_url(&api_base_url)?; let phone = phone.trim(); if !phone_is_valid(phone) { return Err("请输入正确的手机号".to_string()); } if password.trim().is_empty() { return Err("请输入密码".to_string()); } let client = build_client()?; let response = request_auth( &client, &origin, "/api/auth/entry", Some(serde_json::json!({ "countryCode": "86", "purePhoneNumber": phone, "password": password.trim(), })), None, None, CookiePolicy::Capture, "登录失败", ) .await?; complete_login(&app, &origin, response).await } #[tauri::command] pub(crate) async fn login_client_with_phone_code( app: tauri::AppHandle, api_base_url: String, phone: String, code: String, ) -> Result { let origin = validate_client_api_base_url(&api_base_url)?; let phone = phone.trim(); if !phone_is_valid(phone) { return Err("请输入正确的手机号".to_string()); } if code.trim().is_empty() { return Err("请输入验证码".to_string()); } let client = build_client()?; let response = request_auth( &client, &origin, "/api/auth/phone/login", Some(serde_json::json!({ "countryCode": "86", "purePhoneNumber": phone, "code": code.trim(), })), None, None, CookiePolicy::Capture, "登录失败", ) .await?; complete_login(&app, &origin, response).await } /// 登出:先尝试服务端撤销,再无条件清掉本地凭据与本进程会话。 #[tauri::command] pub(crate) async fn logout_client_session(app: tauri::AppHandle) -> Result<(), String> { let origin = current_session_origin() .or_else(|| { auth_state() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) .session .as_ref() .map(|session| session.api_base_url.clone()) }) .unwrap_or_else(|| DEVELOPMENT_ORIGIN.to_string()); if let Ok(client) = build_client() { if let Some(snapshot) = current_platform_session() { let _ = request_auth( &client, &origin, "/api/auth/logout", None, Some(&snapshot.access_token), None, CookiePolicy::Ignore, "退出登录失败", ) .await; } } clear_authenticated_session(&app).await?; let view = auth_state_view("unauthenticated", None, None); emit_auth_state(&app, &view); Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn channel_origins_are_always_accepted_and_normalized() { assert_eq!( validate_client_api_base_url("https://dev.genarrative.world/").expect("dev origin"), DEVELOPMENT_ORIGIN ); assert_eq!( validate_client_api_base_url(RELEASE_ORIGIN).expect("release origin"), RELEASE_ORIGIN ); } #[test] fn origins_reject_credentials_paths_and_plain_remote_http() { assert!(validate_client_api_base_url("https://user:pass@example.com").is_err()); assert!(validate_client_api_base_url("https://example.com/api").is_err()); assert!(validate_client_api_base_url("https://example.com?a=1").is_err()); assert!(validate_client_api_base_url("http://example.com").is_err()); assert!(validate_client_api_base_url("ftp://example.com").is_err()); assert!(validate_client_api_base_url(" ").is_err()); assert_eq!( validate_client_api_base_url("http://127.0.0.1:10001").expect("loopback debug"), "http://127.0.0.1:10001" ); } #[test] fn phones_must_be_digits_within_the_bound() { assert!(phone_is_valid("13800000000")); assert!(!phone_is_valid("")); assert!(!phone_is_valid("138 0000 0000")); assert!(!phone_is_valid("13800000000x")); assert!(!phone_is_valid(&"9".repeat(MAX_PHONE_CHARS + 1))); } #[test] fn session_routes_classify_401_403_as_authority_failures() { assert_eq!(auth_route_kind("/api/auth/refresh"), AuthRouteKind::Session); assert_eq!(auth_route_kind("/api/auth/me"), AuthRouteKind::Session); assert_eq!(auth_route_kind("/api/auth/entry"), AuthRouteKind::Login); assert!(is_authority_failure(&map_auth_failure( StatusCode::UNAUTHORIZED, "{}", "刷新失败", AuthRouteKind::Session ))); assert!(is_authority_failure(&map_auth_failure( StatusCode::FORBIDDEN, r#"{"error":{"message":"无权"}}"#, "刷新失败", AuthRouteKind::Session ))); let transient = map_auth_failure( StatusCode::INTERNAL_SERVER_ERROR, "{}", "刷新失败", AuthRouteKind::Session, ); assert!(!is_authority_failure(&transient)); assert!(transient.starts_with("刷新失败")); assert!(!is_authority_failure(AUTH_NETWORK_ERROR)); assert!(is_authority_failure(AUTH_AUTHORITY_ERROR)); } #[test] fn login_routes_keep_the_server_reason_instead_of_reporting_expiry() { let wrong_password = map_auth_failure( StatusCode::UNAUTHORIZED, r#"{"error":{"message":"手机号或密码错误"}}"#, "登录失败", AuthRouteKind::Login, ); assert_eq!(wrong_password, "手机号或密码错误"); assert!(!is_authority_failure(&wrong_password)); let missing_reason = map_auth_failure( StatusCode::UNAUTHORIZED, "{}", "登录失败", AuthRouteKind::Login, ); assert_eq!(missing_reason, "登录失败"); } #[test] fn keepalive_only_refreshes_after_the_freshness_window() { let issued = 1_000_000_u64; assert!(!session_needs_refresh( Some(issued), issued + CLIENT_SESSION_REFRESH_AFTER_MILLIS - 1 )); assert!(session_needs_refresh( Some(issued), issued + CLIENT_SESSION_REFRESH_AFTER_MILLIS )); // 未知签发时间按「需要续期」处理,不能把过期凭据当成新鲜凭据。 assert!(session_needs_refresh(None, issued)); // 时钟回拨按「新鲜」处理(饱和减法不下溢,不会造成刷新风暴); // 真过期时仍有 401 续期链路兜底。 assert!(!session_needs_refresh(Some(issued), 0)); } #[test] fn session_keepalive_does_not_start_without_an_app_handle() { // 测试进程从未调用 `initialize_auth_session`:此时不能启动后台循环, // 更不能留下一个没人能停止的保活任务。 assert!(AUTH_APP_HANDLE.get().is_none()); assert!(spawn_client_session_keepalive().is_none()); } #[test] fn concurrent_refresh_reuses_a_session_rotated_while_waiting() { let before = ClientSessionFile { schema_version: session_schema_version(), api_base_url: DEVELOPMENT_ORIGIN.to_string(), user_id: "user-1".to_string(), refresh_cookie_name: "genarrative_refresh_session".to_string(), refresh_cookie_value: "cookie-a".to_string(), }; let rotated = ClientSessionFile { refresh_cookie_value: "cookie-b".to_string(), ..before.clone() }; assert!(credential_rotated_elsewhere(&before, Some(&rotated))); assert!(!credential_rotated_elsewhere(&before, Some(&before))); assert!(!credential_rotated_elsewhere(&before, None)); let other_user = ClientSessionFile { user_id: "user-2".to_string(), ..rotated.clone() }; assert!(!credential_rotated_elsewhere(&before, Some(&other_user))); let other_origin = ClientSessionFile { api_base_url: RELEASE_ORIGIN.to_string(), ..rotated }; assert!(!credential_rotated_elsewhere(&before, Some(&other_origin))); } #[test] fn auth_state_events_never_carry_credentials() { let view = ClientAuthStateView { status: "authenticated".to_string(), user: Some(AuthUserPayload { id: "user-1".to_string(), public_user_code: "tn-1".to_string(), display_name: "测试".to_string(), avatar_url: None, phone_number: None, phone_number_masked: Some("138****0000".to_string()), login_method: "phone".to_string(), binding_status: "active".to_string(), wechat_bound: false, wechat_display_name: None, wechat_account: None, }), api_base_url: Some(DEVELOPMENT_ORIGIN.to_string()), error_kind: None, error_message: None, }; let serialized = serde_json::to_string(&view).expect("serialize auth state"); for forbidden in [ "token", "Token", "cookie", "Cookie", "refresh", "accessToken", "client-session.json", ] { assert!( !serialized.contains(forbidden), "登录态事件不能包含 {forbidden}:{serialized}" ); } assert!(serialized.contains("138****0000")); assert!(serialized.contains(DEVELOPMENT_ORIGIN)); } #[test] fn session_files_must_be_complete_and_round_trip_atomically() { let directory = tempfile::tempdir().expect("session fixture"); let path = directory.path().join(SESSION_FILE_NAME); let incomplete = ClientSessionFile { schema_version: session_schema_version(), api_base_url: DEVELOPMENT_ORIGIN.to_string(), user_id: String::new(), refresh_cookie_name: "genarrative_refresh_session".to_string(), refresh_cookie_value: "value".to_string(), }; assert!(!incomplete.is_complete()); write_session_file_at(&path, Some(&incomplete)).expect("write incomplete session"); assert!(read_session_file_at(&path).is_none()); assert!(fs::read_to_string(&path) .expect("incomplete file stays for diagnosis") .contains(SESSION_SCHEMA_VERSION)); let complete = ClientSessionFile { user_id: "user-1".to_string(), ..incomplete }; write_session_file_at(&path, Some(&complete)).expect("write session"); assert_eq!(read_session_file_at(&path), Some(complete)); write_session_file_at(&path, None).expect("clear session"); assert!(read_session_file_at(&path).is_none()); assert!(!path.exists()); } #[test] fn corrupt_or_foreign_session_files_never_authenticate() { let directory = tempfile::tempdir().expect("session fixture"); let path = directory.path().join(SESSION_FILE_NAME); // 不存在:未登录。 assert!(read_session_file_at(&path).is_none()); // 坏 JSON:不能被当成凭据。 fs::write(&path, b"{not json").expect("malformed session fixture"); assert!(read_session_file_at(&path).is_none()); // 字段缺失:整份凭据按无效处理,不允许"半个会话"进入续期链路。 fs::write( &path, br#"{"schemaVersion":"agc-client-session.v1","apiBaseUrl":"https://dev.genarrative.world","userId":"user-1"}"#, ) .expect("incomplete session fixture"); assert!(read_session_file_at(&path).is_none()); // 空值 refresh cookie(登出清理形态):同样不算凭据。 fs::write( &path, br#"{"schemaVersion":"agc-client-session.v1","apiBaseUrl":"https://dev.genarrative.world","userId":"user-1","refreshCookieName":"genarrative_refresh_session","refreshCookieValue":""}"#, ) .expect("cleared session fixture"); assert!(read_session_file_at(&path).is_none()); // 符号链接形式没有覆盖在这里:它由 `write_session_file_at` 的私有路径门禁拒绝, // 且 Windows 上无法用普通文件 API 构造。 } #[test] fn refresh_cookie_parsing_keeps_name_and_value_and_ignores_clears() { let with_cookie = http::Response::builder() .status(200) .header( SET_COOKIE, "genarrative_refresh_session=abc123; Path=/; HttpOnly; SameSite=Lax", ) .body(String::new()) .expect("fixture response"); let response = reqwest::Response::from(with_cookie); assert_eq!( refresh_cookie_from_response(&response), Some(( "genarrative_refresh_session".to_string(), "abc123".to_string() )) ); let cleared = http::Response::builder() .status(200) .header( SET_COOKIE, "genarrative_refresh_session=; Path=/; Max-Age=0; HttpOnly", ) .body(String::new()) .expect("clear response"); assert_eq!( refresh_cookie_from_response(&reqwest::Response::from(cleared)), 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()); } }