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
+84 -30
View File
@@ -708,22 +708,25 @@ struct LoginAuditEvent<'a> {
async fn audit_user_login(state: &AppState, audit: LoginAuditEvent<'_>) { async fn audit_user_login(state: &AppState, audit: LoginAuditEvent<'_>) {
let event_type = "user_login"; let event_type = "user_login";
let level = if audit.success { "info" } else { "warning" }; let level = if audit.success { "info" } else { "warning" };
let account_label = display_user_for_audit(audit.user, audit.identifier);
let title = if audit.success { let title = if audit.success {
format!("用户登录成功:{}", display_user_for_audit(audit.user, audit.identifier)) format!("用户登录成功:{account_label}")
} else { } else {
format!("用户登录失败:{}", display_user_for_audit(audit.user, audit.identifier)) format!("用户登录失败:{account_label}")
}; };
let mut message_parts = vec![ let mut message_parts = Vec::new();
format!("方式={}", audit.method.label()),
format!("IP={}", audit.client_ip),
];
if let Some(user) = audit.user { 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!("用户ID={}", user.id));
message_parts.push(format!("邮箱={}", user.email));
} else if let Some(identifier) = audit.identifier { } else if let Some(identifier) = audit.identifier {
message_parts.push(format!("标识={}", 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 { if let Some(api_key) = audit.api_key {
message_parts.push(format!("密钥={}", api_key.name)); message_parts.push(format!("密钥={}", api_key.name));
message_parts.push(format!("密钥ID={}", api_key.id)); 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 { fn display_user_for_audit(user: Option<&User>, identifier: Option<&str>) -> String {
if let Some(user) = user { if let Some(user) = user {
if !user.email.trim().is_empty() { return user_account_label_for_audit(user);
return user.email.clone();
}
if !user.username.trim().is_empty() {
return user.username.clone();
}
return format!("用户 #{}", user.id);
} }
identifier identifier
.map(str::trim) .map(str::trim)
@@ -783,6 +780,17 @@ fn display_user_for_audit(user: Option<&User>, identifier: Option<&str>) -> Stri
.to_string() .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> { fn user_agent_from_headers(headers: &HeaderMap) -> Option<String> {
headers headers
.get(header::USER_AGENT) .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 { fn client_ip_from_headers(headers: &HeaderMap, fallback: SocketAddr) -> String {
let mut candidates = Vec::new();
for name in [ for name in [
"cf-connecting-ip", "cf-connecting-ip",
"x-real-ip", "x-real-ip",
"x-forwarded-for", "x-forwarded-for",
"forwarded", "forwarded",
] { ] {
if let Some(value) = header_ip_value(headers, name) { candidates.extend(header_ip_values(headers, name));
return value;
} }
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() fallback.ip().to_string()
} }
fn header_ip_value(headers: &HeaderMap, name: &str) -> Option<String> { fn is_ipv4_address(value: &str) -> bool {
let raw = headers.get(name)?.to_str().ok()?.trim(); matches!(value.parse::<IpAddr>(), Ok(IpAddr::V4(_)))
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 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(',') raw.split(',')
.flat_map(|entry| entry.split(';')) .flat_map(|entry| entry.split(';'))
.filter_map(|part| { .filter_map(|part| {
@@ -829,7 +847,7 @@ fn forwarded_header_ip(raw: &str) -> Option<String> {
} }
normalize_ip_token(value) normalize_ip_token(value)
}) })
.next() .collect()
} }
fn normalize_ip_token(raw: &str) -> Option<String> { fn normalize_ip_token(raw: &str) -> Option<String> {
@@ -1000,6 +1018,7 @@ mod tests {
template_proxy_url: None, template_proxy_url: None,
}, },
comfyui: Default::default(), comfyui: Default::default(),
payment: Default::default(),
}, },
pool, pool,
BuiltinKeys { BuiltinKeys {
@@ -1131,7 +1150,9 @@ mod tests {
let (event_type, title, message, related_id, related_type) = latest_audit_event(&state).await; let (event_type, title, message, related_id, related_type) = latest_audit_event(&state).await;
assert_eq!(event_type, "user_login"); assert_eq!(event_type, "user_login");
assert!(title.contains("用户登录成功")); 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("方式=password"));
assert!(message.contains("IP=203.0.113.8")); assert!(message.contains("IP=203.0.113.8"));
assert_eq!(related_id, user_id); 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; let (event_type, title, message, related_id, related_type) = latest_audit_event(&state).await;
assert_eq!(event_type, "user_login"); assert_eq!(event_type, "user_login");
assert!(title.contains("用户登录成功")); 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("方式=api_key"));
assert!(message.contains("IP=198.51.100.24")); assert!(message.contains("IP=198.51.100.24"));
assert!(message.contains("密钥=Mobile Login Key")); assert!(message.contains("密钥=Mobile Login Key"));
assert_eq!(related_id, user_id); assert_eq!(related_id, user_id);
assert_eq!(related_type, "user"); 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}");
}
} }
+41 -2
View File
@@ -95,7 +95,7 @@ describe('ProgramLogs status label', () => {
expect(targetRow).toBeTruthy(); expect(targetRow).toBeTruthy();
const tds = targetRow!.findAll((node) => node.type === 'td'); const tds = targetRow!.findAll((node) => node.type === 'td');
const statusCell = tds[5]; const statusCell = tds[7];
expect(collectText(statusCell).trim()).toBe('成功'); expect(collectText(statusCell).trim()).toBe('成功');
const statusBadge = statusCell.find((node) => node.type === 'span'); const statusBadge = statusCell.find((node) => node.type === 'span');
expect(String(statusBadge.props.className || '')).toContain('badge-success'); expect(String(statusBadge.props.className || '')).toContain('badge-success');
@@ -131,12 +131,51 @@ describe('ProgramLogs status label', () => {
expect(targetRow).toBeTruthy(); expect(targetRow).toBeTruthy();
const tds = targetRow!.findAll((node) => node.type === 'td'); const tds = targetRow!.findAll((node) => node.type === 'td');
const statusCell = tds[5]; const statusCell = tds[7];
expect(collectText(statusCell).trim()).toBe('成功'); expect(collectText(statusCell).trim()).toBe('成功');
const statusBadge = statusCell.find((node) => node.type === 'span'); const statusBadge = statusCell.find((node) => node.type === 'span');
expect(String(statusBadge.props.className || '')).toContain('badge-success'); 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 () => { it('opens related trace and route targets from desktop rows', async () => {
apiMock.getEvents.mockResolvedValue([ apiMock.getEvents.mockResolvedValue([
{ {
+18
View File
@@ -16,6 +16,8 @@ type ProgramEvent = {
type: string; type: string;
title: string; title: string;
message?: string | null; message?: string | null;
account?: string | null;
clientIp?: string | null;
level: 'info' | 'warning' | 'error'; level: 'info' | 'warning' | 'error';
read: boolean; read: boolean;
relatedId?: number | null; 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 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 ${level.cls}`} style={{ fontSize: 11 }}>{level.label}</span>} />
<MobileField label="状态" value={<span className={`badge ${eventStatus.cls}`} style={{ fontSize: 11 }}>{eventStatus.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 /> <MobileField label="内容" value={row.message || '-'} stacked />
</MobileCard> </MobileCard>
); );
@@ -482,6 +490,8 @@ export default function ProgramLogs() {
<col style={{ width: 90 }} /> <col style={{ width: 90 }} />
<col style={{ width: 90 }} /> <col style={{ width: 90 }} />
<col style={{ width: 260 }} /> <col style={{ width: 260 }} />
<col style={{ width: 220 }} />
<col style={{ width: 150 }} />
<col /> <col />
<col style={{ width: 110 }} /> <col style={{ width: 110 }} />
<col style={{ width: 180 }} /> <col style={{ width: 180 }} />
@@ -492,6 +502,8 @@ export default function ProgramLogs() {
<th>类型</th> <th>类型</th>
<th>级别</th> <th>级别</th>
<th>标题</th> <th>标题</th>
<th>账号</th>
<th>IPv4/IP</th>
<th>内容</th> <th>内容</th>
<th>状态</th> <th>状态</th>
<th style={{ textAlign: 'right' }}>操作</th> <th style={{ textAlign: 'right' }}>操作</th>
@@ -519,6 +531,12 @@ export default function ProgramLogs() {
<td className="program-logs-title-cell"> <td className="program-logs-title-cell">
{row.title || '-'} {row.title || '-'}
</td> </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"> <td className="program-logs-content-cell">
{row.message || '-'} {row.message || '-'}
</td> </td>