解决跨域问题,看似简单的问题却是一步一坑

1. 背景

我们的项目作为一个通用型的低代码网关,为很多业务方提供着通过简单托拉拽来动态封装REST接口的能力。以前提供的大多数场景都是直接将封装给后端进行使用,所以不会有跨域问题。而这次场景比较特殊,由于接口过于简单,并且后端资源紧张,前端直接调用了低代码网关提供的动态REST接口,因此出现了跨域问题。

2. 什么是跨域

出于安全考虑,默认情况下浏览器会限制通过脚本非同源的URL发起请求。

上面定义中有两个关键词 “通过脚本” 和 “非同源”。

通过脚本是指通过javascript来发起请求,因此在页面上的form表单和img的src是不受跨域限制的。因此我们可以很轻松的盗链其他网站好看的图片(当然前提是被盗的网站没有进行防盗链)。
同源只的是协议、域名、端口号要完全相同,因此以上三个条件只要有一个不满足,就是非同源。示例如下:

当前页面url被请求页面url是否跨域原因
http://www.9527.com/ahttp://www.9527.com/b同源,协议、域名、端口一致
http://www.9527.com/ahttps://www.9527.com/b非同源,协议不同
http://www.9527.com/ahttp://test.9527.com/b非同源,域名不同
http://www.9527.com:80/ahtttp://www.9527.com:8080/a非同源,端口不同

3.跨域解决办法

  • 通过jsonp解决跨域
  • 通过document.domain + iframe解决跨域
  • location.hash + iframe跨域
  • window.name + iframe跨域
  • postMessage解决跨域
  • 跨域资源共享(CORS)

以上方案都有各自的局限性,相关内容可以参考【参考内容1】中的介绍,这里不再赘述。本文中使用的方案为CORS。

4. CORS方案解决问题

4.1 天真的springboot方案

出现这个问题以后,感觉就是一个很简单的跨域问题,就委托其他人去处理了。在处理的过程中因为我们的项目时springboot的原因,该同学就复制了网上通用的springboot的跨域解决代码来进行解决,网上代码大体如下:


import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
 
@Configuration
public class CorsConfig implements WebMvcConfigurer {
 
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOrigins("*")
                .allowedMethods("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS")
                .allowCredentials(true)
                .maxAge(3600)
                .allowedHeaders("*");
    }

这种方式是基于SpringMVC的Interceptor来实现的,因此在一些情况下会由于Interceptor的加载和执行顺序不同而导致跨域问题解决失败。具体内容请参考【参考内容2】。

由于这种由于这种方式是基于SpringMVC来配置的,那么只能处理所有经过DispatcherServlet进行处理的请求(也就是springMVC自身的Controller)。但是是我们的服务比较特殊,虽然项目本身是用springboot写的,而且项目本身提供的各种REST接口也是基于springMVC的controller对外提供的,但是我们低代码部动态生成的REST接口却是基于动态Servlet来提供的。

因此,不管配置的对不对,对于我们的场景来说问题就是白配!!!首战失利!!!

4.2手动给response中添加CORS响应头

由于我们项目的核心是“低代码的动态服务”,所有对外提供的REST、WEBSERVICE等等能力都由用户在画布上进行流程编排后动态进行发布的。所以我们可以很容易滴对response相应头进行控制,即很容易想相应头中插入必要的信息。最终插入了如下的信息。

response.setHeader("Access-Control-Allow-Origin", "*");
response.setHeader("Access-Control-Allow-Methods", "*");
response.setHeader("Access-Control-Max-Age", "3600");
response.setHeader("Access-Control-Allow-Headers", "*");
response.setHeader("Access-Control-Allow-Credentials","true");

这段代码设置了几乎所有CORS的响应头,看似可以完美解决相关问题。但是现实又狠狠地打了脸。
在进行访问的时候,通过浏览器进行测试,在浏览器的控制台中依然报跨域问题,而查看浏览器的network相关的请求,发现没有得到任何返回结果。
在这里插入图片描述
但是从浏览器控制台中的报错信息,敏感地捕捉到了 preflight request,也就是浏览器发起的是预请求。
没了解过什么是预请求,于是进行了抓包,发现浏览器发出的请求如下(已经翻译成了CURL命令)

curl -H "Host: baize-pre.crxn.cn" -H "Accept: */*" -H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: content-type,sign,timestamp,x-requested-with" -H "Origin: http://b2coper-uat.crxn.cn" \
-H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36" \
-H "Sec-Fetch-Mode: cors" -H "Referer: http://b2coper-uat.crxn.cn/" \
-H "Accept-Language: zh-CN,zh;q=0.9" -X OPTIONS --compressed "http://xxxxxxxx"

从该请求中可以发现,浏览器并没有真正的进行我们的POST请求,而是发起了一个OPTIONS请求,也就是所谓的预请求。于是果断研究了预请求,详情可以参考【参考内容4】,这里对结果进行简单的阐述。

CORS请求分为 简单请求 和 非简单请求,浏览器在发起非简单请求时,会先发出预请求来进行跨域验证。

  • 简单请求:同时满足以下2个条件的,即为简单请求。

    (1) 请求方法为HEAD/GET/POST。
    (2) HTTP的头信息不超出以下几种字段:Accept、Accept-Language、Content-Language、Last-Event-ID、Content-Type(只限于三个值application/x-www-form-urlencoded、multipart/form-data、text/plain)。

对于简单请求,浏览器自动在头信息之中添加一个Origin字段,用于说明本次请求来自哪个源(协议+域名+端口)。服务器根据Origin字段,决定是否同意这次请求。服务器响应消息中包含“Access-Control-Allow-Origin”时,表示同意请求。

  • 非简单请求:不满足以上2个条件的,都为非简单请求。

对于非简单请求,在正式通信之前,浏览器会增加一次HTTP查询请求,称为预检请求。浏览器查询服务器,当前页面所在的源是否在服务器的许可名单之中,以及可以使用哪些HTTP请求方法和头信息字段。预检通过后,浏览器向服务器发送简单请求。

我们的客户端和服务端交互的时候使用的json格式,因此必然不是简单请求,需要对OPTIONS请求进行携带跨域头信息的200返回。

4.3使用Filter来解决问题

为了统一收口,决定使用Servlet Filter来解决跨域问题。Filter的代码如下:

@WebFilter(filterName = "corsFilter", urlPatterns = "/*")
public class CorsFilter implements Filter {
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletResponse response = (HttpServletResponse) servletResponse;
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        if (request.getMethod().equals("OPTIONS")) {
            fillCorsHeaders(servletRequest, servletResponse);
            response.setStatus(200);
            return;
        }
        filterChain.doFilter(servletRequest, servletResponse);
        fillCorsHeaders(servletRequest, servletResponse);
        
    }


    private void fillCorsHeaders(ServletRequest servletRequest, ServletResponse servletResponse) {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;
        response.setHeader("Access-Control-Allow-Origin", "*");
        response.setHeader("Access-Control-Allow-Methods", "*");
        response.setHeader("Access-Control-Max-Age", "3600");
        response.setHeader("Access-Control-Allow-Headers", "*");
        response.setHeader("Access-Control-Allow-Credentials","true");
    }
}

在该拦截器中对OPTIONS请求进行了响应,会返回带有跨域头的200响应。同时也为所有的请求在执行后添加跨域响应头。
其实参考springMVC的实现,也是如此,只不过用的是Interceptor而已,代码如下。

//作为RequestMappingHandlerMapping的父类,掌控着cors的请求和响应
public abstract class AbstractHandlerMapping extends WebApplicationObjectSupport implements HandlerMapping, Ordered {
 
//所有的corsRegistry最终都会进入这个属性中
private final UrlBasedCorsConfigurationSource globalCorsConfigSource = new UrlBasedCorsConfigurationSource();
 
//实际拦截请求进行cors处理的地方,
	protected HandlerExecutionChain getCorsHandlerExecutionChain(HttpServletRequest request,
			HandlerExecutionChain chain, @Nullable CorsConfiguration config) {
        //如果是预检请求
		if (CorsUtils.isPreFlightRequest(request)) {
			HandlerInterceptor[] interceptors = chain.getInterceptors();
			chain = new HandlerExecutionChain(new PreFlightHandler(config), interceptors);
		}
        //正常请求
		else {
			chain.addInterceptor(new CorsInterceptor(config));
		}
		return chain;
	}

想要这个Filter生效,只需要在springboot的application上加上@ServletComponentScan注解即可:

@SpringBootApplication
@ServletComponentScan
public class ApplicationForWeb extends SpringBootServletInitializer {

    /**
     * SpringBoot 集成容器启动方式
     */
    public static void main(String[] args) {
        SpringApplication.run(ApplicationForWeb.class);
    }

    /**
     * 外部容器启动方式 (Tomcat, JBoss 等)
     */
    @Override
    protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) {
        return builder.sources(ApplicationForWeb.class);
    }

}

4.4 来自Access-Control-Allow-Credentials的教训

本以为自定义了Filter以后就会一切顺利,但是在测试到时候又收到了来自Access-Control-Allow-Credentials的教训。
Access-Control-Allow-Credentials的作用是允许跨域传递cookies信息,但是将Access-Control-Allow-Credentials设置为true以后需要注意一下两点。

  • (1)CORS响应头中的Access-Control-Allow-Origin不能为*号,也就是不能再指定允许任意来源的跨域请求,必须明确来源,而我们的需求是任意来源,因此做出来如下修改。直接使用请求头中的Origin信息来作为允许跨域的响应头即可。
  • (2)CORS响应头中的Access-Control-Allow-Headers不能为*号,也就是不能指定允许任意的请求头,必须明确允许的请求头。

最终修改后的代码如下:

    private void fillCorsHeaders(ServletRequest servletRequest, ServletResponse servletResponse) {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;
        response.setHeader("Access-Control-Allow-Origin", request.getHeader("Origin"));
        response.setHeader("Access-Control-Allow-Methods", "*");
        response.setHeader("Access-Control-Max-Age", "3600");
        response.setHeader("Access-Control-Allow-Headers", "User-Agent,X-Requested-With,X-Stage,X-Sdk-Date");
        response.setHeader("Access-Control-Allow-Credentials","true");
    }

4.5 临门一脚,来自版本的教训

经过以上的修改,在本地已经可以可以成功地进行跨域请求了。但是部署到测试环境以后却一直报Filter初始化失败,导致服务无法启动。

本地和线上的区别在于,本地是基于springboot直接启动的,而线上则是基于外部tomcat来启动的。而springboot内嵌的tomcat的版本是9以上的版本,版本较高,而外部的tomcat是8的版本,版本较低。

tomcat8是用1.7的JDK编写和编译的,而在1.7的版本中在interface中没有default方法,而我本地环境的Filter接口是这样的。

public interface Filter {
    public default void init(FilterConfig filterConfig) throws ServletException {}
    public void doFilter(ServletRequest request, ServletResponse response,
            FilterChain chain) throws IOException, ServletException;
    public default void destroy() {}
}

从代码中可以看到,init 和 destroy方法都用了default,也就导致我自定义的Filter只需要实现doFilter方法即可。这在本地是本有问题的,但是到了线上的tomcat8环境,我的Filter就没有覆写init和destroy方法,所以导致服务无法启动。

最终在自定义的Filter中覆写了以上方法,问题完美解决。

5. 总结

  • CORS请求有简单请求和非简单请求,非简单请求中OPTIONS预请求操作,需要进行处理。
  • CORS在允许cookies跨域后,允许的Origin和Header是不能为*的。

参考内容

1.什么是跨域
2. SpringMVC的Interceptor和Cors冲突【优先级配置】
3. SpringBoot-实现CORS跨域原理及解决方案
4. 配置跨域访问API

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
跨域(Cross-Origin)指的是在浏览器中,当一个网页的 JavaScript 代码尝试访问不同源(即不同域名、端口或协议)的资源时,就会触发跨域问题。浏览器为了保护用户的安全,限制了跨域资源的访问。 跨域问题可以通过以下几种方式进行解决: 1. JSONP(JSON with Padding):JSONP 是一种利用 `<script>` 标签可以跨域加载资源的特性来实现跨域请求的方法。服务器端返回的数据需要包裹在一个函数调用中,前端通过动态创建 `<script>` 标签来获取数据。但是 JSONP 只支持 GET 请求,并且存在安全性问题,容易受到 XSS 攻击。 2. CORS(Cross-Origin Resource Sharing):CORS 是一种现代浏览器支持的跨域解决方案。它通过在服务器端设置响应头来控制是否允许跨域请求。具体来说,服务器需要在响应头中设置 `Access-Control-Allow-Origin` 字段来指定允许的源(域名、端口或通配符 *),以及其他相关的 CORS 相关字段。通过这种方式,浏览器会根据响应头的配置判断是否允许跨域请求。 3. 代理服务器:可以通过在服务器端设置代理服务器来解决跨域问题。前端代码发送请求给同源的服务器,然后由服务器端代理转发请求到目标服务器,并将响应结果返回给前端。这种方式需要在服务器端进行配置,并且会增加服务器的负载。 4. WebSocket:WebSocket 是一种全双工通信协议,它可以在浏览器和服务器之间建立持久连接。由于 WebSocket 协议并不受同源策略的限制,因此可以用于跨域通信。 5. postMessage:postMessage 是一种在不同窗口或 iframe 之间进行跨域通信的方法。通过调用 `window.postMessage` 方法,可以在不同窗口之间传递消息。 需要注意的是,以上解决方案的可行性和适用性取决于具体的情况和需求。在使用时需要根据实际情况选择合适的解决方案。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值