442 lines
15 KiB
Rust
442 lines
15 KiB
Rust
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::*")
|
||
);
|
||
}
|
||
}
|