1e62d8317a
- 活动回合事实源统一到 Direct 线程管理器:删除 direct_runtime 的第二份快照,接单、进度内容变化与收口各广播一次活动回合变更事件
- 平台维护态判定移入 Rust 并在渲染层只订阅单一事件:新增 platform_maintenance 模块与各平台 facade 错误分支的分类入口
- 封面生成请求补 generationInputs.source,保持队列回填后仍能拿到平台素材 ID
- 渲染层按 master 5398a53e6 退役诊断详情入口:删除 agentRuntimeErrorDetail 与「查看详情」交互及其专属用例
- 冲突收口:nginx SPA 白名单、.gitignore、mobile 检查脚本、capabilities 描述取上游,两个已退役计划随上游删除,文档保留双方条目
- 新增并回写本里程碑取证、decision-log 与 pitfalls 的 2026-09-28 记录
1352 lines
49 KiB
Rust
1352 lines
49 KiB
Rust
//! 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<AuthUserPayload>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub(crate) api_base_url: Option<String>,
|
||
/// 失败分类:`network` / `authority` / `contract`;成功或未登录时为 `None`。
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub(crate) error_kind: Option<String>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub(crate) error_message: 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` 表示续期期间身份已经变化,调用方不得重放旧身份请求。
|
||
#[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<AuthUserPayload>,
|
||
#[serde(default)]
|
||
pub(crate) authoritative: bool,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub(crate) error_message: Option<String>,
|
||
}
|
||
|
||
#[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, String> {
|
||
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<String, String> {
|
||
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<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<(), 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<Option<ClientSessionFile>, 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<String> {
|
||
current_platform_session().map(|snapshot| snapshot.api_base_url)
|
||
}
|
||
|
||
fn endpoint(origin: &str, route: &str) -> Result<String, String> {
|
||
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<reqwest::Client, String> {
|
||
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<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 是权威失效,登录路由的 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<Value, String> {
|
||
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<Value>,
|
||
bearer: Option<&str>,
|
||
refresh_cookie: Option<&(String, String)>,
|
||
policy: CookiePolicy,
|
||
fallback: &str,
|
||
) -> Result<AuthResponse, String> {
|
||
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,
|
||
})
|
||
}
|
||
/// 持久化新凭据并安装本进程会话。
|
||
async fn commit_authenticated_session(
|
||
app: &tauri::AppHandle,
|
||
origin: &str,
|
||
known_user: Option<AuthUserPayload>,
|
||
user_id: String,
|
||
token: String,
|
||
refresh_cookie: (String, String),
|
||
identity_change: bool,
|
||
) -> Result<AuthUserPayload, String> {
|
||
if token.chars().count() > MAX_SECRET_CHARS {
|
||
return Err("登录服务返回的凭据无效".to_string());
|
||
}
|
||
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<AuthUserPayload>,
|
||
api_base_url: Option<String>,
|
||
) -> 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 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>, 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<ClientAuthRefreshView, String> {
|
||
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,
|
||
None,
|
||
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<String>,
|
||
) -> Result<ClientAuthStateView, String> {
|
||
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<String>,
|
||
) -> Result<ClientAuthRefreshView, String> {
|
||
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, String> {
|
||
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<AuthUserPayload, String> {
|
||
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,
|
||
Some(payload.user),
|
||
String::new(),
|
||
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, String> {
|
||
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<AuthUserPayload, String> {
|
||
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
|
||
);
|
||
}
|
||
}
|