Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs
T
k88936 a512786996 读body失败保留已确认状态码
- auth_session:拿到 status 后 response.text() 失败不再一律报网络不可达;非 2xx 按状态码走既有分类(serverMessage 为 None,如 503 → authServiceUnavailable),只有 2xx 响应没收完才归传输层故障
- 抽出 classify_unreadable_body 并补单测(503 / 会话 401 / 400 / 2xx)
- 决策记录追加该口径
2026-10-02 13:47:03 +08:00

1710 lines
63 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! AGC 认证会话:凭据持有、登录/续期/登出 HTTP 与本地运行时会话安装。
//!
//! 渲染层只表达「用哪个手机号/密码/验证码登录」「哪个 origin」这类用户意图;access token
//! 只在 Rust 内存与本进程会话快照里,refresh 凭据只写在 AppData 私有文件里。换号、登出或
//! origin 变化都会让旧身份的在途请求失败关闭;同一身份的凭据轮换不改变身份代次。
use crate::auth_error::{
AuthClientInitFailed, AuthNetworkFailure, AuthNetworkReason, AuthResponseInvalid,
AuthResponseInvalidReason, AuthServiceUnavailable, ClientAuthError, ClientSessionPersistFailed,
PasswordLoginRejected, PhoneCodeLoginRejected, RuntimeSessionInstallFailed, SendCodeRejected,
ServerAddressReason, ServerAddressRejected, UnexpectedRejection,
};
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";
/// 认证态投影:只含状态、用户展示字段与 origin,不含 token 或 refresh 凭据。
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ClientAuthStateView {
/// `authenticated` / `unauthenticated`。
pub(crate) status: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) user: Option<AuthUserPayload>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) api_base_url: Option<String>,
}
#[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` 表示续期期间身份已经变化,调用方不得重放旧身份请求;
/// 非权威失败(网络 / 5xx / 契约异常)不在这里降级成字符串,直接以 `Err(ClientAuthError)` 返回。
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ClientAuthRefreshView {
/// `refreshed` / `unauthenticated` / `stale`。
pub(crate) status: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) user: Option<AuthUserPayload>,
#[serde(default)]
pub(crate) authoritative: bool,
}
#[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<ClientSessionFile>,
identity_generation: u64,
revision: u64,
counters_seeded: bool,
/// 当前 access token 的签发时间(本进程视角),用于按到期时间自助续期。
access_token_issued_at: Option<u64>,
}
static AUTH_STATE: std::sync::OnceLock<Mutex<AuthState>> = std::sync::OnceLock::new();
static REFRESH_LOCK: std::sync::OnceLock<tokio::sync::Mutex<()>> = std::sync::OnceLock::new();
static AUTH_APP_HANDLE: std::sync::OnceLock<tauri::AppHandle> = 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<u64>, 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<ClientSessionKeepalive> {
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<AuthState> {
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<AuthUserPayload>,
}
#[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<PathBuf, ClientAuthError> {
app.path()
.app_data_dir()
.map(|root| root.join(SESSION_FILE_NAME))
.map_err(|error| {
let detail = format!("无法读取 AGC 应用数据目录:{error}");
app_log!("{detail}");
ClientAuthError::ClientSessionPersistFailed(ClientSessionPersistFailed { detail })
})
}
/// 凭据文件相关的字符串错误统一收口成凭据落盘失败变体。
fn session_persist_error(message: impl Into<String>) -> ClientAuthError {
let detail = message.into();
app_log!("{detail}");
ClientAuthError::ClientSessionPersistFailed(ClientSessionPersistFailed { detail })
}
/// 本机运行时安装 / 清理失败的字符串错误统一收口。
fn runtime_session_error(message: impl Into<String>) -> ClientAuthError {
let detail = message.into();
app_log!("{detail}");
ClientAuthError::RuntimeSessionInstallFailed(RuntimeSessionInstallFailed { detail })
}
/// 服务地址校验失败统一收口成带类型化原因的变体。
fn server_address_rejected(reason: ServerAddressReason) -> ClientAuthError {
ClientAuthError::ServerAddressRejected(ServerAddressRejected { reason })
}
/// 响应契约破损(不含服务端原文)统一收口。
fn response_invalid(reason: AuthResponseInvalidReason) -> ClientAuthError {
ClientAuthError::AuthResponseInvalid(AuthResponseInvalid {
reason,
server_message: None,
})
}
/// 校验并归一化平台服务 origin。
///
/// 只接受纯 origin:不允许凭据、路径、查询或 fragment;非本机必须 HTTPS。发布构建额外
/// 限制在已知渠道 origin 内,避免调试期写入的自定义地址在正式包里被继续使用。
///
/// 每种拒绝各成一个变体:调用方只按变体选提示,不需要读任何文案。
pub(crate) fn validate_client_api_base_url(value: &str) -> Result<String, ClientAuthError> {
let trimmed = value.trim().trim_end_matches('/');
if trimmed.is_empty() || trimmed.chars().count() > MAX_ORIGIN_CHARS {
return Err(server_address_rejected(ServerAddressReason::EmptyOrTooLong));
}
let parsed =
Url::parse(trimmed).map_err(|_| server_address_rejected(ServerAddressReason::NotAUrl))?;
if !parsed.username().is_empty() || parsed.password().is_some() {
return Err(server_address_rejected(ServerAddressReason::HasCredentials));
}
if !matches!(parsed.path(), "" | "/") || parsed.query().is_some() || parsed.fragment().is_some()
{
return Err(server_address_rejected(
ServerAddressReason::HasPathOrQueryOrFragment,
));
}
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(server_address_rejected(ServerAddressReason::NotHttps)),
_ => {
return Err(server_address_rejected(
ServerAddressReason::UnsupportedScheme,
))
}
}
if !cfg!(debug_assertions)
&& !matches!(trimmed, RELEASE_ORIGIN | DEVELOPMENT_ORIGIN)
&& !matches!(host.as_str(), "localhost" | "127.0.0.1" | "[::1]")
{
return Err(server_address_rejected(ServerAddressReason::OutsideChannel));
}
Ok(trimmed.to_string())
}
fn read_session_file_at(path: &Path) -> Option<ClientSessionFile> {
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::<ClientSessionFile>(&content).ok()?;
file.is_complete().then_some(file)
}
fn write_session_file_at(
path: &Path,
session: Option<&ClientSessionFile>,
) -> Result<(), ClientAuthError> {
let Some(session) = session else {
if fs::symlink_metadata(path).is_ok() {
fs::remove_file(path).map_err(|error| {
session_persist_error(format!("清除客户端登录凭据失败:{error}"))
})?;
}
return Ok(());
};
let parent = path
.parent()
.ok_or_else(|| session_persist_error("客户端登录凭据缺少父目录"))?;
crate::ensure_game_creator_private_directory_tree(parent, "客户端登录凭据目录")
.map_err(session_persist_error)?;
let content = serde_json::to_string_pretty(session)
.map_err(|error| session_persist_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| {
session_persist_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(session_persist_error(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(session_persist_error(format!(
"写入客户端登录凭据失败:{error}"
)));
}
if fs::symlink_metadata(path).is_ok() {
crate::prepare_game_creator_private_path_for_read(path, false, "客户端登录凭据")
.map_err(session_persist_error)?;
#[cfg(windows)]
fs::remove_file(path).map_err(|error| {
let _ = fs::remove_file(&temp_path);
session_persist_error(format!("替换客户端登录凭据失败:{error}"))
})?;
}
if let Err(error) = fs::rename(&temp_path, path) {
let _ = fs::remove_file(&temp_path);
return Err(session_persist_error(format!(
"提交客户端登录凭据失败:{error}"
)));
}
Ok(())
}
fn require_app_session(
app: &tauri::AppHandle,
) -> Result<Option<ClientSessionFile>, ClientAuthError> {
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<String> {
current_platform_session().map(|snapshot| snapshot.api_base_url)
}
fn endpoint(origin: &str, route: &str) -> Result<String, ClientAuthError> {
let mut url = Url::parse(&format!("{}/", origin.trim_end_matches('/')))
.map_err(|_| server_address_rejected(ServerAddressReason::NotAUrl))?;
{
let mut segments = url
.path_segments_mut()
.map_err(|_| server_address_rejected(ServerAddressReason::NotAUrl))?;
for segment in route.trim_start_matches('/').split('/') {
if segment.is_empty() {
continue;
}
segments.push(segment);
}
}
Ok(url.to_string())
}
fn build_client() -> Result<reqwest::Client, ClientAuthError> {
agc_main_site_client_builder()
.connect_timeout(Duration::from_secs(10))
.timeout(HTTP_TIMEOUT)
.build()
.map_err(|error| {
ClientAuthError::AuthClientInitFailed(AuthClientInitFailed {
detail: error.to_string(),
})
})
}
fn network_error(error: &reqwest::Error) -> ClientAuthError {
let reason = if error.is_timeout() {
AuthNetworkReason::Timeout
} else {
AuthNetworkReason::Unreachable
};
ClientAuthError::AuthNetworkFailure(AuthNetworkFailure { reason })
}
fn error_message(body: &str) -> Option<String> {
let value = serde_json::from_str::<Value>(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/429 落到哪个具体变体。
///
/// 会话路由的 `401/403` 是登录态权威失效,登录路由的 `401` 是用户可修正的输入问题;
/// 这个区分现在由 [`ClientAuthError`] 的变体承担,不再靠文本前缀。
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum AuthRoute {
PasswordEntry,
PhoneLogin,
SendCode,
Session,
Other,
}
fn auth_route(route: &str) -> AuthRoute {
let route = route.trim_end_matches('/');
if route.ends_with("/api/auth/entry") {
AuthRoute::PasswordEntry
} else if route.ends_with("/api/auth/phone/login") {
AuthRoute::PhoneLogin
} else if route.ends_with("/api/auth/phone/send-code") {
AuthRoute::SendCode
} else if route.ends_with("/api/auth/me")
|| route.ends_with("/api/auth/refresh")
|| route.ends_with("/api/auth/logout")
{
AuthRoute::Session
} else {
AuthRoute::Other
}
}
/// 把一次认证 HTTP 响应归类成具体变体。
///
/// 会话路由的 `401/403` 归到权威失效变体,调用方据此清会话;登录路由的 `401` 是用户可修正的
/// 输入问题。可判定的用户输入原因只进业务变体,认不出的才落到 `UnexpectedRejection`。
/// `429` 也按路由判定:发码路由是频控,登录路由是「验证码错误次数过多」,两者都是用户可修正的
/// 输入问题;其余路由的 `429` 仍按未识别拒绝处理。网络、5xx 与契约异常必须保留会话。
fn map_auth_failure(status: StatusCode, body: &str, route: AuthRoute) -> ClientAuthError {
crate::platform_maintenance::watch_platform_response(status.as_u16(), body);
let status_code = status.as_u16();
let server_message = error_message(body);
if status == StatusCode::UNAUTHORIZED {
return match route {
AuthRoute::Session => ClientAuthError::SessionInvalidated,
AuthRoute::PasswordEntry => ClientAuthError::PhoneOrPasswordMismatch,
AuthRoute::PhoneLogin => {
ClientAuthError::PhoneCodeLoginRejected(PhoneCodeLoginRejected { server_message })
}
_ => ClientAuthError::UnexpectedRejection(UnexpectedRejection {
status: status_code,
server_message,
}),
};
}
if status == StatusCode::FORBIDDEN {
return match route {
AuthRoute::Session => ClientAuthError::PermissionDenied,
_ => ClientAuthError::UnexpectedRejection(UnexpectedRejection {
status: status_code,
server_message,
}),
};
}
if status == StatusCode::TOO_MANY_REQUESTS {
return match route {
AuthRoute::SendCode => ClientAuthError::SmsCodeThrottled,
AuthRoute::PhoneLogin => {
ClientAuthError::PhoneCodeLoginRejected(PhoneCodeLoginRejected { server_message })
}
_ => ClientAuthError::UnexpectedRejection(UnexpectedRejection {
status: status_code,
server_message,
}),
};
}
if status == StatusCode::BAD_REQUEST {
return match route {
AuthRoute::PasswordEntry => {
ClientAuthError::PasswordLoginRejected(PasswordLoginRejected { server_message })
}
AuthRoute::PhoneLogin => {
ClientAuthError::PhoneCodeLoginRejected(PhoneCodeLoginRejected { server_message })
}
AuthRoute::SendCode => {
ClientAuthError::SendCodeRejected(SendCodeRejected { server_message })
}
_ => ClientAuthError::UnexpectedRejection(UnexpectedRejection {
status: status_code,
server_message,
}),
};
}
if status.is_server_error() {
return ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable {
status: status_code,
});
}
ClientAuthError::UnexpectedRejection(UnexpectedRejection {
status: status_code,
server_message,
})
}
/// body 读取失败时的归类。
///
/// 状态码此时已经拿到:非 2xx 按状态码走已有分类(body 没读到,服务端原文就是 `None`),
/// 不把服务端失败误报成传输层故障;只有 2xx 响应没收完才归网络不可达。
fn classify_unreadable_body(status: StatusCode, route: AuthRoute) -> ClientAuthError {
if status.is_success() {
ClientAuthError::AuthNetworkFailure(AuthNetworkFailure {
reason: AuthNetworkReason::Unreachable,
})
} else {
map_auth_failure(status, "", route)
}
}
fn response_data(body: &str) -> Result<Value, ClientAuthError> {
let value: Value = serde_json::from_str(body)
.map_err(|_| response_invalid(AuthResponseInvalidReason::NotJson))?;
if value.get("ok").and_then(Value::as_bool) == Some(false) {
return Err(ClientAuthError::AuthResponseInvalid(AuthResponseInvalid {
reason: AuthResponseInvalidReason::ServerRejected,
server_message: error_message(body),
}));
}
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<Value>,
bearer: Option<&str>,
refresh_cookie: Option<&(String, String)>,
policy: CookiePolicy,
) -> Result<AuthResponse, ClientAuthError> {
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(&error))?;
let status = response.status();
let captured = match policy {
CookiePolicy::Capture | CookiePolicy::Require => refresh_cookie_from_response(&response),
CookiePolicy::Ignore => None,
};
let text = match response.text().await {
Ok(text) => text,
Err(_) => return Err(classify_unreadable_body(status, auth_route(route))),
};
if !status.is_success() {
return Err(map_auth_failure(status, &text, auth_route(route)));
}
if matches!(policy, CookiePolicy::Require) && captured.is_none() {
return Err(response_invalid(
AuthResponseInvalidReason::MissingRefreshCookie,
));
}
Ok(AuthResponse {
data: response_data(&text)?,
refresh_cookie: captured,
})
}
/// 一次会话写入的身份来源。
///
/// 登录路径的主体只能来自本次登录响应的用户投影,续期路径只能来自已持久化的凭据文件;
/// 把来源编码进类型后,「漏传主体」不再是能写出来的状态:`resolve` 是唯一入口。
enum SessionIdentity {
/// 登录:主体与展示字段都取自响应里的用户投影。
Login(AuthUserPayload),
/// 续期:只有已持久化的主体,展示字段随后用新凭据复核。
Persisted(String),
}
impl SessionIdentity {
/// 拆出会话主体与展示用的用户投影:空主体在这里失败关闭,既不能写凭据文件,也不能
/// 装进本进程会话。
fn resolve(self) -> Result<(String, Option<AuthUserPayload>), ClientAuthError> {
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<AuthUserPayload, ClientAuthError> {
if token.chars().count() > MAX_SECRET_CHARS {
return Err(response_invalid(AuthResponseInvalidReason::InvalidBody));
}
// 主体先于凭据落盘解析:没有主体就不写 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
.map_err(runtime_session_error)?;
// 续期路径只知道 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<(), ClientAuthError> {
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
.map_err(runtime_session_error)
}
fn auth_state_view(
status: &str,
user: Option<AuthUserPayload>,
api_base_url: Option<String>,
) -> ClientAuthStateView {
ClientAuthStateView {
status: status.to_string(),
user,
api_base_url,
}
}
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<String, ClientAuthError> {
let user_id = user_id.trim();
if user_id.is_empty() {
return Err(response_invalid(
AuthResponseInvalidReason::MissingUserIdentity,
));
}
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<Option<AuthUserPayload>, ClientAuthError> {
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(|_| response_invalid(AuthResponseInvalidReason::InvalidBody))?;
Ok(me.user)
}
/// 用当前 refresh 凭据换一次新 access token,并刷新本进程会话。
///
/// 并发调用复用同一次续期:等锁期间身份代次已经推进时直接返回最新会话,不再轮换第二次
/// refresh 凭据。
async fn refresh_session_inner(
app: &tauri::AppHandle,
expected_user_id: Option<&str>,
) -> Result<ClientAuthRefreshView, ClientAuthError> {
let Some(session) = require_app_session(app)? else {
return Ok(ClientAuthRefreshView {
status: "unauthenticated".to_string(),
user: None,
authoritative: true,
});
};
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,
});
}
}
}
}
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 error.is_authority_failure() {
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,
});
}
// 非权威失败不再降级成字符串:把 typed error 原样交给命令出口,
// 由前端 `invokeClientAuth` 装进 `ClientAuthErrorWrapper`(`cause` 是判别联合)后按变体分流。
return Err(error);
}
};
let token: TokenResponse = serde_json::from_value(refreshed.data)
.map_err(|_| response_invalid(AuthResponseInvalidReason::InvalidBody))?;
let install_token = token.token.clone();
let new_cookie = refreshed
.refresh_cookie
.ok_or_else(|| response_invalid(AuthResponseInvalidReason::MissingRefreshCookie))?;
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,
});
}
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,
})
}
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,
})
}
Err(error) if error.is_authority_failure() => {
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,
})
}
Err(error) => Err(error),
}
}
/// 读取登录态投影:没有凭据就直接报告未登录,有凭据则先续期再复核当前用户。
#[tauri::command]
pub(crate) async fn read_client_auth_state(
app: tauri::AppHandle,
expected_api_base_url: Option<String>,
) -> Result<ClientAuthStateView, ClientAuthError> {
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)),
},
Err(error) => Err(error),
}
}
#[tauri::command]
pub(crate) async fn refresh_client_auth_session(
app: tauri::AppHandle,
expected_user_id: Option<String>,
) -> Result<ClientAuthRefreshView, ClientAuthError> {
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<ClientLoginCodeView, ClientAuthError> {
let origin = validate_client_api_base_url(&api_base_url)?;
let phone = phone.trim();
if !phone_is_valid(phone) {
return Err(ClientAuthError::PhoneNumberInvalid);
}
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(|_| response_invalid(AuthResponseInvalidReason::InvalidBody))?;
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<AuthUserPayload, ClientAuthError> {
let payload: TokenUserResponse = serde_json::from_value(response.data)
.map_err(|_| response_invalid(AuthResponseInvalidReason::InvalidBody))?;
let cookie = response
.refresh_cookie
.ok_or_else(|| response_invalid(AuthResponseInvalidReason::MissingRefreshCookie))?;
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<AuthUserPayload, ClientAuthError> {
let origin = validate_client_api_base_url(&api_base_url)?;
let phone = phone.trim();
if !phone_is_valid(phone) {
return Err(ClientAuthError::PhoneNumberInvalid);
}
if password.trim().is_empty() {
return Err(ClientAuthError::PasswordMissing);
}
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<AuthUserPayload, ClientAuthError> {
let origin = validate_client_api_base_url(&api_base_url)?;
let phone = phone.trim();
if !phone_is_valid(phone) {
return Err(ClientAuthError::PhoneNumberInvalid);
}
if code.trim().is_empty() {
return Err(ClientAuthError::LoginCodeMissing);
}
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<(), ClientAuthError> {
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() {
// 顶层只占一个变体,细分走类型化 reason:前端 switch(reason) 仍然按类型分流,不读文案。
assert_eq!(
validate_client_api_base_url("https://user:pass@example.com"),
Err(server_address_rejected(ServerAddressReason::HasCredentials))
);
assert_eq!(
validate_client_api_base_url("https://example.com/api"),
Err(server_address_rejected(
ServerAddressReason::HasPathOrQueryOrFragment
))
);
assert_eq!(
validate_client_api_base_url("https://example.com?a=1"),
Err(server_address_rejected(
ServerAddressReason::HasPathOrQueryOrFragment
))
);
assert_eq!(
validate_client_api_base_url("https://example.com#frag"),
Err(server_address_rejected(
ServerAddressReason::HasPathOrQueryOrFragment
))
);
assert_eq!(
validate_client_api_base_url("http://example.com"),
Err(server_address_rejected(ServerAddressReason::NotHttps))
);
assert_eq!(
validate_client_api_base_url("ftp://example.com"),
Err(server_address_rejected(
ServerAddressReason::UnsupportedScheme
))
);
assert_eq!(
validate_client_api_base_url(" "),
Err(server_address_rejected(ServerAddressReason::EmptyOrTooLong))
);
assert_eq!(
validate_client_api_base_url("不是地址"),
Err(server_address_rejected(ServerAddressReason::NotAUrl))
);
assert_eq!(
validate_client_api_base_url(&format!(
"https://example.com/{}",
"a".repeat(MAX_ORIGIN_CHARS)
)),
Err(server_address_rejected(ServerAddressReason::EmptyOrTooLong))
);
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 routes_are_classified_by_their_concrete_endpoint() {
assert_eq!(auth_route("/api/auth/refresh"), AuthRoute::Session);
assert_eq!(auth_route("/api/auth/me"), AuthRoute::Session);
assert_eq!(auth_route("/api/auth/entry"), AuthRoute::PasswordEntry);
assert_eq!(auth_route("/api/auth/phone/login"), AuthRoute::PhoneLogin);
assert_eq!(auth_route("/api/auth/phone/send-code"), AuthRoute::SendCode);
assert_eq!(auth_route("/api/other"), AuthRoute::Other);
}
#[test]
fn session_routes_classify_401_403_as_authority_failures() {
assert!(
map_auth_failure(StatusCode::UNAUTHORIZED, "{}", AuthRoute::Session)
.is_authority_failure()
);
assert!(map_auth_failure(
StatusCode::FORBIDDEN,
r#"{"error":{"message":"无权"}}"#,
AuthRoute::Session
)
.is_authority_failure());
assert_eq!(
map_auth_failure(StatusCode::UNAUTHORIZED, "{}", AuthRoute::Session),
ClientAuthError::SessionInvalidated
);
assert_eq!(
map_auth_failure(
StatusCode::FORBIDDEN,
r#"{"error":{"message":"无权"}}"#,
AuthRoute::Session,
),
ClientAuthError::PermissionDenied
);
let transient =
map_auth_failure(StatusCode::INTERNAL_SERVER_ERROR, "{}", AuthRoute::Session);
assert!(!transient.is_authority_failure());
assert_eq!(
transient,
ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { status: 500 })
);
}
/// 登录路由的 401 是用户可修正的输入问题,不能和会话失效混在一起。
///
/// 服务端文案不再进载荷:变体本身已经说清事实,前缀/展示文案由前端调用方按当前操作给。
#[test]
fn login_route_401_stays_a_user_correctable_mismatch() {
for body in [r#"{"error":{"message":"手机号或密码错误"}}"#, "{}"] {
let error = map_auth_failure(StatusCode::UNAUTHORIZED, body, AuthRoute::PasswordEntry);
assert_eq!(error, ClientAuthError::PhoneOrPasswordMismatch);
assert!(!error.is_authority_failure());
}
}
/// 发码端点不带凭据:401/403 不是「用户可改的输入」,而是协议异常,属系统变体(带上文上报)。
///
/// api-server 的 `send_phone_code` 只会返回 400(手机号登录未启用)与 5xx;401/403 真出现时
/// 说明契约破了,不该被当成业务提示吞掉。
#[test]
fn send_code_401_403_stay_system_rejections() {
for status in [StatusCode::UNAUTHORIZED, StatusCode::FORBIDDEN] {
let error = map_auth_failure(
status,
r#"{"error":{"message":"手机号登录暂未启用"}}"#,
AuthRoute::SendCode,
);
assert!(!error.is_authority_failure());
let ClientAuthError::UnexpectedRejection(UnexpectedRejection {
status: 401 | 403,
server_message: Some(message),
}) = error
else {
panic!("发码端点 401/403 必须落成带原文的系统变体");
};
assert_eq!(message, "手机号登录暂未启用");
}
}
#[test]
fn input_rejections_keep_the_server_text_while_self_describing_variants_stay_bare() {
let password_length = map_auth_failure(
StatusCode::BAD_REQUEST,
r#"{"error":{"message":"密码长度需要在 6 到 128 位之间"}}"#,
AuthRoute::PasswordEntry,
);
assert_eq!(
password_length,
ClientAuthError::PasswordLoginRejected(PasswordLoginRejected {
server_message: Some("密码长度需要在 6 到 128 位之间".to_string()),
})
);
let throttled = map_auth_failure(
StatusCode::TOO_MANY_REQUESTS,
r#"{"error":{"message":"发送过于频繁"}}"#,
AuthRoute::SendCode,
);
assert_eq!(throttled, ClientAuthError::SmsCodeThrottled);
// 登录路由的 429 是「验证码错误次数过多」:用户可修正的输入问题,不能进系统变体被上报。
let verify_attempts_exceeded = map_auth_failure(
StatusCode::TOO_MANY_REQUESTS,
r#"{"error":{"message":"验证码错误次数过多,请重新获取验证码"}}"#,
AuthRoute::PhoneLogin,
);
assert_eq!(
verify_attempts_exceeded,
ClientAuthError::PhoneCodeLoginRejected(PhoneCodeLoginRejected {
server_message: Some("验证码错误次数过多,请重新获取验证码".to_string()),
})
);
// 其余路由的 429 仍未识别拒绝:既不冒充业务原因,也不冒充发码频控。
let unknown_throttle = map_auth_failure(
StatusCode::TOO_MANY_REQUESTS,
r#"{"error":{"message":"限流"}}"#,
AuthRoute::Other,
);
assert_eq!(
unknown_throttle,
ClientAuthError::UnexpectedRejection(UnexpectedRejection {
status: 429,
server_message: Some("限流".to_string()),
})
);
// 登录路由的 401 只来自「用户不存在」:仍是业务变体,保留服务端原文,不能进系统变体。
let user_not_found = map_auth_failure(
StatusCode::UNAUTHORIZED,
r#"{"error":{"message":"用户不存在"}}"#,
AuthRoute::PhoneLogin,
);
assert_eq!(
user_not_found,
ClientAuthError::PhoneCodeLoginRejected(PhoneCodeLoginRejected {
server_message: Some("用户不存在".to_string()),
})
);
}
/// 读 body 失败时状态码已经拿到,不能被压成「网络不可达」。
#[test]
fn unreadable_body_keeps_the_known_status() {
assert_eq!(
classify_unreadable_body(StatusCode::SERVICE_UNAVAILABLE, AuthRoute::PhoneLogin),
ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { status: 503 })
);
assert_eq!(
classify_unreadable_body(StatusCode::UNAUTHORIZED, AuthRoute::Session),
ClientAuthError::SessionInvalidated
);
assert_eq!(
classify_unreadable_body(StatusCode::BAD_REQUEST, AuthRoute::PasswordEntry),
ClientAuthError::PasswordLoginRejected(PasswordLoginRejected {
server_message: None
})
);
// 只有 2xx 响应没收完才是传输层故障。
assert_eq!(
classify_unreadable_body(StatusCode::OK, AuthRoute::Other),
ClientAuthError::AuthNetworkFailure(AuthNetworkFailure {
reason: AuthNetworkReason::Unreachable,
})
);
}
#[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()),
};
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(),
response_invalid(AuthResponseInvalidReason::MissingUserIdentity)
);
}
#[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(),
response_invalid(AuthResponseInvalidReason::MissingUserIdentity)
);
// 续期路径的空主体同样失败关闭:缺字段的凭据文件本来就读不出来,不能在这里被复活。
assert!(SessionIdentity::Persisted(String::new()).resolve().is_err());
}
}