背景
项目中使用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 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的类库中定义的。
默认的四个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 getDocumentation(
@RequestParam(value = "group", required = false) String swaggerGroup,
HttpServletRequest servletRequest) {
......
}
看到这里我们不难发现,WebMvcPropertySourcedRequestMappingHandlerMapping的主要作用就是实现请求路径的动态配置,让请求更加灵活。
接下来我们来看看如何解决这个问题。
解决方法
主要有三种方式:
- ResponseBodyAdvice
- 通过拦截器实现,WebMvcPropertySourcedRequestMappingHandlerMapping实例化的过程中绑定自定义拦截器。
- Spring AOP LTW
方法1:ResponseBodyAdvice
既然自定的HanderMapping初始化未绑定拦截器,导致拦截器失效,那我们换一种思路,通过ResponseBodyAdvice配合@ControllerAdvice注解,在请求响应体返回之前,校验请求URL,若为外网请求的Swagger路径,返回403,代码如下:
@RestControllerAdvice
public class SwaggerAdvice implements ResponseBodyAdvice {
private static final List 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 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依赖
org.aspectj
aspectjrt
1.8.13
org.aspectj
aspectjweaver
1.8.13
添加aop.xml文件
文件内容:
开启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 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代码地址请私信