feat(audit): login audit log with account info and IPv4
- 审计日志记录具体账号(用户名<邮箱>) - 优先显示 IPv4,保留 IPv6 - Program Logs 页面新增 account/IPv4 列
This commit is contained in:
+84
-30
@@ -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}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user