feat(audit): login audit log with account info and IPv4

- 审计日志记录具体账号(用户名<邮箱>)
- 优先显示 IPv4,保留 IPv6
- Program Logs 页面新增 account/IPv4 列
This commit is contained in:
boos
2026-06-10 19:54:32 +08:00
parent d1f3f4e93d
commit d697146941
3 changed files with 144 additions and 33 deletions
+85 -31
View File
@@ -708,22 +708,25 @@ struct LoginAuditEvent<'a> {
async fn audit_user_login(state: &AppState, audit: LoginAuditEvent<'_>) {
let event_type = "user_login";
let level = if audit.success { "info" } else { "warning" };
let account_label = display_user_for_audit(audit.user, audit.identifier);
let title = if audit.success {
format!("用户登录成功:{}", display_user_for_audit(audit.user, audit.identifier))
format!("用户登录成功:{account_label}")
} else {
format!("用户登录失败:{}", display_user_for_audit(audit.user, audit.identifier))
format!("用户登录失败:{account_label}")
};
let mut message_parts = vec![
format!("方式={}", audit.method.label()),
format!("IP={}", audit.client_ip),
];
let mut message_parts = Vec::new();
if let Some(user) = audit.user {
message_parts.push(format!("账号={}", user_account_label_for_audit(user)));
if !user.email.trim().is_empty() {
message_parts.push(format!("邮箱={}", user.email.trim()));
}
message_parts.push(format!("用户ID={}", user.id));
message_parts.push(format!("邮箱={}", user.email));
} else if let Some(identifier) = audit.identifier {
message_parts.push(format!("标识={}", identifier));
}
message_parts.push(format!("方式={}", audit.method.label()));
message_parts.push(format!("IP={}", audit.client_ip));
if let Some(api_key) = audit.api_key {
message_parts.push(format!("密钥={}", api_key.name));
message_parts.push(format!("密钥ID={}", api_key.id));
@@ -768,13 +771,7 @@ async fn audit_user_login(state: &AppState, audit: LoginAuditEvent<'_>) {
fn display_user_for_audit(user: Option<&User>, identifier: Option<&str>) -> String {
if let Some(user) = user {
if !user.email.trim().is_empty() {
return user.email.clone();
}
if !user.username.trim().is_empty() {
return user.username.clone();
}
return format!("用户 #{}", user.id);
return user_account_label_for_audit(user);
}
identifier
.map(str::trim)
@@ -783,6 +780,17 @@ fn display_user_for_audit(user: Option<&User>, identifier: Option<&str>) -> Stri
.to_string()
}
fn user_account_label_for_audit(user: &User) -> String {
let username = user.username.trim();
let email = user.email.trim();
match (username.is_empty(), email.is_empty(), username == email) {
(false, false, false) => format!("{username} <{email}>"),
(false, _, _) => username.to_string(),
(_, false, _) => email.to_string(),
_ => format!("用户 #{}", user.id),
}
}
fn user_agent_from_headers(headers: &HeaderMap) -> Option<String> {
headers
.get(header::USER_AGENT)
@@ -793,33 +801,43 @@ fn user_agent_from_headers(headers: &HeaderMap) -> Option<String> {
}
fn client_ip_from_headers(headers: &HeaderMap, fallback: SocketAddr) -> String {
let mut candidates = Vec::new();
for name in [
"cf-connecting-ip",
"x-real-ip",
"x-forwarded-for",
"forwarded",
] {
if let Some(value) = header_ip_value(headers, name) {
return value;
}
candidates.extend(header_ip_values(headers, name));
}
if let Some(ipv4) = candidates.iter().find(|value| is_ipv4_address(value)) {
return ipv4.clone();
}
if let Some(first) = candidates.into_iter().next() {
return first;
}
fallback.ip().to_string()
}
fn header_ip_value(headers: &HeaderMap, name: &str) -> Option<String> {
let raw = headers.get(name)?.to_str().ok()?.trim();
if raw.is_empty() {
return None;
}
if name.eq_ignore_ascii_case("forwarded") {
return forwarded_header_ip(raw);
}
raw.split(',')
.filter_map(normalize_ip_token)
.next()
fn is_ipv4_address(value: &str) -> bool {
matches!(value.parse::<IpAddr>(), Ok(IpAddr::V4(_)))
}
fn forwarded_header_ip(raw: &str) -> Option<String> {
fn header_ip_values(headers: &HeaderMap, name: &str) -> Vec<String> {
let Some(raw) = headers.get(name).and_then(|value| value.to_str().ok()) else {
return Vec::new();
};
let raw = raw.trim();
if raw.is_empty() {
return Vec::new();
}
if name.eq_ignore_ascii_case("forwarded") {
return forwarded_header_ips(raw);
}
raw.split(',').filter_map(normalize_ip_token).collect()
}
fn forwarded_header_ips(raw: &str) -> Vec<String> {
raw.split(',')
.flat_map(|entry| entry.split(';'))
.filter_map(|part| {
@@ -829,7 +847,7 @@ fn forwarded_header_ip(raw: &str) -> Option<String> {
}
normalize_ip_token(value)
})
.next()
.collect()
}
fn normalize_ip_token(raw: &str) -> Option<String> {
@@ -1000,6 +1018,7 @@ mod tests {
template_proxy_url: None,
},
comfyui: Default::default(),
payment: Default::default(),
},
pool,
BuiltinKeys {
@@ -1131,7 +1150,9 @@ mod tests {
let (event_type, title, message, related_id, related_type) = latest_audit_event(&state).await;
assert_eq!(event_type, "user_login");
assert!(title.contains("用户登录成功"));
assert!(title.contains("audit@example.test"));
assert!(title.contains("Regular User <audit@example.test>"));
assert!(message.starts_with("账号=Regular User <audit@example.test>"));
assert!(message.contains("邮箱=audit@example.test"));
assert!(message.contains("方式=password"));
assert!(message.contains("IP=203.0.113.8"));
assert_eq!(related_id, user_id);
@@ -1315,10 +1336,43 @@ mod tests {
let (event_type, title, message, related_id, related_type) = latest_audit_event(&state).await;
assert_eq!(event_type, "user_login");
assert!(title.contains("用户登录成功"));
assert!(title.contains("Regular User <key-audit@example.test>"));
assert!(message.starts_with("账号=Regular User <key-audit@example.test>"));
assert!(message.contains("方式=api_key"));
assert!(message.contains("IP=198.51.100.24"));
assert!(message.contains("密钥=Mobile Login Key"));
assert_eq!(related_id, user_id);
assert_eq!(related_type, "user");
}
#[tokio::test]
async fn login_audit_prefers_ipv4_from_proxy_headers() {
let state = test_state().await;
let password_hash = hash_password("user-password").expect("password hash");
sqlx::query(
"INSERT INTO users (username, email, password_hash, role, status)
VALUES (?, ?, ?, 'user', 'active')",
)
.bind("IPv4 User")
.bind("ipv4@example.test")
.bind(password_hash)
.execute(&*state.db)
.await
.expect("insert user");
let Json(_) = login(
State(state.clone()),
test_addr(),
headers_with("x-forwarded-for", "2a09:bac2:3918:16dc::247:4d, 203.0.113.9"),
Json(LoginRequest {
email: "ipv4@example.test".into(),
password: "user-password".into(),
}),
)
.await
.expect("regular user password login succeeds");
let (_, _, message, _, _) = latest_audit_event(&state).await;
assert!(message.contains("IP=203.0.113.9"), "{message}");
}
}