a512786996
- auth_session:拿到 status 后 response.text() 失败不再一律报网络不可达;非 2xx 按状态码走既有分类(serverMessage 为 None,如 503 → authServiceUnavailable),只有 2xx 响应没收完才归传输层故障 - 抽出 classify_unreadable_body 并补单测(503 / 会话 401 / 400 / 2xx) - 决策记录追加该口径
1710 lines
63 KiB
Rust
1710 lines
63 KiB
Rust
//! 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());
|
||
}
|
||
}
|