Spring AOP 实战:安全日志

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 注解,剩下的事情由切面统一处理。结合异步保存、敏感信息脱敏、高风险告警,可以构建一套完整的安全审计体系。

相关推荐
专注仿真1 小时前
梯度提升类(XFBoost)算法详解与代码实例
开发语言·人工智能·python·算法·大模型训练·控制算法
外收内放1 小时前
Python基础语法练习题(75)
开发语言·python·学习
开开心心就好2 小时前
音频降噪剪辑工具,音量裁剪一键处理
运维·服务器·前端·spring·ocr·音视频·1024程序员节
黑十3 小时前
PyTorch 报 “no CUDA GPUs are available“ 怎么办?3 步排查
服务器·网络·人工智能·pytorch·python
threelab3 小时前
开源图片去水印代码
python
Flynn Liang4 小时前
大模型怎么自己调函数?Spring AI 2.0 Tool Calling 自动循环原理 + 完整时序图
java·人工智能·spring·agent·函数调用·springai·toolcalling
taller_20004 小时前
【012】数据类型入门: Excel 数据到 Python 值的转换
python·excel·pie·py
淡漠的蓝精灵4 小时前
web.py:一个简单到极致的 Python Web 框架
开发语言·前端·python·其他
乌暮5 小时前
请求进门先安检:Spring 拦截器与统一功能实战指南
java·开发语言·后端·学习·spring
安教安全员通关宝5 小时前
2026年河北安全员A证考试题库+答案+解析
安全