Spring AOP 实战:安全日志
安全日志是 AOP 最典型的应用场景之一。它需要记录“谁在什么时间、从哪里、做了什么操作、结果如何”。这些信息横跨所有业务方法,用 AOP 统一处理最合适。
一、需求分析
安全日志通常要记录以下信息:
| 字段 | 说明 | 来源 |
|---|---|---|
| 操作人 | 当前登录用户 | SecurityContext / ThreadLocal |
| 操作时间 | 方法执行时间 | LocalDateTime.now() |
| 操作类型 | 登录、删除、修改等 | 自定义注解 |
| 操作模块 | 用户管理、订单管理 | 自定义注解 |
| 请求方法 | 类名 + 方法名 | JoinPoint |
| 请求参数 | 方法入参 | JoinPoint.getArgs() |
| 请求 IP | 客户端 IP | RequestContextHolder |
| 执行结果 | 成功/失败 | 返回值或异常 |
| 耗时 | 方法执行时间 | System.currentTimeMillis() |
二、整体设计
自定义注解 @SecurityLog → 标注在需要记录安全日志的方法上 → 切面拦截标注了 @SecurityLog 的方法 → 收集操作人、IP、参数、结果等信息 → 异步写入日志表或日志文件三、自定义注解
@Target(ElementType.METHOD)@Retention(RetentionPolicy.RUNTIME)@Documentedpublic@interfaceSecurityLog{/** 操作模块 */Stringmodule()default"";/** 操作类型 */Stringtype()default"";/** 操作描述 */Stringdescription()default"";/** 是否记录请求参数 */booleansaveParams()defaulttrue;/** 是否记录返回值 */booleansaveResult()defaultfalse;}四、日志实体
@DatapublicclassSecurityLogEntity{privateLongid;/** 操作人 */privateStringoperator;/** 操作模块 */privateStringmodule;/** 操作类型 */privateStringtype;/** 操作描述 */privateStringdescription;/** 请求方法 */privateStringmethod;/** 请求参数 */privateStringparams;/** 返回值 */privateStringresult;/** 客户端 IP */privateStringip;/** 请求 URI */privateStringuri;/** 执行状态:SUCCESS / FAIL */privateStringstatus;/** 异常信息 */privateStringerrorMsg;/** 耗时(毫秒) */privateLongcostTime;/** 操作时间 */privateLocalDateTimecreateTime;}五、获取当前用户和 IP
@ComponentpublicclassSecurityContextHolder{privatestaticfinalThreadLocal<String>CURRENT_USER=newThreadLocal<>();publicstaticvoidsetCurrentUser(Stringusername){CURRENT_USER.set(username);}publicstaticStringgetCurrentUser(){Stringuser=CURRENT_USER.get();returnuser!=null?user:"anonymous";}publicstaticvoidclear(){CURRENT_USER.remove();}}获取 IP 的工具类:
publicclassIpUtils{publicstaticStringgetClientIp(){HttpServletRequestrequest=getRequest();if(request==null)return"unknown";Stringip=request.getHeader("X-Forwarded-For");if(isValid(ip))returnip.split(",")[0].trim();ip=request.getHeader("X-Real-IP");if(isValid(ip))returnip;ip=request.getHeader("Proxy-Client-IP");if(isValid(ip))returnip;returnrequest.getRemoteAddr();}privatestaticHttpServletRequestgetRequest(){RequestAttributesattrs=RequestContextHolder.getRequestAttributes();if(attrsinstanceofServletRequestAttributes){return((ServletRequestAttributes)attrs).getRequest();}returnnull;}privatestaticbooleanisValid(Stringip){returnip!=null&&!ip.isEmpty()&&!"unknown".equalsIgnoreCase(ip);}}六、切面实现
@Aspect@Component@Order(1)@Slf4jpublicclassSecurityLogAspect{@AutowiredprivateSecurityLogServicesecurityLogService;@Pointcut("@annotation(com.example.annotation.SecurityLog)")publicvoidsecurityLogPointcut(){}@Around("securityLogPointcut() && @annotation(securityLog)")publicObjectaround(ProceedingJoinPointpjp,SecurityLogsecurityLog)throwsThrowable{// 1. 开始计时longstart=System.currentTimeMillis();// 2. 构建日志实体SecurityLogEntitylogEntity=newSecurityLogEntity();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. 执行目标方法Objectresult;try{result=pjp.proceed();logEntity.setStatus("SUCCESS");if(securityLog.saveResult()){logEntity.setResult(serializeResult(result));}}catch(Throwablee){logEntity.setStatus("FAIL");logEntity.setErrorMsg(e.getMessage());throwe;}finally{logEntity.setCostTime(System.currentTimeMillis()-start);// 5. 异步保存日志securityLogService.saveAsync(logEntity);}returnresult;}privateStringgetMethodName(ProceedingJoinPointpjp){returnpjp.getSignature().getDeclaringTypeName()+"."+pjp.getSignature().getName();}privateStringgetRequestUri(){RequestAttributesattrs=RequestContextHolder.getRequestAttributes();if(attrsinstanceofServletRequestAttributes){return((ServletRequestAttributes)attrs).getRequest().getRequestURI();}return"";}privateStringserializeArgs(Object[]args){if(args==null||args.length==0)return"";try{// 过滤掉 HttpServletRequest、HttpServletResponse 等不可序列化对象List<Object>filtered=Arrays.stream(args).filter(arg->!(arginstanceofHttpServletRequest)).filter(arg->!(arginstanceofHttpServletResponse)).filter(arg->!(arginstanceofMultipartFile)).collect(Collectors.toList());returnnewObjectMapper().writeValueAsString(filtered);}catch(Exceptione){returnArrays.toString(args);}}privateStringserializeResult(Objectresult){if(result==null)return"";try{returnnewObjectMapper().writeValueAsString(result);}catch(Exceptione){returnresult.toString();}}}七、异步保存日志
安全日志不应该阻塞业务方法,用@Async异步保存。
@Service@Slf4jpublicclassSecurityLogService{@AutowiredprivateSecurityLogMappersecurityLogMapper;@Async("securityLogExecutor")publicvoidsaveAsync(SecurityLogEntitylogEntity){try{securityLogMapper.insert(logEntity);}catch(Exceptione){log.error("保存安全日志失败",e);}}}线程池配置:
@Configuration@EnableAsyncpublicclassAsyncConfig{@Bean("securityLogExecutor")publicExecutorsecurityLogExecutor(){ThreadPoolTaskExecutorexecutor=newThreadPoolTaskExecutor();executor.setCorePoolSize(2);executor.setMaxPoolSize(5);executor.setQueueCapacity(1000);executor.setThreadNamePrefix("security-log-");executor.setRejectedExecutionHandler(newThreadPoolExecutor.CallerRunsPolicy());executor.initialize();returnexecutor;}}八、业务方法使用
@RestController@RequestMapping("/api/users")publicclassUserController{@AutowiredprivateUserServiceuserService;@SecurityLog(module="用户管理",type="DELETE",description="删除用户")@DeleteMapping("/{id}")publicResultdeleteUser(@PathVariableLongid){userService.deleteById(id);returnResult.success();}@SecurityLog(module="用户管理",type="UPDATE",description="修改用户角色")@PutMapping("/{id}/role")publicResultupdateRole(@PathVariableLongid,@RequestParamStringrole){userService.updateRole(id,role);returnResult.success();}@SecurityLog(module="认证",type="LOGIN",description="用户登录",saveParams=false)@PostMapping("/login")publicResultlogin(@RequestBodyLoginRequestrequest){returnuserService.login(request);}}saveParams = false用于登录接口,避免密码被记录到日志中。
九、当前用户设置
在登录拦截器或过滤器中设置当前用户:
@ComponentpublicclassAuthInterceptorimplementsHandlerInterceptor{@OverridepublicbooleanpreHandle(HttpServletRequestrequest,HttpServletResponseresponse,Objecthandler){Stringtoken=request.getHeader("Authorization");if(token!=null){Stringusername=JwtUtils.getUsername(token);SecurityContextHolder.setCurrentUser(username);}returntrue;}@OverridepublicvoidafterCompletion(HttpServletRequestrequest,HttpServletResponseresponse,Objecthandler,Exceptionex){SecurityContextHolder.clear();}}十、日志表结构
CREATETABLEt_security_log(idBIGINTAUTO_INCREMENTPRIMARYKEY,operatorVARCHAR(64)COMMENT'操作人',moduleVARCHAR(64)COMMENT'操作模块',typeVARCHAR(32)COMMENT'操作类型',descriptionVARCHAR(255)COMMENT'操作描述',methodVARCHAR(255)COMMENT'请求方法',paramsTEXTCOMMENT'请求参数',resultTEXTCOMMENT'返回值',ipVARCHAR(64)COMMENT'客户端IP',uriVARCHAR(255)COMMENT'请求URI',statusVARCHAR(16)COMMENT'执行状态',error_msgTEXTCOMMENT'异常信息',cost_timeBIGINTCOMMENT'耗时(ms)',create_timeDATETIMECOMMENT'操作时间',INDEXidx_operator(operator),INDEXidx_create_time(create_time))COMMENT'安全日志表';十一、扩展:敏感操作告警
安全日志不仅能记录,还能对敏感操作实时告警。
@Aspect@Component@Order(2)@Slf4jpublicclassSecurityAlertAspect{@AutowiredprivateAlertServicealertService;@AfterReturning("@annotation(securityLog)")publicvoidalert(JoinPointjp,SecurityLogsecurityLog){// 高风险操作触发告警if(isHighRisk(securityLog.type())){Stringmessage=String.format("【安全告警】用户 %s 在 %s 执行了 %s 操作",SecurityContextHolder.getCurrentUser(),IpUtils.getClientIp(),securityLog.description());alertService.send(message);}}privatebooleanisHighRisk(Stringtype){return"DELETE".equals(type)||"GRANT".equals(type)||"RESET_PASSWORD".equals(type);}}十二、注意事项
1. 敏感信息脱敏
密码、身份证号、银行卡号等不应记录到日志中。可以在序列化参数前做脱敏处理。
privateStringdesensitize(Stringjson){// 将 password 字段的值替换为 ***returnjson.replaceAll("\"password\"\\s*:\\s*\"[^\"]*\"","\"password\":\"***\"");}2. 异步日志的上下文传递
@Async方法在新线程中执行,ThreadLocal中的用户信息会丢失。解决方案:在切面中提前把用户信息取出来,作为参数传给异步方法。上面的代码已经这样做了。
3. 日志量控制
不是所有方法都需要记录安全日志。用@SecurityLog精确标注需要记录的方法,避免日志表被撑爆。
4. 日志表分区
安全日志数据量大,按月分区或定期归档。
5. 切面顺序
安全日志切面应该在事务切面外层,这样才能记录到事务提交前的方法调用。@Order(1)保证它在外层。
十三、总结
| 环节 | 实现 |
|---|---|
| 标注 | 自定义@SecurityLog注解 |
| 拦截 | @Around切面拦截标注方法 |
| 收集 | 操作人、IP、参数、结果、耗时 |
| 脱敏 | 过滤密码等敏感字段 |
| 保存 | @Async异步写入数据库 |
| 告警 | 高风险操作实时通知 |
| 顺序 | @Order(1)放在事务切面外层 |
安全日志是 AOP 最实用的场景之一。它把“记录操作日志”这个横切逻辑从业务方法中完全抽离,业务代码只需要加一个@SecurityLog注解,剩下的事情由切面统一处理。结合异步保存、敏感信息脱敏、高风险告警,可以构建一套完整的安全审计体系。