Spring Boot# 使用AOP实现接口鉴权访问白名单限制记录接口访问日志限制接口请求次数

Posted LRcoding

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Spring Boot# 使用AOP实现接口鉴权访问白名单限制记录接口访问日志限制接口请求次数相关的知识,希望对你有一定的参考价值。

1. AOP相关知识

1.1 基础知识

AOP(Aspect Oriented Programming):面向切面编程,通过预编译方式和运行期动态代理实现程序功能的统一维护的技术。

利用AOP可以对业务逻辑的各个部分进行隔离,从而使得业务逻辑各部分之间的耦合度降低,提供程序的可重用性,同时提高了开发的效率

通知(Advice):通知描述了切面要完成的工作以及何时执行

  • 前置通知(Before):在目标方法执行前,调用通知功能
  • 后置通知(After):在目标方法执行后,调用通知功能,不关心方法的返回结果
  • 返回通知(AfterReturning):在目标方法成功执行后,准备返回结果时,调用通知功能
  • 异常通知(AfterThrowing):在目标方法抛出异常后,调用通知功能
  • 环绕通知(Around):通知包裹了目标方法,在目标方法调用之前和之后执行自定义的行为

连接点(JoinPoint):通知功能被应用的时机。可以拿到目标方法的参数、返回值、签名

  • ProceedingJoinPoint:用于环绕通知,是JoinPoint的子接口,在JoinPoint的基础上,增加了两个方法(最多使用)

    Object proceed() throws Throwable   // 执行目标方法 
    Object proceed(Object[] var1) throws Throwable   // 传入的新的参数去执行目标方法 
    

切点(Pointcut):切点定义了通知功能被应用的范围。某个方法,还是某个类,还是所有的类…

切面(Aspect):就是通知和切点的结合,定义了何时、何地应用通知功能

引入(Introduction):在无需修改现有类的情况下,向现有的类添加新方法或属性。

织入(Weaving):把切面应用到目标对象并创建新的代理对象的过程

1.2 Spring中创建切面需要用到的注解

  • @Aspect:用于定义切面(用于类上)
  • @Before:创建前置通知,通知方法会在目标方法之前执行
  • @After:创建后置通知,通知方法会在目标方法返回或抛出异常后执行
  • @AfterReturning:创建返回通知,通知方法会在目标方法返回后执行
  • @AfterThrowing:创建异常通知,通知方法会在目标方法抛出异常后执行
  • @Around:创建环绕通知,通知方法会将目标方法包围起来,在目标方法之前及之后执行
  • @Pointcut:定义切点表达式

1.3 切点表达式

制定了通知被应用的范围,具体的格式为:

execution(访问修饰符  返回值类型  包名.类名.方法名称(方法参数))
  • 访问修饰符:可省略
  • 返回值类型、包名、类名、方法名称:都可用 * 代替(表示任意的)
  • 包名与类名之间一个点代表当前包下的类两个点代表当前包及其子包下的类
  • 参数列表可以使用两个点,代表任意个数任意参数列表

示例:com.lwclick.java.controller包下所有类的public方法

execution(public com.lwlick.java.controller.*.*(..))

2. 实现接口鉴权访问

定义一个接口控制切面,在环绕通知中,获取用户请求头中携带的信息进行鉴别,判断是否允许执行目标方法(其余的功能,都可与该功能合并实现)

@Aspect
@Component
@Order(1)
public class LoginAccessAspect 
    private static final Logger LOG = LoggerFactory.getLogger(LoginAccessAspect.class);

    /**
     * 所有controller下的方法
     */
    @Pointcut("execution(public * com.lwclick.*controller.*(..))")
    public void pointCut() 
    

    /**
     * 登录方法
     */
    @Pointcut("execution(public String com.lwclick.SysController.login(..))")
    public void login() 
    

    /**
     * 拦截除【登录方法】之外的,所有controller下的方法
     *
     * @return
     */
    @Around("pointCut() && !login()")
    public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable 
        ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
        // 此处无需判断 requestAttributes 是否为空
        HttpServletRequest request = requestAttributes.getRequest();

        // 获取请求头中的 Authorization 信息
        String authorization = request.getHeader("Authorization");
        // TODO: 根据 authorization 信息进行判断
        if ("通过".equals(authorization)) 
            // 通过了判断,可以认为是携带着登录信息进行请求的,则【允许目标方法继续执行】!!!!!!
            return joinPoint.proceed();
        

        // 返回错误信息,此处通过反射使用了 R 或 AjaxResult 的 error方法(若依框架中的R类及AjaxResult类)
        LOG.info("存在未登录系统的请求,IP为:", getIpAddress(request));
        LOG.info("存在未登录系统的请求,具体参数为:", getParameter(method, joinPoint.getArgs()));
        Class<?> returnType = method.getReturnType();
        Method error = returnType.getMethod("error", String.class);
        return error.invoke(returnType, "未登录系统,禁止访问!");
    

    /**
     * 获取request中的IP
     *
     * @param request
     * @return
     */
    public static String getIpAddress(HttpServletRequest request) 
        if (request == null) 
            return "unknown";
        
        String ip = request.getHeader("x-forwarded-for");
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) 
            ip = request.getHeader("Proxy-Client-IP");
        
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) 
            ip = request.getHeader("X-Forwarded-For");
        
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) 
            ip = request.getHeader("WL-Proxy-Client-IP");
        
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) 
            ip = request.getHeader("X-Real-IP");
        
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) 
            ip = request.getRemoteAddr();
        

        return "0:0:0:0:0:0:0:1".equals(ip) ? "127.0.0.1" : ip;
    

    /**
     * 根据方法和传入的参数获取请求参数
     *
     * @param method 具体的方法
     * @param args   参数列表
     * @return
     */
    private Object getParameter(Method method, Object[] args) 
        List<Object> argList = new ArrayList<>();
        Parameter[] parameters = method.getParameters();
        for (int i = 0; i < parameters.length; i++) 
            // 将RequestBody注解修饰的参数作为请求参数
            RequestBody requestBody = parameters[i].getAnnotation(RequestBody.class);
            if (requestBody != null) 
                argList.add(args[i]);
            
            // 将RequestParam注解修饰的参数作为请求参数
            RequestParam requestParam = parameters[i].getAnnotation(RequestParam.class);
            if (requestParam != null) 
                Map<String, Object> map = new HashMap<>();
                String key = parameters[i].getName();
                if (!StringUtils.isEmpty(requestParam.value())) 
                    key = requestParam.value();
                
                map.put(key, args[i]);
                argList.add(map);
            
        
        if (argList.size() == 0) 
            return null;
         else if (argList.size() == 1) 
            return argList.get(0);
         else 
            return argList;
        
    

3. 实现白名单限制

接口鉴权访问的基础上,当获取到用户的请求IP时,从数据库或缓存中取出白名单组,判断该IP是否在白名单组中即可。

4. 记录接口访问日志

接口鉴权访问的基础上,通过request获取请求的信息,进行记录即可

// 获取请求的URL       http://127.0.0.1:8080/api/getName
String urlStr = request.getRequestURL().toString();
// 获取请求的方式      POST
String methodStr = request.getMethod();
// 获取请求的URI       /api/getName
String uri = request.getRequestURI();
// 获取请求的参数  
getParameter(method, joinPoint.getArgs());      // getParameter,上面【接口鉴权访问】的代码中有
// 获取方法执行后的结果
Object result = joinPoint.proceed();

5. 限制接口请求次数

实现思路:

  • 定义注解,配置频率,将注解放在需要限制调用频率的接口上
  • 定义切面拦截注解,如果拦截到了自定义的注解,则通过redis进行记录,redis的key为 “ip_接口名称”,value为调用次数,拦截到一次值便 +1
  • 如果该次拦截到的请求,通过key获取到的值已经超过了注解中配置的值,则不放行
  • 否则,放行,redis中的值 +1

5.1 自定义注解

注意:count 为访问次数,time 为指定时间,结合起来就是在某段时间内,如果请求达到一定次数,则不予响应

@Retention(RetentionPolicy.RUNTIME) // 运行时
@Target(ElementType.TYPE, ElementType.METHOD) // 可以被用在类及方法上
@Order(Ordered.HIGHEST_PRECEDENCE) // 最高优先级
public @interface LimitRequest 
    /**
     * 允许的请求次数,默认 MAX_VALUE
     *
     * @return
     */
    int count() default Integer.MAX_VALUE;

    /**
     * 时间段,单位为毫秒数(和redis统一),默认 1分钟
     *
     * @return
     */
    int time() default 60 * 1000;

5.2 在类或方法上使用注解

在需要限制请求次数的类或者方法上,添加 @LimitRequest注解:1分钟内,该接口仅允许被调用 2 次

@LimitRequest(count = 2)
@GetMapping("/getInfo")
public Map<String, Object> getInfo(@RequestParam String deptCode) 
    // TODO

5.3 配置切面

当用户进行请求时,切面拦截到请求开始判断,通过设置 redis的数据,key 为 ip+接口名称,value为已经调用次数,redis数据有效时间为注解定义的时间

@Aspect
@Component
@Order(1)
public class ApiControllerAspect 
    private static final Logger logger = LoggerFactory.getLogger(ApiControllerAspect.class);

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    /**
     * 需要拦截的位置(所有controller中返回值为 Map的)
     */
    @Pointcut("execution(public java.util.Map com.lwclick.controller.*Controller.*(..)) ")
    public void webAspect() 
    

    @Around("webAspect()")
    public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable 
        // 获取当前请求对象
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        boolean flag = true;

        String ipAddr = IpUtils.getIpAddr(ServletUtils.getRequest());
        HttpServletRequest request = attributes.getRequest();

        // 获取类注释
        Class<ApiController> aClass = ApiController.class;
        InterfaceReqLimit classAnnotation = aClass.getAnnotation(InterfaceReqLimit.class);

        // 获取方法注释
        Signature signature = joinPoint.getSignature();
        MethodSignature methodSignature = (MethodSignature) signature;
        Method method = methodSignature.getMethod();
        InterfaceReqLimit methodAnnotation = method.getAnnotation(InterfaceReqLimit.class);
        String uri = request.getRequestURI();

        if (methodAnnotation != null) 
            flag = validRequestCount(ipAddr, uri.replace("/", "_"), methodAnnotation.count(), methodAnnotation.time());
         else if (classAnnotation != null) 
            flag = validRequestCount(ipAddr, uri.replace("/", "_"), classAnnotation.count(), classAnnotation.time());
        

        logger.info("请求超过请求次数,请稍后再试", ipAddr, uri);
        if (flag) 
            return joinPoint.proceed();
        

        return new HashMap<String, Object>() 
            
                put("msg", "超过请求次数,请稍后再试");
            
        ;
    

    /**
     * 判断某一个ip请求接口的次数是否超过限制
     *
     * @param ipAddr
     * @param uri
     * @param limitCount
     * @param timeOut
     * @return
     */
    private boolean validRequestCount(String ipAddr, String uri, int limitCount, long timeOut) 
        try 
            // /api/queryInfo
            String redisKey = "req_limit_".concat(ipAddr).concat(uri);
            long count = redisTemplate.opsForValue().increment(redisKey, 1);
            if (count == 1) 
                redisTemplate.expire(redisKey, timeOut, TimeUnit.MILLISECONDS);
            
            if (count > limitCount) 
                return false;
            
         catch (Exception e) 
            return false;
        
        return true;
    

以上是关于Spring Boot# 使用AOP实现接口鉴权访问白名单限制记录接口访问日志限制接口请求次数的主要内容,如果未能解决你的问题,请参考以下文章

Spring Boot中使用Sa-Token实现轻量级登录与鉴权

Spring Boot使用AOP实现REST接口简易灵活的安全认证

Spring Boot 中使用Spring Aop实现日志记录功能

Spring Boot AOP 扫盲,实现接口访问的统一日志记录

Spring Boot @Aspect 切面编程实现访问请求日志记录

Spring Boot项目鉴权的4种方法