9070c0760b
- 允许 release 客户端安全访问 custom HTTPS 与本机 loopback HTTP - 修复 Tauri WebView 请求传输与无效 URLPattern - 按服务器 origin 隔离本机 External Editor 凭据 - 保留登录请求底层错误并补充路由与凭据隔离测试
390 lines
12 KiB
Rust
390 lines
12 KiB
Rust
use std::sync::{Mutex, OnceLock};
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub(crate) struct PlatformSessionSnapshot {
|
|
pub(crate) user_id: String,
|
|
pub(crate) access_token: String,
|
|
pub(crate) api_base_url: String,
|
|
pub(crate) generation: u64,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub(crate) enum EditorApiMode {
|
|
PlatformAccount,
|
|
ExternalDeveloper,
|
|
}
|
|
|
|
pub(crate) fn editor_api_mode_for_build(
|
|
debug_assertions: bool,
|
|
game_chat_release_feature: bool,
|
|
) -> EditorApiMode {
|
|
if !debug_assertions && game_chat_release_feature {
|
|
EditorApiMode::ExternalDeveloper
|
|
} else {
|
|
EditorApiMode::PlatformAccount
|
|
}
|
|
}
|
|
|
|
pub(crate) fn editor_api_mode() -> EditorApiMode {
|
|
editor_api_mode_for_build(cfg!(debug_assertions), cfg!(feature = "game-chat-release"))
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct PlatformSessionState {
|
|
generation: u64,
|
|
snapshot: Option<PlatformSessionSnapshot>,
|
|
}
|
|
|
|
static PLATFORM_SESSION: OnceLock<Mutex<PlatformSessionState>> = OnceLock::new();
|
|
|
|
fn platform_session() -> &'static Mutex<PlatformSessionState> {
|
|
PLATFORM_SESSION.get_or_init(|| Mutex::new(PlatformSessionState::default()))
|
|
}
|
|
|
|
fn install_platform_session_in(
|
|
current: &mut PlatformSessionState,
|
|
user_id: &str,
|
|
access_token: &str,
|
|
api_base_url: &str,
|
|
generation: u64,
|
|
) {
|
|
if generation < current.generation {
|
|
return;
|
|
}
|
|
if generation == current.generation {
|
|
if current.snapshot.as_ref().is_some_and(|snapshot| {
|
|
snapshot.user_id == user_id
|
|
&& snapshot.access_token == access_token
|
|
&& snapshot.api_base_url == api_base_url
|
|
}) {
|
|
return;
|
|
}
|
|
// Equal-generation retries may only repeat the exact committed snapshot. In
|
|
// particular, a late install cannot revive a generation that was cleared.
|
|
return;
|
|
}
|
|
current.generation = generation;
|
|
current.snapshot = Some(PlatformSessionSnapshot {
|
|
user_id: user_id.to_string(),
|
|
access_token: access_token.to_string(),
|
|
api_base_url: api_base_url.to_string(),
|
|
generation,
|
|
});
|
|
}
|
|
|
|
fn clear_platform_session_in(current: &mut PlatformSessionState, generation: u64) {
|
|
if generation < current.generation {
|
|
return;
|
|
}
|
|
current.generation = generation;
|
|
current.snapshot = None;
|
|
}
|
|
|
|
pub(crate) fn install_platform_session(
|
|
user_id: &str,
|
|
access_token: &str,
|
|
api_base_url: &str,
|
|
generation: u64,
|
|
) -> Result<(), String> {
|
|
if editor_api_mode() == EditorApiMode::ExternalDeveloper {
|
|
return Err("独立 game-chat 高级模式不接受陶泥儿网站登录态".to_string());
|
|
}
|
|
let user_id = user_id.trim();
|
|
let access_token = access_token.trim();
|
|
let api_base_url = normalize_platform_api_base_url(api_base_url)?;
|
|
if user_id.is_empty() || user_id.len() > 256 {
|
|
return Err("陶泥儿登录用户身份无效".to_string());
|
|
}
|
|
if access_token.is_empty() || access_token.len() > 16 * 1024 {
|
|
return Err("陶泥儿登录凭据无效".to_string());
|
|
}
|
|
let mut current = platform_session()
|
|
.lock()
|
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
install_platform_session_in(
|
|
&mut current,
|
|
user_id,
|
|
access_token,
|
|
&api_base_url,
|
|
generation,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn normalize_platform_api_base_url(value: &str) -> Result<String, String> {
|
|
let value = value.trim().trim_end_matches('/');
|
|
let parsed = url::Url::parse(value).map_err(|_| "陶泥儿服务地址无效".to_string())?;
|
|
if !matches!(parsed.scheme(), "http" | "https")
|
|
|| !parsed.username().is_empty()
|
|
|| parsed.password().is_some()
|
|
|| parsed.query().is_some()
|
|
|| parsed.fragment().is_some()
|
|
|| parsed.path() != "/"
|
|
{
|
|
return Err("陶泥儿服务地址必须是纯 HTTP(S) origin".to_string());
|
|
}
|
|
let host = parsed
|
|
.host_str()
|
|
.ok_or_else(|| "陶泥儿服务地址缺少 host".to_string())?;
|
|
let loopback = host == "localhost"
|
|
|| host == "127.0.0.1"
|
|
|| host
|
|
.parse::<std::net::IpAddr>()
|
|
.is_ok_and(|ip| ip.is_loopback());
|
|
if parsed.scheme() == "http" && !loopback {
|
|
return Err("陶泥儿服务地址不在受信任白名单内".to_string());
|
|
}
|
|
Ok(value.to_string())
|
|
}
|
|
|
|
pub(crate) fn clear_platform_session(generation: u64) {
|
|
let mut current = platform_session()
|
|
.lock()
|
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
clear_platform_session_in(&mut current, generation);
|
|
}
|
|
|
|
pub(crate) fn current_platform_session() -> Option<PlatformSessionSnapshot> {
|
|
platform_session()
|
|
.lock()
|
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
|
.snapshot
|
|
.clone()
|
|
}
|
|
|
|
pub(crate) fn validate_platform_session_snapshot(
|
|
expected: &PlatformSessionSnapshot,
|
|
) -> Result<(), String> {
|
|
if platform_session_snapshot_matches(current_platform_session().as_ref(), expected) {
|
|
Ok(())
|
|
} else {
|
|
Err(
|
|
"authentication-required: 陶泥儿登录态已变化,旧账号请求已停止,请使用当前账号重试"
|
|
.to_string(),
|
|
)
|
|
}
|
|
}
|
|
|
|
fn platform_session_snapshot_matches(
|
|
current: Option<&PlatformSessionSnapshot>,
|
|
expected: &PlatformSessionSnapshot,
|
|
) -> bool {
|
|
current == Some(expected)
|
|
}
|
|
|
|
pub(crate) fn platform_session_is_available() -> bool {
|
|
current_platform_session().is_some()
|
|
}
|
|
|
|
pub(crate) fn platform_session_service_identity() -> Option<String> {
|
|
current_platform_session()
|
|
.map(|snapshot| format!("{}\nuser:{}", snapshot.api_base_url, snapshot.user_id))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
static PLATFORM_SESSION_TEST_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
|
|
|
#[cfg(test)]
|
|
pub(crate) struct TestPlatformSessionGuard {
|
|
_isolation: std::sync::MutexGuard<'static, ()>,
|
|
previous: Option<PlatformSessionState>,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
impl Drop for TestPlatformSessionGuard {
|
|
fn drop(&mut self) {
|
|
let mut current = platform_session()
|
|
.lock()
|
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
*current = self
|
|
.previous
|
|
.take()
|
|
.expect("platform session test guard must restore its prior state");
|
|
}
|
|
}
|
|
|
|
/// Installs a scoped platform-account session for tests that must exercise the
|
|
/// production-default client authentication path. The previous process state
|
|
/// is restored on drop so the fixture cannot leak credentials or account
|
|
/// identity to another test.
|
|
#[cfg(test)]
|
|
pub(crate) fn install_test_platform_session(
|
|
user_id: &str,
|
|
access_token: &str,
|
|
api_base_url: &str,
|
|
) -> TestPlatformSessionGuard {
|
|
let isolation = PLATFORM_SESSION_TEST_LOCK
|
|
.get_or_init(|| Mutex::new(()))
|
|
.lock()
|
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
let mut current = platform_session()
|
|
.lock()
|
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
let previous = std::mem::take(&mut *current);
|
|
*current = PlatformSessionState {
|
|
generation: 1,
|
|
snapshot: Some(PlatformSessionSnapshot {
|
|
user_id: user_id.to_string(),
|
|
access_token: access_token.to_string(),
|
|
api_base_url: api_base_url.to_string(),
|
|
generation: 1,
|
|
}),
|
|
};
|
|
drop(current);
|
|
TestPlatformSessionGuard {
|
|
_isolation: isolation,
|
|
previous: Some(previous),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn cleared_generation_rejects_late_install_and_older_clear() {
|
|
let mut state = PlatformSessionState::default();
|
|
install_platform_session_in(
|
|
&mut state,
|
|
"user-a",
|
|
"token-a",
|
|
"https://dev.genarrative.world",
|
|
1,
|
|
);
|
|
clear_platform_session_in(&mut state, 2);
|
|
install_platform_session_in(
|
|
&mut state,
|
|
"user-a",
|
|
"late-token-a",
|
|
"https://dev.genarrative.world",
|
|
1,
|
|
);
|
|
install_platform_session_in(
|
|
&mut state,
|
|
"user-a",
|
|
"same-generation-token",
|
|
"https://dev.genarrative.world",
|
|
2,
|
|
);
|
|
assert!(state.snapshot.is_none());
|
|
assert_eq!(state.generation, 2);
|
|
|
|
install_platform_session_in(
|
|
&mut state,
|
|
"user-b",
|
|
"token-b",
|
|
"https://dev.genarrative.world",
|
|
3,
|
|
);
|
|
clear_platform_session_in(&mut state, 2);
|
|
assert_eq!(
|
|
state.snapshot.as_ref().map(|value| value.user_id.as_str()),
|
|
Some("user-b")
|
|
);
|
|
assert_eq!(state.generation, 3);
|
|
}
|
|
|
|
#[test]
|
|
fn equal_generation_only_accepts_the_exact_idempotent_snapshot() {
|
|
let mut state = PlatformSessionState::default();
|
|
install_platform_session_in(
|
|
&mut state,
|
|
"user-a",
|
|
"token-a",
|
|
"https://dev.genarrative.world",
|
|
4,
|
|
);
|
|
install_platform_session_in(
|
|
&mut state,
|
|
"user-a",
|
|
"token-a",
|
|
"https://dev.genarrative.world",
|
|
4,
|
|
);
|
|
install_platform_session_in(
|
|
&mut state,
|
|
"user-b",
|
|
"token-b",
|
|
"https://dev.genarrative.world",
|
|
4,
|
|
);
|
|
assert_eq!(
|
|
state.snapshot.as_ref().map(|value| value.user_id.as_str()),
|
|
Some("user-a")
|
|
);
|
|
assert_eq!(
|
|
state
|
|
.snapshot
|
|
.as_ref()
|
|
.map(|value| value.access_token.as_str()),
|
|
Some("token-a")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn editor_api_mode_is_fixed_by_the_trusted_build_flavor() {
|
|
assert_eq!(
|
|
editor_api_mode_for_build(true, false),
|
|
EditorApiMode::PlatformAccount
|
|
);
|
|
assert_eq!(
|
|
editor_api_mode_for_build(true, true),
|
|
EditorApiMode::PlatformAccount
|
|
);
|
|
assert_eq!(
|
|
editor_api_mode_for_build(false, false),
|
|
EditorApiMode::PlatformAccount
|
|
);
|
|
assert_eq!(
|
|
editor_api_mode_for_build(false, true),
|
|
EditorApiMode::ExternalDeveloper
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn custom_server_selection_accepts_https_origins_and_rejects_plain_http() {
|
|
assert_eq!(
|
|
normalize_platform_api_base_url("https://staging.example.com/"),
|
|
Ok("https://staging.example.com".to_string())
|
|
);
|
|
assert_eq!(
|
|
normalize_platform_api_base_url("http://127.0.0.1:8080/"),
|
|
Ok("http://127.0.0.1:8080".to_string())
|
|
);
|
|
assert!(normalize_platform_api_base_url("http://staging.example.com").is_err());
|
|
assert!(normalize_platform_api_base_url("https://staging.example.com/api").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn frozen_platform_session_rejects_logout_account_switch_and_token_rotation() {
|
|
let expected = PlatformSessionSnapshot {
|
|
user_id: "user-a".to_string(),
|
|
access_token: "token-a".to_string(),
|
|
api_base_url: "https://dev.genarrative.world".to_string(),
|
|
generation: 4,
|
|
};
|
|
assert!(platform_session_snapshot_matches(
|
|
Some(&expected),
|
|
&expected
|
|
));
|
|
|
|
for current in [
|
|
None,
|
|
Some(PlatformSessionSnapshot {
|
|
user_id: "user-b".to_string(),
|
|
..expected.clone()
|
|
}),
|
|
Some(PlatformSessionSnapshot {
|
|
access_token: "token-b".to_string(),
|
|
generation: 5,
|
|
..expected.clone()
|
|
}),
|
|
] {
|
|
assert!(!platform_session_snapshot_matches(
|
|
current.as_ref(),
|
|
&expected
|
|
));
|
|
}
|
|
}
|
|
}
|