feat(audit): login audit log with account info and IPv4
- 审计日志记录具体账号(用户名<邮箱>) - 优先显示 IPv4,保留 IPv6 - Program Logs 页面新增 account/IPv4 列
This commit is contained in:
+85
-31
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ describe('ProgramLogs status label', () => {
|
||||
expect(targetRow).toBeTruthy();
|
||||
|
||||
const tds = targetRow!.findAll((node) => node.type === 'td');
|
||||
const statusCell = tds[5];
|
||||
const statusCell = tds[7];
|
||||
expect(collectText(statusCell).trim()).toBe('成功');
|
||||
const statusBadge = statusCell.find((node) => node.type === 'span');
|
||||
expect(String(statusBadge.props.className || '')).toContain('badge-success');
|
||||
@@ -131,12 +131,51 @@ describe('ProgramLogs status label', () => {
|
||||
expect(targetRow).toBeTruthy();
|
||||
|
||||
const tds = targetRow!.findAll((node) => node.type === 'td');
|
||||
const statusCell = tds[5];
|
||||
const statusCell = tds[7];
|
||||
expect(collectText(statusCell).trim()).toBe('成功');
|
||||
const statusBadge = statusCell.find((node) => node.type === 'span');
|
||||
expect(String(statusBadge.props.className || '')).toContain('badge-success');
|
||||
});
|
||||
|
||||
it('shows login account and client IP as first-class columns', async () => {
|
||||
apiMock.getEvents.mockResolvedValue([
|
||||
{
|
||||
id: 8,
|
||||
type: 'user_login',
|
||||
title: '用户登录成功:姜先生 <user@example.test>',
|
||||
account: '姜先生 <user@example.test>',
|
||||
clientIp: '203.0.113.8',
|
||||
message: '账号=姜先生 <user@example.test> · 邮箱=user@example.test · 用户ID=12 · 方式=api_key · IP=203.0.113.8',
|
||||
level: 'info',
|
||||
read: false,
|
||||
relatedType: 'user',
|
||||
relatedId: 12,
|
||||
createdAt: '2026-06-10T08:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/events']}>
|
||||
<ToastProvider>
|
||||
<ProgramLogs />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const rows = root!.root.findAll((node) => node.type === 'tr');
|
||||
const targetRow = rows.find((row) => collectText(row).includes('用户登录成功'));
|
||||
expect(targetRow).toBeTruthy();
|
||||
|
||||
const tds = targetRow!.findAll((node) => node.type === 'td');
|
||||
expect(collectText(tds[4]!).trim()).toBe('姜先生 <user@example.test>');
|
||||
expect(collectText(tds[5]!).trim()).toBe('203.0.113.8');
|
||||
expect(collectText(targetRow!)).toContain('IP=203.0.113.8');
|
||||
});
|
||||
|
||||
it('opens related trace and route targets from desktop rows', async () => {
|
||||
apiMock.getEvents.mockResolvedValue([
|
||||
{
|
||||
|
||||
@@ -16,6 +16,8 @@ type ProgramEvent = {
|
||||
type: string;
|
||||
title: string;
|
||||
message?: string | null;
|
||||
account?: string | null;
|
||||
clientIp?: string | null;
|
||||
level: 'info' | 'warning' | 'error';
|
||||
read: boolean;
|
||||
relatedId?: number | null;
|
||||
@@ -462,6 +464,12 @@ export default function ProgramLogs() {
|
||||
<MobileField label="类型" value={<span className="badge badge-muted" style={{ fontSize: 11 }}>{typeLabel(row.type)}</span>} />
|
||||
<MobileField label="级别" value={<span className={`badge ${level.cls}`} style={{ fontSize: 11 }}>{level.label}</span>} />
|
||||
<MobileField label="状态" value={<span className={`badge ${eventStatus.cls}`} style={{ fontSize: 11 }}>{eventStatus.label}</span>} />
|
||||
{row.type === 'user_login' && (
|
||||
<>
|
||||
<MobileField label="账号" value={row.account || '-'} />
|
||||
<MobileField label="IPv4/IP" value={row.clientIp || '-'} />
|
||||
</>
|
||||
)}
|
||||
<MobileField label="内容" value={row.message || '-'} stacked />
|
||||
</MobileCard>
|
||||
);
|
||||
@@ -482,6 +490,8 @@ export default function ProgramLogs() {
|
||||
<col style={{ width: 90 }} />
|
||||
<col style={{ width: 90 }} />
|
||||
<col style={{ width: 260 }} />
|
||||
<col style={{ width: 220 }} />
|
||||
<col style={{ width: 150 }} />
|
||||
<col />
|
||||
<col style={{ width: 110 }} />
|
||||
<col style={{ width: 180 }} />
|
||||
@@ -492,6 +502,8 @@ export default function ProgramLogs() {
|
||||
<th>类型</th>
|
||||
<th>级别</th>
|
||||
<th>标题</th>
|
||||
<th>账号</th>
|
||||
<th>IPv4/IP</th>
|
||||
<th>内容</th>
|
||||
<th>状态</th>
|
||||
<th style={{ textAlign: 'right' }}>操作</th>
|
||||
@@ -519,6 +531,12 @@ export default function ProgramLogs() {
|
||||
<td className="program-logs-title-cell">
|
||||
{row.title || '-'}
|
||||
</td>
|
||||
<td className="program-logs-account-cell">
|
||||
{row.account || '-'}
|
||||
</td>
|
||||
<td className="program-logs-ip-cell">
|
||||
{row.clientIp || '-'}
|
||||
</td>
|
||||
<td className="program-logs-content-cell">
|
||||
{row.message || '-'}
|
||||
</td>
|
||||
|
||||
Reference in New Issue
Block a user