Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs
T
kdletters 1e62d8317a 合并 origin/master 到 AGC 渲染层下沉分支并完成三处对齐
- 活动回合事实源统一到 Direct 线程管理器:删除 direct_runtime 的第二份快照,接单、进度内容变化与收口各广播一次活动回合变更事件
- 平台维护态判定移入 Rust 并在渲染层只订阅单一事件:新增 platform_maintenance 模块与各平台 facade 错误分支的分类入口
- 封面生成请求补 generationInputs.source,保持队列回填后仍能拿到平台素材 ID
- 渲染层按 master 5398a53e6 退役诊断详情入口:删除 agentRuntimeErrorDetail 与「查看详情」交互及其专属用例
- 冲突收口:nginx SPA 白名单、.gitignore、mobile 检查脚本、capabilities 描述取上游,两个已退役计划随上游删除,文档保留双方条目
- 新增并回写本里程碑取证、decision-log 与 pitfalls 的 2026-09-28 记录
2026-09-28 15:07:30 +08:00

1352 lines
49 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::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
);
}
}