安全审计与日志
概述
安全审计是企业合规的刚需。Spring Security 提供了完善的事件机制,可以捕获认证成功/失败、授权失败等安全事件,配合 AOP 可以实现方法级的操作审计。
审计要素(WHO / WHEN / WHAT / HOW)
| 要素 | 说明 | 数据来源 |
|---|---|---|
| WHO | 操作主体 | SecurityContextHolder |
| WHEN | 操作时间 | 系统时间 |
| WHAT | 操作内容 | 请求 URI / 方法名 / 参数 |
| HOW | 操作结果 | 成功/失败/异常 |
一、Spring Security 事件体系
1.1 事件类型
| 事件 | 触发时机 |
|---|---|
AuthenticationSuccessEvent | 认证成功 |
AbstractAuthenticationFailureEvent | 认证失败 |
AuthenticationFailureBadCredentialsEvent | 密码错误 |
AuthenticationFailureLockedEvent | 账户锁定 |
AuthenticationFailureDisabledEvent | 账户禁用 |
AuthenticationFailureExpiredEvent | 账户过期 |
AuthorizationFailureEvent | 授权失败 |
LogoutSuccessEvent | 登出成功 |
SessionFixationProtectionEvent | Session 固定攻击防护触发 |
1.2 事件监听器
java
@Component
public class SecurityAuditListener {
private static final Logger log = LoggerFactory.getLogger("SECURITY_AUDIT");
@EventListener
public void onAuthenticationSuccess(AuthenticationSuccessEvent event) {
Authentication auth = event.getAuthentication();
auditLog("LOGIN_SUCCESS", auth.getName(), "Authentication successful");
}
@EventListener
public void onAuthenticationFailure(AbstractAuthenticationFailureEvent event) {
Authentication auth = event.getAuthentication();
String error = event.getException().getClass().getSimpleName();
auditLog("LOGIN_FAILURE", auth.getName(), "Error: " + error);
}
@EventListener
public void onAuthorizationFailure(AuthorizationFailureEvent event) {
Authentication auth = event.getAuthentication();
String requestUri = extractRequestUri(event);
auditLog("ACCESS_DENIED",
auth != null ? auth.getName() : "anonymous",
"Access denied for: " + requestUri);
}
@EventListener
public void onLogoutSuccess(LogoutSuccessEvent event) {
Authentication auth = event.getAuthentication();
auditLog("LOGOUT", auth.getName(), "User logged out");
}
}1.3 多监听器场景
java
// 分层处理:不同级别的审计使用不同监听器
@Component
@Order(1)
public class MetricsAuditListener {
@EventListener
public void onSuccess(AuthenticationSuccessEvent event) {
// 记录到 Prometheus 指标
loginCounter.increment();
}
}
@Component
@Order(2)
public class DatabaseAuditListener {
@EventListener
public void onSuccess(AuthenticationSuccessEvent event) {
// 记录到数据库审计表
auditRepository.save(AuditLog.builder()
.event("LOGIN_SUCCESS")
.username(event.getAuthentication().getName())
.timestamp(Instant.now())
.build());
}
}
@Component
@Order(3)
public class AlertAuditListener {
@EventListener
public void onFailure(AbstractAuthenticationFailureEvent event) {
// 连续失败超过 5 次触发告警
String username = event.getAuthentication().getName();
long count = loginFailureCounter.incrementAndGet(username);
if (count >= 5) {
alertService.sendAlert("Brute force detected: " + username);
}
}
}二、自定义审计日志
2.1 审计日志实体
java
@Entity
@Table(name = "audit_log")
public class AuditLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "event_type", nullable = false, length = 50)
private String eventType; // LOGIN_SUCCESS / ACCESS_DENIED / OPERATION
@Column(name = "username", length = 100)
private String username; // 操作人
@Column(name = "ip_address", length = 45)
private String ipAddress; // 客户端 IP
@Column(name = "user_agent", length = 500)
private String userAgent; // 客户端信息
@Column(name = "request_uri", length = 500)
private String requestUri; // 请求路径
@Column(name = "http_method", length = 10)
private String httpMethod; // GET / POST / PUT / DELETE
@Column(name = "parameters", columnDefinition = "TEXT")
private String parameters; // 请求参数(脱敏后)
@Column(name = "result", length = 20)
private String result; // SUCCESS / FAILURE
@Column(name = "error_message", columnDefinition = "TEXT")
private String errorMessage; // 错误信息
@Column(name = "duration_ms")
private Long durationMs; // 耗时
@Column(name = "created_at", nullable = false)
private Instant createdAt; // 审计时间
// 参数脱敏:替换密码等敏感信息
public static String sanitizeParams(String params) {
if (params == null) return null;
return params
.replaceAll("password=[^&]+", "password=***")
.replaceAll("token=[^&]+", "token=***")
.replaceAll("secret=[^&]+", "secret=***");
}
}2.2 审计日志仓储
java
@Repository
public interface AuditLogRepository extends JpaRepository<AuditLog, Long> {
// 按用户查询
List<AuditLog> findByUsernameOrderByCreatedAtDesc(String username, Pageable pageable);
// 查询登录失败记录
@Query("SELECT a FROM AuditLog a WHERE a.eventType = 'LOGIN_FAILURE' " +
"AND a.username = :username AND a.createdAt > :since")
List<AuditLog> findRecentLoginFailures(String username, Instant since);
// 按类型和时间范围统计
long countByEventTypeAndCreatedAtBetween(String eventType, Instant start, Instant end);
}三、AOP 方法审计
3.1 自定义审计注解
java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Audited {
String action() default "";
String resource() default "";
boolean logParams() default true;
boolean logResult() default false;
}3.2 AOP 切面
java
@Aspect
@Component
public class AuditAspect {
@Autowired
private AuditLogRepository auditRepository;
@Around("@annotation(audited)")
public Object audit(ProceedingJoinPoint pjp, Audited audited) throws Throwable {
// 构建审计上下文
AuditContext ctx = buildContext(pjp, audited);
try {
// 执行目标方法
Object result = pjp.proceed();
ctx.setResult("SUCCESS");
if (audited.logResult()) {
ctx.setParameters(String.valueOf(result));
}
return result;
} catch (Throwable e) {
ctx.setResult("FAILURE");
ctx.setErrorMessage(e.getClass().getSimpleName() + ": " + e.getMessage());
throw e; // 重新抛出异常
} finally {
// 异步记录审计日志
saveAuditLog(ctx);
}
}
private AuditContext buildContext(ProceedingJoinPoint pjp, Audited audited) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
HttpServletRequest request = ((ServletRequestAttributes)
RequestContextHolder.currentRequestAttributes()).getRequest();
AuditContext ctx = new AuditContext();
ctx.setEventType("OPERATION_" + audited.action());
ctx.setUsername(auth != null ? auth.getName() : "anonymous");
ctx.setIpAddress(request.getRemoteAddr());
ctx.setUserAgent(request.getHeader("User-Agent"));
ctx.setRequestUri(request.getRequestURI());
ctx.setHttpMethod(request.getMethod());
if (audited.logParams()) {
ctx.setParameters(sanitizeArgs(pjp.getArgs()));
}
ctx.setCreatedAt(Instant.now());
return ctx;
}
@Async // 异步写入,不阻塞主流程
protected void saveAuditLog(AuditContext ctx) {
AuditLog log = new AuditLog();
log.setEventType(ctx.getEventType());
log.setUsername(ctx.getUsername());
log.setIpAddress(ctx.getIpAddress());
log.setUserAgent(ctx.getUserAgent());
log.setRequestUri(ctx.getRequestUri());
log.setHttpMethod(ctx.getHttpMethod());
log.setParameters(ctx.getParameters());
log.setResult(ctx.getResult());
log.setErrorMessage(ctx.getErrorMessage());
log.setCreatedAt(Instant.now());
auditRepository.save(log);
}
}3.3 使用示例
java
@Service
public class OrderService {
@Audited(action = "CREATE_ORDER", resource = "ORDER")
public Order createOrder(@Valid CreateOrderRequest request) {
// 参数会自动脱敏记录
return orderRepository.save(request.toOrder());
}
@Audited(action = "DELETE_ORDER", resource = "ORDER")
public void deleteOrder(Long orderId) {
orderRepository.deleteById(orderId);
}
@Audited(action = "TRANSFER", resource = "ACCOUNT", logResult = true)
public TransferResult transfer(TransferRequest request) {
// 转账等敏感操作记录入参和出参
return transferService.transfer(request);
}
}四、合规审计体系
4.1 审计日志表设计
sql
-- 审计日志主表
CREATE TABLE audit_log (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
event_type VARCHAR(50) NOT NULL, -- 事件类型
username VARCHAR(100), -- 操作人
ip_address VARCHAR(45), -- 来源 IP
user_agent VARCHAR(500), -- 客户端信息
request_uri VARCHAR(500), -- 请求路径
http_method VARCHAR(10), -- HTTP 方法
parameters TEXT, -- 请求参数(脱敏)
result VARCHAR(20) NOT NULL, -- SUCCESS / FAILURE
error_message TEXT, -- 错误信息
duration_ms BIGINT, -- 耗时
created_at DATETIME(3) NOT NULL, -- 审计时间
INDEX idx_username (username),
INDEX idx_event_type (event_type),
INDEX idx_created_at (created_at),
INDEX idx_user_event (username, event_type, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 审计日志归档表(按月分区)
CREATE TABLE audit_log_archive LIKE audit_log;
-- 定期将 90 天前的数据迁移到归档表4.2 不可篡改方案
java
// 使用 HMAC 链式哈希确保日志不可篡改
@Component
public class TamperProofService {
private static final String HMAC_KEY = "audit-hmac-key-32-bytes-long!";
// 计算审计日志的哈希
public String calculateHash(AuditLog log, String previousHash) {
String content = log.getId() + "|" +
log.getEventType() + "|" +
log.getUsername() + "|" +
log.getCreatedAt().toString() + "|" +
log.getResult() + "|" +
previousHash;
return HmacUtils.hmacSha256Hex(HMAC_KEY, content);
}
// 验证日志完整性
public boolean verifyChain(List<AuditLog> logs) {
String previousHash = "";
for (AuditLog log : logs) {
String expectedHash = calculateHash(log, previousHash);
if (!expectedHash.equals(log.getHash())) {
return false; // 日志被篡改
}
previousHash = expectedHash;
}
return true;
}
}五、总结
| 知识点 | 说明 |
|---|---|
| 事件监听 | @EventListener 捕获认证/授权/登出事件 |
| AOP 审计 | @Audited 注解 + @Around 切面 |
| 审计要素 | WHO / WHEN / WHAT / HOW |
| 参数脱敏 | 替换密码、Token、Secret 等敏感信息 |
| 异步写入 | @Async 避免影响主流程性能 |
| 链式哈希 | 使用 HMAC 确保日志不可篡改 |
| 合规要求 | 审计日志保留 > 90 天,支持溯源 |
参考链接: