Spring AOP 实战:安全日志
安全日志是 AOP 最典型的应用场景之一。它需要记录"谁在什么时间、从哪里、做了什么操作、结果如何"。这些信息横跨所有业务方法,用 AOP 统一处理最合适。
一、需求分析
安全日志通常要记录以下信息:
| 字段 | 说明 | 来源 |
|---|---|---|
| 操作人 | 当前登录用户 | SecurityContext / ThreadLocal |
| 操作时间 | 方法执行时间 | LocalDateTime.now() |
| 操作类型 | 登录、删除、修改等 | 自定义注解 |
| 操作模块 | 用户管理、订单管理 | 自定义注解 |
| 请求方法 | 类名 + 方法名 | JoinPoint |
| 请求参数 | 方法入参 | JoinPoint.getArgs() |
| 请求 IP | 客户端 IP | RequestContextHolder |
| 执行结果 | 成功/失败 | 返回值或异常 |
| 耗时 | 方法执行时间 | System.currentTimeMillis() |
二、整体设计
自定义注解 @SecurityLog
→ 标注在需要记录安全日志的方法上
→ 切面拦截标注了 @SecurityLog 的方法
→ 收集操作人、IP、参数、结果等信息
→ 异步写入日志表或日志文件
三、自定义注解
java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface SecurityLog {
/** 操作模块 */
String module() default "";
/** 操作类型 */
String type() default "";
/** 操作描述 */
String description() default "";
/** 是否记录请求参数 */
boolean saveParams() default true;
/** 是否记录返回值 */
boolean saveResult() default false;
}
四、日志实体
java
@Data
public class SecurityLogEntity {
private Long id;
/** 操作人 */
private String operator;
/** 操作模块 */
private String module;
/** 操作类型 */
private String type;
/** 操作描述 */
private String description;
/** 请求方法 */
private String method;
/** 请求参数 */
private String params;
/** 返回值 */
private String result;
/** 客户端 IP */
private String ip;
/** 请求 URI */
private String uri;
/** 执行状态:SUCCESS / FAIL */
private String status;
/** 异常信息 */
private String errorMsg;
/** 耗时(毫秒) */
private Long costTime;
/** 操作时间 */
private LocalDateTime createTime;
}
五、获取当前用户和 IP
java
@Component
public class SecurityContextHolder {
private static final ThreadLocal<String> CURRENT_USER = new ThreadLocal<>();
public static void setCurrentUser(String username) {
CURRENT_USER.set(username);
}
public static String getCurrentUser() {
String user = CURRENT_USER.get();
return user != null ? user : "anonymous";
}
public static void clear() {
CURRENT_USER.remove();
}
}
获取 IP 的工具类:
java
public class IpUtils {
public static String getClientIp() {
HttpServletRequest request = getRequest();
if (request == null) return "unknown";
String ip = request.getHeader("X-Forwarded-For");
if (isValid(ip)) return ip.split(",")[0].trim();
ip = request.getHeader("X-Real-IP");
if (isValid(ip)) return ip;
ip = request.getHeader("Proxy-Client-IP");
if (isValid(ip)) return ip;
return request.getRemoteAddr();
}
private static HttpServletRequest getRequest() {
RequestAttributes attrs = RequestContextHolder.getRequestAttributes();
if (attrs instanceof ServletRequestAttributes) {
return ((ServletRequestAttributes) attrs).getRequest();
}
return null;
}
private static boolean isValid(String ip) {
return ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip);
}
}
六、切面实现
java
@Aspect
@Component
@Order(1)
@Slf4j
public class SecurityLogAspect {
@Autowired
private SecurityLogService securityLogService;
@Pointcut("@annotation(com.example.annotation.SecurityLog)")
public void securityLogPointcut() {}
@Around("securityLogPointcut() && @annotation(securityLog)")
public Object around(ProceedingJoinPoint pjp, SecurityLog securityLog) throws Throwable {
// 1. 开始计时
long start = System.currentTimeMillis();
// 2. 构建日志实体
SecurityLogEntity logEntity = new SecurityLogEntity();
logEntity.setOperator(SecurityContextHolder.getCurrentUser());
logEntity.setModule(securityLog.module());
logEntity.setType(securityLog.type());
logEntity.setDescription(securityLog.description());
logEntity.setMethod(getMethodName(pjp));
logEntity.setIp(IpUtils.getClientIp());
logEntity.setUri(getRequestUri());
logEntity.setCreateTime(LocalDateTime.now());
// 3. 记录请求参数
if (securityLog.saveParams()) {
logEntity.setParams(serializeArgs(pjp.getArgs()));
}
// 4. 执行目标方法
Object result;
try {
result = pjp.proceed();
logEntity.setStatus("SUCCESS");
if (securityLog.saveResult()) {
logEntity.setResult(serializeResult(result));
}
} catch (Throwable e) {
logEntity.setStatus("FAIL");
logEntity.setErrorMsg(e.getMessage());
throw e;
} finally {
logEntity.setCostTime(System.currentTimeMillis() - start);
// 5. 异步保存日志
securityLogService.saveAsync(logEntity);
}
return result;
}
private String getMethodName(ProceedingJoinPoint pjp) {
return pjp.getSignature().getDeclaringTypeName() + "." + pjp.getSignature().getName();
}
private String getRequestUri() {
RequestAttributes attrs = RequestContextHolder.getRequestAttributes();
if (attrs instanceof ServletRequestAttributes) {
return ((ServletRequestAttributes) attrs).getRequest().getRequestURI();
}
return "";
}
private String serializeArgs(Object[] args) {
if (args == null || args.length == 0) return "";
try {
// 过滤掉 HttpServletRequest、HttpServletResponse 等不可序列化对象
List<Object> filtered = Arrays.stream(args)
.filter(arg -> !(arg instanceof HttpServletRequest))
.filter(arg -> !(arg instanceof HttpServletResponse))
.filter(arg -> !(arg instanceof MultipartFile))
.collect(Collectors.toList());
return new ObjectMapper().writeValueAsString(filtered);
} catch (Exception e) {
return Arrays.toString(args);
}
}
private String serializeResult(Object result) {
if (result == null) return "";
try {
return new ObjectMapper().writeValueAsString(result);
} catch (Exception e) {
return result.toString();
}
}
}
七、异步保存日志
安全日志不应该阻塞业务方法,用 @Async 异步保存。
java
@Service
@Slf4j
public class SecurityLogService {
@Autowired
private SecurityLogMapper securityLogMapper;
@Async("securityLogExecutor")
public void saveAsync(SecurityLogEntity logEntity) {
try {
securityLogMapper.insert(logEntity);
} catch (Exception e) {
log.error("保存安全日志失败", e);
}
}
}
线程池配置:
java
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean("securityLogExecutor")
public Executor securityLogExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(5);
executor.setQueueCapacity(1000);
executor.setThreadNamePrefix("security-log-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}
八、业务方法使用
java
@RestController
@RequestMapping("/api/users")
public class UserController {
@Autowired
private UserService userService;
@SecurityLog(module = "用户管理", type = "DELETE", description = "删除用户")
@DeleteMapping("/{id}")
public Result deleteUser(@PathVariable Long id) {
userService.deleteById(id);
return Result.success();
}
@SecurityLog(module = "用户管理", type = "UPDATE", description = "修改用户角色")
@PutMapping("/{id}/role")
public Result updateRole(@PathVariable Long id, @RequestParam String role) {
userService.updateRole(id, role);
return Result.success();
}
@SecurityLog(module = "认证", type = "LOGIN", description = "用户登录", saveParams = false)
@PostMapping("/login")
public Result login(@RequestBody LoginRequest request) {
return userService.login(request);
}
}
saveParams = false 用于登录接口,避免密码被记录到日志中。
九、当前用户设置
在登录拦截器或过滤器中设置当前用户:
java
@Component
public class AuthInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
String token = request.getHeader("Authorization");
if (token != null) {
String username = JwtUtils.getUsername(token);
SecurityContextHolder.setCurrentUser(username);
}
return true;
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler, Exception ex) {
SecurityContextHolder.clear();
}
}
十、日志表结构
sql
CREATE TABLE t_security_log (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
operator VARCHAR(64) COMMENT '操作人',
module VARCHAR(64) COMMENT '操作模块',
type VARCHAR(32) COMMENT '操作类型',
description VARCHAR(255) COMMENT '操作描述',
method VARCHAR(255) COMMENT '请求方法',
params TEXT COMMENT '请求参数',
result TEXT COMMENT '返回值',
ip VARCHAR(64) COMMENT '客户端IP',
uri VARCHAR(255) COMMENT '请求URI',
status VARCHAR(16) COMMENT '执行状态',
error_msg TEXT COMMENT '异常信息',
cost_time BIGINT COMMENT '耗时(ms)',
create_time DATETIME COMMENT '操作时间',
INDEX idx_operator (operator),
INDEX idx_create_time (create_time)
) COMMENT '安全日志表';
十一、扩展:敏感操作告警
安全日志不仅能记录,还能对敏感操作实时告警。
java
@Aspect
@Component
@Order(2)
@Slf4j
public class SecurityAlertAspect {
@Autowired
private AlertService alertService;
@AfterReturning("@annotation(securityLog)")
public void alert(JoinPoint jp, SecurityLog securityLog) {
// 高风险操作触发告警
if (isHighRisk(securityLog.type())) {
String message = String.format(
"【安全告警】用户 %s 在 %s 执行了 %s 操作",
SecurityContextHolder.getCurrentUser(),
IpUtils.getClientIp(),
securityLog.description()
);
alertService.send(message);
}
}
private boolean isHighRisk(String type) {
return "DELETE".equals(type)
|| "GRANT".equals(type)
|| "RESET_PASSWORD".equals(type);
}
}
十二、注意事项
1. 敏感信息脱敏
密码、身份证号、银行卡号等不应记录到日志中。可以在序列化参数前做脱敏处理。
java
private String desensitize(String json) {
// 将 password 字段的值替换为 ***
return json.replaceAll("\"password\"\\s*:\\s*\"[^\"]*\"", "\"password\":\"***\"");
}
2. 异步日志的上下文传递
@Async 方法在新线程中执行,ThreadLocal 中的用户信息会丢失。解决方案:在切面中提前把用户信息取出来,作为参数传给异步方法。上面的代码已经这样做了。
3. 日志量控制
不是所有方法都需要记录安全日志。用 @SecurityLog 精确标注需要记录的方法,避免日志表被撑爆。
4. 日志表分区
安全日志数据量大,按月分区或定期归档。
5. 切面顺序
安全日志切面应该在事务切面外层,这样才能记录到事务提交前的方法调用。@Order(1) 保证它在外层。
十三、总结
| 环节 | 实现 |
|---|---|
| 标注 | 自定义 @SecurityLog 注解 |
| 拦截 | @Around 切面拦截标注方法 |
| 收集 | 操作人、IP、参数、结果、耗时 |
| 脱敏 | 过滤密码等敏感字段 |
| 保存 | @Async 异步写入数据库 |
| 告警 | 高风险操作实时通知 |
| 顺序 | @Order(1) 放在事务切面外层 |
安全日志是 AOP 最实用的场景之一。它把"记录操作日志"这个横切逻辑从业务方法中完全抽离,业务代码只需要加一个 @SecurityLog 注解,剩下的事情由切面统一处理。结合异步保存、敏感信息脱敏、高风险告警,可以构建一套完整的安全审计体系。