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, pub device_fingerprint: Option, pub device_display_name: String, pub mini_program_app_id: Option, pub mini_program_env: Option, pub user_agent: Option, pub ip: Option, } 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 { 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) -> Option { 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, ua_lower: &str) -> Option { 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, ua_lower: &str) -> Option { 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 { 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 { 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 { // 这里的指纹只用于会话聚类与展示,不参与任何鉴权决策。 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::>() .join(" ") .to_ascii_lowercase() } fn resolve_ip(headers: &HeaderMap) -> Option { 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 { // 会话列表只返回脱敏后的 IP,避免把完整地址直接暴露给前端。 let ip = ip?.trim(); if ip.is_empty() { return None; } if ip.contains(':') { let parts = ip .split(':') .filter(|part| !part.is_empty()) .collect::>(); if parts.len() <= 2 { return Some(ip.to_string()); } return Some(format!("{}:{}::*", parts[0], parts[1])); } let parts = ip.split('.').collect::>(); 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::*") ); } }