记一次自定义拦截器失效的问题排查

背景

项目中使用swagger来自动生成接口文档,为了防止接口文档地址在外网被访问,需要对swagger的静态资源链接以及动态接口请求根据host属性做一些处理,使得外网域名不能访问接口文档地址。一个容易想到的办法就是通过Spring的Interceptor来实现,在preHandle方法中对swagger的请求路径做拦截,如果是外网域名请求,直接返回403。

代码示例
public class SwaggerInterceptor implements HandlerInterceptor {

    // 外网访问时需要排除的Swagger URL,其中/swagger-ui.html是接口文档首页UI地址,/v2/api-docs是动态请求接口,返回json格式的接口详细信息,两个都需要拦截
    private static final List<String> EXCLUDE_URL = Arrays.asList("tes1-kids.youdao.com/v2/api-docs", "test1-kids.youdao.com/swagger-ui.html");

  @Override
  public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    String url = request.getRequestURL().toString();
    // 判断请求路径是否在排除列表中,如果在返回403
    if (EXCLUDE_URL.stream().anyMatch(item -> url.contains(item))) {
        response.sendError(403);
    }
    return HandlerInterceptor.super.preHandle(request, response, handler);
  }
}
出现的问题

SwaggerInterceptor可以拦截到/swagger-ui.html请求,但是始终无法拦截/v2/api-docs接口,用户还是可以通过直接访 /v2/api-docs获取所有接口的json格式信息。

问题排查

首先,检查拦截器的配置,发现SwaggerInterceptor配置是拦截全部请求addPathPatterns("/**"),并且可以拦截到/swagger-ui.html,确认不是拦截器全局配置的问题。

接下来,就具体看下/v2/api-docs这个接口的请求执行情况,基于Spring MVC请求的入口类为DispatchServlet,核心的入口方法doDispatch源码如下:

protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
    ......
    try {
        // HandlerMapping根据请求路径选择对应的handler(controller下的某个方法)来处理当前请求
        // 补充下HandlerMapping:
        // 1. 根据当前请求的找到对应的 Handler,
        // 2. 将 Handler(执行程序)与一堆 HandlerInterceptor(拦截器)封装到 HandlerExecutionChain 对象中
        // 3. DispatcherServlet会从容器中取出所有HandlerMapping实例并遍历,让HandlerMapping实例根据自己实现类的方式去尝试查找Handler
        mappedHandler = getHandler(processedRequest);
        ......
        // 根据handler来找到支持它的HandlerAdapter,通过HandlerAdapter执行这个最后的代码处理逻辑得到具体的返回结果
        HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler());
        ......
        // 拦截器preHandle处理,按顺序执行
        if (!mappedHandler.applyPreHandle(processedRequest, response)) {
            return;
        }
        // handlerAdapter实际的执行逻辑
        mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
        ......
        // 拦截器postHandle处理
        mappedHandler.applyPostHandle(processedRequest, response, mv);
    } catch (Throwable err) {
            ......
    } finally {
            ......
    }
}

在doDispatch方法中,拦截器的preHandle执行逻辑在mappedHandler.applyPreHandle中,接下来我看下这个方法:

boolean applyPreHandle(HttpServletRequest request, HttpServletResponse response) throws Exception {
    // 遍历handler绑定的所有interceptors,按顺序执行preHanlde方法
    for (int i = 0; i < this.interceptorList.size(); i++) {
        HandlerInterceptor interceptor = this.interceptorList.get(i);
        // 如果preHandle返回false,则触发afterCompletion方法的执行
        if (!interceptor.preHandle(request, response, this.handler)) {
            triggerAfterCompletion(request, response, null);
            return false;
        }
        this.interceptorIndex = I;
    }
    return true;
}

debug跟踪到这个方法,发现applyPreHandle方法中this.interceptorList的长度为0,即处理该请求的handler没有绑定任何interceptor。这个时候很容易想到问题可能出现在handlerMapping上,因为handlerMapping负责将handler与一堆 handlerInterceptor(拦截器)封装到 HandlerExecutionChain 对象中。

我们往前在具体看下doDispatch中的getHandler方法:

protected HandlerExecutionChain getHandler(HttpServletRequest request) throws Exception {
    // DispatcherServlet会从容器中取出所有HandlerMapping实例并遍历,让HandlerMapping实例根据自己实现类的方式去尝试查找Handler
    if (this.handlerMappings != null) {
        for (HandlerMapping mapping : this.handlerMappings) {
            HandlerExecutionChain handler = mapping.getHandler(request);
            if (handler != null) {
                return handler;
            }
        }
    }
    return null;
}

debug到这里,发现DispatcherServlet关联的handleMapping实例列表中,除了默认的四个HandleMapping实现,还多了一个自定义的HandlerMapping,即WebMvcPropertySourcedRequestMappingHandlerMapping,且优先级最高,这个handlerMapping是在Swagger的类库中定义的。

image-20210411112305354

默认的四个HandlerMapping实现都会在实例化的过程中绑定自定义的拦截器,下面的源码为WebMvcConfigurationSupport中RequestMappingHandlerMapping实例化的过程,可以看到创建对象后会设置Interceptors成员变量,从而绑定interceptors。

RequestMappingHandlerMapping mapping = createRequestMappingHandlerMapping();
mapping.setOrder(0);
// 绑定上下文中的自定义拦截器
mapping.setInterceptors(getInterceptors(conversionService, resourceUrlProvider));
mapping.setContentNegotiationManager(contentNegotiationManager);
mapping.setCorsConfigurations(getCorsConfigurations());

到现在我们就能够基本确定问题出在WebMvcPropertySourcedRequestMappingHandlerMapping这个自定义HandlerMapping上,它的实例化过程中应该没有绑定自定义的拦截器,同时/v2/api-docs接口是由这个HandlerMapping负责选择具体执行请求的handler,导致拦截器失效。下面我们再通过swagger的源码验证下:

// Swagger2DocumentationWebMvcConfiguration类
@Bean
public HandlerMapping swagger2ControllerMapping(
    Environment environment,
  DocumentationCache documentationCache,
  ServiceModelToSwagger2Mapper mapper,
  JsonSerializer jsonSerializer) {
  // 创建WebMvcPropertySourcedRequestMappingHandlerMapping实例,并没有调用setIntercetors设置拦截器
  // 同时构造方法第二个参数为处理/v2/api-docs接口的具体handler(Swagger2ControllerWebMVC)
  return new WebMvcPropertySourcedRequestMappingHandlerMapping(
    environment,
       new Swagger2ControllerWebMvc(environment, documentationCache, mapper, jsonSerializer));
}

至此,问题的原因已找到,在讨论具体解决方法之前,我们先大致的看下WebMvcPropertySourcedRequestMappingHandlerMapping这个自定义handlerMapping的作用。

我们查看Swagger2ControllerWebMvc的getDocumentation方法,原来这里可以动态地指定getDocumentation这个方法的请求路径,在application.properties设置springfox.documentation.swagger.v2.path这个key的值,即可覆盖掉默认的URL。

@RequestMapping(
    value = DEFAULT_URL,  // 这里DEFAULT_URL即为/v2/api-docs
    method = RequestMethod.GET,
    produces = { APPLICATION_JSON_VALUE, HAL_MEDIA_TYPE })
@PropertySourcedMapping(
    value = "${springfox.documentation.swagger.v2.path}",
    propertyKey = "springfox.documentation.swagger.v2.path")
@ResponseBody
public ResponseEntity<Json> getDocumentation(
    @RequestParam(value = "group", required = false) String swaggerGroup,
     HttpServletRequest servletRequest) {
        ......
}

看到这里我们不难发现,WebMvcPropertySourcedRequestMappingHandlerMapping的主要作用就是实现请求路径的动态配置,让请求更加灵活。

接下来我们来看看如何解决这个问题。

解决方法

主要有三种方式:

  1. ResponseBodyAdvice
  2. 通过拦截器实现,WebMvcPropertySourcedRequestMappingHandlerMapping实例化的过程中绑定自定义拦截器。
  3. Spring AOP LTW
方法1:ResponseBodyAdvice

既然自定的HanderMapping初始化未绑定拦截器,导致拦截器失效,那我们换一种思路,通过ResponseBodyAdvice配合@ControllerAdvice注解,在请求响应体返回之前,校验请求URL,若为外网请求的Swagger路径,返回403,代码如下:

@RestControllerAdvice
public class SwaggerAdvice implements ResponseBodyAdvice {

    private static final List<String> EXCLUDE_URL = Arrays.asList("test1-kids.youdao.com/v2/api-docs");

    @Override
    public Object beforeBodyWrite(Object o, MethodParameter methodParameter, MediaType mediaType, Class aClass, ServerHttpRequest serverHttpRequest, ServerHttpResponse serverHttpResponse) {
        String url = serverHttpRequest.getURI().getHost() + serverHttpRequest.getURI().getPath();
        if (EXCLUDE_URL.stream().anyMatch(item -> url.contains(item))) {
            serverHttpResponse.setStatusCode(HttpStatus.FORBIDDEN);
            return "403";
        }
        return o;
    }

    @Override
    public boolean supports(MethodParameter methodParameter, Class aClass) {
        return true;
    }
}
方法2:HandlerMapping初始化绑定拦截器

如果还是想用拦截器来解决这个问题,基本思路就是我们自己在WebMvcPropertySourcedRequestMappingHandlerMapping初始化的时候手动绑定拦截器,这样自定的拦截器就不会失效了,代码如下:

// 创建WebMvcPropertySourcedRequestMappingHandlerMapping的子类
// 这里的主要作用就是重写initHandlerMethods设置order的优先级为最高
public class SwaggerRequestMappingHandlerMapping extends WebMvcPropertySourcedRequestMappingHandlerMapping {

    public SwaggerRequestMappingHandlerMapping(Environment environment, Object handler) {
        super(environment, handler);
    }

    @Override
    protected void initHandlerMethods() {
        super.initHandlerMethods();
        // WebMvcPropertySourcedRequestMappingHandlerMapping的优先级为Ordered.HIGHEST_PRECEDENCE + 1000
        this.setOrder(Ordered.HIGHEST_PRECEDENCE + 999);
    }
}

// 参照RequestMappingHandlerMapping的初始化过程,这里需要注意的是需要继承DelegatingWebMvcConfiguration
// 直接继承WebMvcConfigurationSupport无法获取到上下文中的拦截器列表
@Configuration
public class HandlerMappingConfig extends DelegatingWebMvcConfiguration {

    @Bean
    public HandlerMapping newSwagger2ControllerMapping(
            Environment environment,
            DocumentationCache documentationCache,
            ServiceModelToSwagger2Mapper mapper,
            JsonSerializer jsonSerializer,
            @Qualifier("mvcConversionService") FormattingConversionService conversionService,
            @Qualifier("mvcResourceUrlProvider") ResourceUrlProvider resourceUrlProvider) {
        SwaggerRequestMappingHandlerMapping newSwagger2ControllerMapping =
                new SwaggerRequestMappingHandlerMapping(
                        environment,
                        new Swagger2ControllerWebMvc(environment, documentationCache, mapper, jsonSerializer));
        // 关联拦截器列表
        newSwagger2ControllerMapping.setInterceptors(
                getInterceptors(conversionService, resourceUrlProvider));
        return newSwagger2ControllerMapping;
    }
}
方法三:Spring AOP LTW

可以考虑基于Spring AOP机制来实现,在/v2/api-docs请求对应的方法执行前,判断请求是否为外网请求。示例代码如下:

// 开启基于CGLIB实现的动态代理
@SpringBootApplication(scanBasePackages = {"com.youdao.kids", "springfox"})
@EnableAspectJAutoProxy(proxyTargetClass = true)
public class SwaggerExampleApplication {

    public static void main(String[] args) {
        SpringApplication.run(SwaggerExampleApplication.class, args);
    }

}

// 定义一个切面,在方法执行前,拦截外网请求
@Aspect
@Component
public class SwaggerAspect {

    private static final List<String> EXCLUDE_URL = Arrays.asList("test1-kids.youdao.com/v2/api-docs");

    @Before("execution(* springfox.documentation.swagger2.web.Swagger2ControllerWebMvc.getDocumentation(..))")
    public void switchDataSource() throws Exception {
        ServletRequestAttributes attributes = (ServletRequestAttributes)RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = attributes.getRequest();
        HttpServletResponse response = attributes.getResponse();
        String url = request.getRequestURL().toString();
        if (EXCLUDE_URL.stream().anyMatch(item -> url.contains(item))) {
            response.sendError(403);
        }
    }
}

当我们运行上述代码的时候,会发现这个切面对于Swagger2ControllerWebMvc是失效的,无法拦截对应的getDocumentation方法。

原因是什么呢,这个时候我们可能就会想到,基于动态代理的AOP实现只能代理Spring容器管理的bean,在代码的运行过程中动态地为这些bean创建代理对象,从而达到增强bean的目的。

回到我们这个场景,查看Swagger2ControllerWebMvc的源码,发现这个类有controller的注解,应该可以被代理,但却为什么不生效呢。这个时候我们再回到WebMvcPropertySourcedRequestMappingHandlerMapping的初始化过程,发现这个handlerMapping不是通过Ioc的方式拿到Swagger2ControllerWebMvc的实例,而是直接new了一个Swagger2ControllerWebMvc对象。这个时候问题就清楚了,自己new的对象是不能被Spring容器管理的,导致AOP失效。

// 有controller注解,理应可以被代理
@Controller
@ConditionalOnClass(name = "javax.servlet.http.HttpServletRequest")
@ApiIgnore
public class Swagger2ControllerWebMvc {
 ......
}

// 直接new了一个Swagger2ControllerWebMvc对应与SwaggerRequestMappingHandlerMapping关联
SwaggerRequestMappingHandlerMapping newSwagger2ControllerMapping =
                new SwaggerRequestMappingHandlerMapping(
                        environment,
                        new Swagger2ControllerWebMvc(environment, documentationCache, mapper, jsonSerializer));

那么有没有什么方法可以对这种第三方类库的非spring bean对象进行AOP增强呢。

答案是基于AspectJ的AOP实现。基于AspectJ的实现有三种织入方式

  • 编译期织入:利用ajc编译器替代javac编译器,直接将源文件(Java和Aspect)编译成class文件并将切面织入代码中
  • 编译后织入:利用ajc编译器将javac编译器编译后的class文件或者jar文件织入切面代码
  • 加载时织入:不使用ajc编译器,利用aspectjweaver.jar工具,使用java agent代理在类加载阶段将切面织入代码

前两种不太适合我们的场景,这里我们着重看下类加载时织入的实现,即Spring AOP LTW(Load Time Weaving)。

主要步骤如下:

增加maven依赖
<dependency>
    <groupId>org.aspectj</groupId>
    <artifactId>aspectjrt</artifactId>
    <version>1.8.13</version>
</dependency>
<dependency>
    <groupId>org.aspectj</groupId>
    <artifactId>aspectjweaver</artifactId>
    <version>1.8.13</version>
</dependency>
添加aop.xml文件
image-20210412113515722

文件内容:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE aspectj PUBLIC "-//AspectJ//DTD//EN" "http://www.eclipse.org/aspectj/dtd/aspectj.dtd">
<aspectj>
    <!--要织入切面的目标类-->
    <weaver>
        <include within="springfox.documentation.swagger2.web..*" />
        <include within="com.youdao.kids.aspect.SwaggerAspect" />
    </weaver>
    <!--切面类-->
    <aspects>
        <aspect name="com.youdao.kids.aspect.SwaggerAspect" />
    </aspects>
</aspectj>
开启EnableLoadTimeWeaving
@SpringBootApplication(scanBasePackages = {"com.youdao.kids", "springfox"})
@EnableLoadTimeWeaving(aspectjWeaving = EnableLoadTimeWeaving.AspectJWeaving.AUTODETECT)
public class SwaggerExampleApplication {

    public static void main(String[] args) {
        SpringApplication.run(SwaggerExampleApplication.class, args);
    }

}

@Aspect
public class SwaggerAspect {

    private static final List<String> EXCLUDE_URL = Arrays.asList("test1-kids.youdao.com/v2/api-docs");

    @Before("execution(* springfox.documentation.swagger2.web.Swagger2ControllerWebMvc.getDocumentation(..))")
    public void switchDataSource() throws Exception {
        ServletRequestAttributes attributes = (ServletRequestAttributes)RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = attributes.getRequest();
        HttpServletResponse response = attributes.getResponse();
        String url = request.getRequestURL().toString();
        if (EXCLUDE_URL.stream().anyMatch(item -> url.contains(item))) {
            response.sendError(403);
        }
    }
}
添加javaagent启动参数
// 这里需要把这两个jar包提前放到工程的lib的目录下
-javaagent:"lib/aspectjweaver-1.8.13.jar" -javaagent:"lib/spring-instrument-5.1.9.RELEASE.jar"

至此,基于LTW的实现完成了,也可以做到拦截/v2/api-docs接口的外网请求。

代码地址

需要demo代码地址请私信

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,088评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,715评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,361评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,099评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,987评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,063评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,486评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,175评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,440评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,518评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,305评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,190评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,550评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,880评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,152评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,451评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,637评论 2 335

推荐阅读更多精彩内容