Files
Genarrative/server-rs/crates/api-server/src/session_client.rs
2026-04-22 12:34:49 +08:00

442 lines
15 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.
use axum::http::HeaderMap;
use module_auth::RefreshSessionClientInfo;
use platform_auth::hash_refresh_session_token;
use shared_kernel::normalize_optional_string;
const X_CLIENT_TYPE_HEADER: &str = "x-client-type";
const X_CLIENT_RUNTIME_HEADER: &str = "x-client-runtime";
const X_CLIENT_PLATFORM_HEADER: &str = "x-client-platform";
const X_CLIENT_INSTANCE_ID_HEADER: &str = "x-client-instance-id";
const X_MINI_PROGRAM_APP_ID_HEADER: &str = "x-mini-program-app-id";
const X_MINI_PROGRAM_ENV_HEADER: &str = "x-mini-program-env";
const USER_AGENT_HEADER: &str = "user-agent";
const X_FORWARDED_FOR_HEADER: &str = "x-forwarded-for";
const X_REAL_IP_HEADER: &str = "x-real-ip";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SessionClientContext {
// 统一保存登录时采集到的客户端身份快照,后续直接写入 refresh_session。
pub client_type: String,
pub client_runtime: String,
pub client_platform: String,
pub client_instance_id: Option<String>,
pub device_fingerprint: Option<String>,
pub device_display_name: String,
pub mini_program_app_id: Option<String>,
pub mini_program_env: Option<String>,
pub user_agent: Option<String>,
pub ip: Option<String>,
}
impl SessionClientContext {
pub fn to_refresh_session_client_info(&self) -> RefreshSessionClientInfo {
RefreshSessionClientInfo {
client_type: self.client_type.clone(),
client_runtime: self.client_runtime.clone(),
client_platform: self.client_platform.clone(),
client_instance_id: self.client_instance_id.clone(),
device_fingerprint: self.device_fingerprint.clone(),
device_display_name: self.device_display_name.clone(),
mini_program_app_id: self.mini_program_app_id.clone(),
mini_program_env: self.mini_program_env.clone(),
user_agent: self.user_agent.clone(),
ip: self.ip.clone(),
}
}
}
pub fn resolve_session_client_context(headers: &HeaderMap) -> SessionClientContext {
// 显式头优先UA 自动识别兜底,避免前端没有完全补头时整条登录链路不可用。
let user_agent = header_value(headers, USER_AGENT_HEADER);
let ua_lower = user_agent
.as_ref()
.map(|value| value.to_ascii_lowercase())
.unwrap_or_default();
let explicit_client_type = normalize_client_type(header_value(headers, X_CLIENT_TYPE_HEADER));
let explicit_client_runtime =
normalize_runtime(header_value(headers, X_CLIENT_RUNTIME_HEADER), &ua_lower);
let explicit_client_platform =
normalize_platform(header_value(headers, X_CLIENT_PLATFORM_HEADER), &ua_lower);
let client_instance_id =
normalize_optional_string(header_value(headers, X_CLIENT_INSTANCE_ID_HEADER));
let mini_program_app_id =
normalize_optional_string(header_value(headers, X_MINI_PROGRAM_APP_ID_HEADER));
let mini_program_env =
normalize_optional_string(header_value(headers, X_MINI_PROGRAM_ENV_HEADER));
let inferred_client_type = infer_client_type(explicit_client_type.as_deref(), &ua_lower);
let inferred_runtime = infer_client_runtime(
explicit_client_runtime.as_deref(),
&inferred_client_type,
&ua_lower,
);
let inferred_platform = infer_client_platform(explicit_client_platform.as_deref(), &ua_lower);
let ip = resolve_ip(headers);
let device_display_name =
build_device_display_name(&inferred_client_type, &inferred_runtime, &inferred_platform);
let device_fingerprint = build_device_fingerprint(
&inferred_client_type,
&inferred_runtime,
&inferred_platform,
client_instance_id.as_deref(),
user_agent.as_deref(),
);
SessionClientContext {
client_type: inferred_client_type,
client_runtime: inferred_runtime,
client_platform: inferred_platform,
client_instance_id,
device_fingerprint,
device_display_name,
mini_program_app_id,
mini_program_env,
user_agent: normalize_optional_string(user_agent),
ip,
}
}
fn header_value(headers: &HeaderMap, name: &str) -> Option<String> {
headers
.get(name)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn normalize_client_type(value: Option<String>) -> Option<String> {
value.and_then(|raw| {
let normalized = raw.trim().to_ascii_lowercase();
match normalized.as_str() {
"web_browser" | "wechat_h5" | "mini_program" | "native_app" | "desktop_app"
| "unknown" => Some(normalized),
_ => None,
}
})
}
fn normalize_runtime(value: Option<String>, ua_lower: &str) -> Option<String> {
value.and_then(|raw| {
let normalized = raw.trim().to_ascii_lowercase();
match normalized.as_str() {
"chrome"
| "edge"
| "safari"
| "firefox"
| "wechat_embedded_browser"
| "wechat_mini_program"
| "alipay_mini_program"
| "douyin_mini_program"
| "unknown" => Some(normalized),
_ => infer_runtime_from_user_agent(ua_lower),
}
})
}
fn normalize_platform(value: Option<String>, ua_lower: &str) -> Option<String> {
value.and_then(|raw| {
let normalized = raw.trim().to_ascii_lowercase();
match normalized.as_str() {
"windows" | "macos" | "linux" | "ios" | "android" | "unknown" => Some(normalized),
_ => infer_platform_from_user_agent(ua_lower),
}
})
}
fn infer_client_type(explicit_type: Option<&str>, ua_lower: &str) -> String {
if let Some(client_type) = explicit_type {
return client_type.to_string();
}
if ua_lower.contains("micromessenger") {
return "wechat_h5".to_string();
}
"web_browser".to_string()
}
fn infer_client_runtime(
explicit_runtime: Option<&str>,
client_type: &str,
ua_lower: &str,
) -> String {
if client_type == "mini_program" {
if let Some(runtime) = explicit_runtime {
return runtime.to_string();
}
if ua_lower.contains("alipayclient") {
return "alipay_mini_program".to_string();
}
if ua_lower.contains("toutiaomicroapp") || ua_lower.contains("douyin") {
return "douyin_mini_program".to_string();
}
return "wechat_mini_program".to_string();
}
if client_type == "wechat_h5" {
return "wechat_embedded_browser".to_string();
}
explicit_runtime
.map(ToOwned::to_owned)
.or_else(|| infer_runtime_from_user_agent(ua_lower))
.unwrap_or_else(|| "unknown".to_string())
}
fn infer_client_platform(explicit_platform: Option<&str>, ua_lower: &str) -> String {
explicit_platform
.map(ToOwned::to_owned)
.or_else(|| infer_platform_from_user_agent(ua_lower))
.unwrap_or_else(|| "unknown".to_string())
}
fn infer_runtime_from_user_agent(ua_lower: &str) -> Option<String> {
if ua_lower.contains("edg/") {
return Some("edge".to_string());
}
if ua_lower.contains("firefox/") {
return Some("firefox".to_string());
}
if ua_lower.contains("chrome/") || ua_lower.contains("crios/") {
return Some("chrome".to_string());
}
if ua_lower.contains("safari/") && !ua_lower.contains("chrome/") && !ua_lower.contains("crios/")
{
return Some("safari".to_string());
}
None
}
fn infer_platform_from_user_agent(ua_lower: &str) -> Option<String> {
if ua_lower.contains("iphone") || ua_lower.contains("ipad") || ua_lower.contains("ios") {
return Some("ios".to_string());
}
if ua_lower.contains("android") {
return Some("android".to_string());
}
if ua_lower.contains("windows") {
return Some("windows".to_string());
}
if ua_lower.contains("mac os") || ua_lower.contains("macintosh") {
return Some("macos".to_string());
}
if ua_lower.contains("linux") {
return Some("linux".to_string());
}
None
}
fn build_device_display_name(
client_type: &str,
client_runtime: &str,
client_platform: &str,
) -> String {
// 展示名固定由后端派生,避免前端上传自由文本导致同类设备标签漂移。
if client_type == "mini_program" {
return format!(
"{} / {}",
map_runtime_display(client_runtime),
map_platform_display(client_platform)
);
}
if client_type == "wechat_h5" {
return format!("微信内网页 / {}", map_platform_display(client_platform));
}
if client_type == "unknown" {
return "未知设备".to_string();
}
format!(
"{} / {}",
map_platform_display(client_platform),
map_runtime_display(client_runtime)
)
}
fn map_runtime_display(runtime: &str) -> &'static str {
match runtime {
"chrome" => "Chrome",
"edge" => "Edge",
"safari" => "Safari",
"firefox" => "Firefox",
"wechat_embedded_browser" => "微信内网页",
"wechat_mini_program" => "微信小程序",
"alipay_mini_program" => "支付宝小程序",
"douyin_mini_program" => "抖音小程序",
_ => "未知客户端",
}
}
fn map_platform_display(platform: &str) -> &'static str {
match platform {
"windows" => "Windows",
"macos" => "macOS",
"linux" => "Linux",
"ios" => "iPhone",
"android" => "Android",
_ => "未知设备",
}
}
fn build_device_fingerprint(
client_type: &str,
client_runtime: &str,
client_platform: &str,
client_instance_id: Option<&str>,
user_agent: Option<&str>,
) -> Option<String> {
// 这里的指纹只用于会话聚类与展示,不参与任何鉴权决策。
let seed = if let Some(instance_id) = client_instance_id {
format!("{client_type}|{client_runtime}|{client_platform}|{instance_id}")
} else if let Some(user_agent) = user_agent {
format!(
"{client_type}|{client_runtime}|{client_platform}|{}",
normalize_user_agent(user_agent)
)
} else {
return None;
};
Some(hash_refresh_session_token(&seed))
}
fn normalize_user_agent(user_agent: &str) -> String {
user_agent
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_ascii_lowercase()
}
fn resolve_ip(headers: &HeaderMap) -> Option<String> {
if let Some(forwarded) = header_value(headers, X_FORWARDED_FOR_HEADER) {
let ip = forwarded
.split(',')
.map(str::trim)
.find(|value| !value.is_empty())
.map(ToOwned::to_owned);
if ip.is_some() {
return ip;
}
}
normalize_optional_string(header_value(headers, X_REAL_IP_HEADER))
}
pub fn mask_ip(ip: Option<&str>) -> Option<String> {
// 会话列表只返回脱敏后的 IP避免把完整地址直接暴露给前端。
let ip = ip?.trim();
if ip.is_empty() {
return None;
}
if ip.contains(':') {
let parts = ip
.split(':')
.filter(|part| !part.is_empty())
.collect::<Vec<_>>();
if parts.len() <= 2 {
return Some(ip.to_string());
}
return Some(format!("{}:{}::*", parts[0], parts[1]));
}
let parts = ip.split('.').collect::<Vec<_>>();
if parts.len() != 4 {
return Some(ip.to_string());
}
Some(format!("{}.{}.*.*", parts[0], parts[1]))
}
#[cfg(test)]
mod tests {
use axum::http::{HeaderMap, HeaderValue};
use super::{mask_ip, resolve_session_client_context};
#[test]
fn resolve_session_client_context_detects_wechat_h5_from_user_agent() {
let mut headers = HeaderMap::new();
headers.insert(
"user-agent",
HeaderValue::from_static(
"Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit Safari MicroMessenger",
),
);
let context = resolve_session_client_context(&headers);
assert_eq!(context.client_type, "wechat_h5");
assert_eq!(context.client_runtime, "wechat_embedded_browser");
assert_eq!(context.client_platform, "ios");
assert_eq!(context.device_display_name, "微信内网页 / iPhone");
}
#[test]
fn resolve_session_client_context_prefers_explicit_mini_program_headers() {
let mut headers = HeaderMap::new();
headers.insert("x-client-type", HeaderValue::from_static("mini_program"));
headers.insert(
"x-client-runtime",
HeaderValue::from_static("wechat_mini_program"),
);
headers.insert("x-client-platform", HeaderValue::from_static("android"));
headers.insert(
"x-client-instance-id",
HeaderValue::from_static("mini-instance-001"),
);
headers.insert(
"x-mini-program-app-id",
HeaderValue::from_static("wx1234567890"),
);
headers.insert("x-mini-program-env", HeaderValue::from_static("release"));
headers.insert(
"user-agent",
HeaderValue::from_static("Mozilla/5.0 Chrome/123.0 MicroMessenger"),
);
let context = resolve_session_client_context(&headers);
assert_eq!(context.client_type, "mini_program");
assert_eq!(context.client_runtime, "wechat_mini_program");
assert_eq!(context.client_platform, "android");
assert_eq!(context.mini_program_app_id.as_deref(), Some("wx1234567890"));
assert_eq!(context.mini_program_env.as_deref(), Some("release"));
assert_eq!(context.device_display_name, "微信小程序 / Android");
assert!(context.device_fingerprint.is_some());
}
#[test]
fn resolve_session_client_context_distinguishes_web_browser_runtime() {
let mut headers = HeaderMap::new();
headers.insert(
"user-agent",
HeaderValue::from_static(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36",
),
);
headers.insert("x-forwarded-for", HeaderValue::from_static("203.0.113.11"));
let context = resolve_session_client_context(&headers);
assert_eq!(context.client_type, "web_browser");
assert_eq!(context.client_runtime, "chrome");
assert_eq!(context.client_platform, "windows");
assert_eq!(context.ip.as_deref(), Some("203.0.113.11"));
assert_eq!(context.device_display_name, "Windows / Chrome");
}
#[test]
fn mask_ip_returns_masked_ipv4_and_ipv6() {
assert_eq!(mask_ip(Some("203.0.113.11")).as_deref(), Some("203.0.*.*"));
assert_eq!(
mask_ip(Some("2408:8000:abcd:1234::1")).as_deref(),
Some("2408:8000::*")
);
}
}