前后端分离之CORS跨域访问踩坑总结

前言

前后端分离的开发模式越来越流行,目前绝大多数的公司与项目都采取这种方式来开发,它的好处是前端可以只专注于页面实现,而后端则主要负责接口开发,前后端分工明确,彼此职责分离,不再高度耦合,但是由于这种开发模式将前后端项目分开来独立部署,所以将必不可免的会碰到跨域问题.
什么是跨域
跨域指的是浏览器不能执行其他网站的脚本.它是由浏览器的同源策略造成的,是浏览器对javascript施加的安全限制.目前所有的浏览器都实行同源策略,所谓的同源指的是

  • 协议相同
  • 域名相同
  • 端口相同

是否跨域判定如下:
http://www.123.com/index.html 调用 http://www.123.com/server.php(非跨域)
http://www.123.com/index.html 调用 http://www.456.com/server.php(主域名不同:123/456,跨域)
http://abc.123.com/index.html 调用 http://def.123.com/server.php(子域名不同:abc/def,跨域)
http://www.123.com:8080/index.html 调用 http://www.123.com:8081/server.php(端口不同:8080/8081,跨域)
http://www.123.com/index.html 调用 https://www.123.com/server.php(协议不同:http/https,跨域)
请注意:localhost和127.0.0.1虽然都指向本机,但也属于跨域.
浏览器执行javascript脚本时,会检查这个脚本属于哪个页面,如果不是同源页面,就不会被执行.

如何解决

常见的解决方案有JSONP和CORS等多种方式,这里我们采用了CORS的方式来统一处理项目中的跨域问题.
cors是一种w3c标准,全称是"跨域资源共享(Cross-origin resource sharing)".它允许浏览器向跨源服务器发出XMLHttpRequest请求,从而克服了同源使用的限制.下面重点来看下我们在SpringBoot项目中使用cors处理跨域时所遇到的问题.
首先创建一个WebMvcConfig类继承自WebMvcConfigurerAdapter类并覆写其中的addCorsMappings方法

    /**
     * 允许跨域访问
     *
     * @param registry
     */
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**").allowedOrigins("*")
                .allowedMethods("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS")
                .allowedHeaders("Accept", "Origin", "X-Requested-With", "Content-Type",
                        "Last-Modified", "device", "token")
                .exposedHeaders("Set-Cookie")
                .allowCredentials(true).maxAge(3600);

    }

接着我们在之前搭建好的Vue项目环境中创建一个vue文件,用来向springboot项目发起跨域请求.

<template>
   <div>
       <input v-model="message" placeholder="edit me"/>
       <p>Message is: {{ message }}</p>
       <input type="button" @click="get()" value="测试">
   </div>
</template>
<script>
  export default {
    data () {
       return {
          message: 'This is my from'
       }
    },
    methods:{
        get:function(){          
            $.ajax({
        url:'http://localhost:8080/win/api/test/cors',//测试接口
        type:'post',
        beforeSend:(xhr) => {
                        xhr.setRequestHeader("token", "web_session_key-082ba2a3-7d8e-407c-8bd0-2fbc430b0dbf");
            xhr.setRequestHeader("device","APP");
                 },
         success:function(data){
              console.log(data);
         }
        });
        }
    }
}
</script>

然后启动前端vue项目

npm run dev

打开浏览器输入http://localhost:8081/点击页面中的测试按钮发起接口调用,注意vue工程的端口为8081,springboot工程的端口为8080,不符合同源规则所以访问后端接口时会出现跨域.此时打开chrome控制台会发现

image.png

通过chrome控制台查看接口请求的headers信息是这样的
image.png

可以看到这个接口的Request Method为OPTIONS,而不是我们在ajax代码中所设置的POST,仔细看控制台报的异常Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.可以发现其中的关键字preflight(预检请求),这个其实就是问题的所在了,再次google了一下cors的相关知识,才知道原来浏览器将cors请求分为两类即简单请求和非简单请求.
简单请求
只要同时满足以下条件就属于简单请求
请求方法是以下三种方法之一:GET POST HEAD
Http的头信息不超出以下几种字段:Accept Accept-Language Content-Language Last-Event-ID Content-Type 只限于三个值application/x-www-form-urlencoded、multipart/form-data、text/plain
非简单请求
其他的请求皆属于非简单请求.
注意在cors定义中,如果头信息中的Content-Type不设置,则默认值为json/application,如果Content-Type值不为application/x-www-form-urlencoded,multipart/form-data或text/plain,都被视为非简单请求,即预检请求.
主要问题描述
浏览器对这两种请求的处理是不一样的.
如果是简单请求的话,一次完整的请求过程是不需要服务端预检的,直接响应回客户端,而非简单请求则浏览器会在发送真正请求之前先用OPTIONS发送一次预检请求preflight request,从而获知服务端是否允许该跨域请求,当服务器确认允许之后,才会发起真正的请求.那么前面所出现的异常很明显就是由这个preflight request导致的了,回头看代码可发现我们在发起ajax调用时往请求头里面塞了两个自定义的header参数token和device,所以此次调用属于非简单请求,并触发了一次预检请求,由于我们服务端使用了shiro权限认证框架,通过拦截用户请求头中传过来的token信息来判定客户端是否为非法调用,而Preflight请求过程中并不会携带我们自定义的token信息到服务器,这样服务器校验就永远也无法通过了,就算是合法的登录用户也会被拦截.
解决的办法
既然已经知道原因了,那么自然解决这个问题的办法就是交给后端了,在后端检测到该请求为预检请求时,不让它继续往下走(也可以返回一个2xx的状态码),直接告诉浏览器此次跨域请求可以继续,很明显过滤器符合我们的要求,我们来把之前的addCorsMappings方法去掉,重写这块代码,在网上查了很久,尝试了很多种方法,得出来的结论大致有如下两种方式:
第一种方案采用过滤器的机制

    @Bean
    public FilterRegistrationBean corsFilter() {
        return new FilterRegistrationBean(new Filter() {
            public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
                    throws IOException, ServletException {
                HttpServletRequest request = (HttpServletRequest) req;
                HttpServletResponse response = (HttpServletResponse) res;
                String method = request.getMethod();
                // this origin value could just as easily have come from a database
                response.setHeader("Access-Control-Allow-Origin", "*");
                response.setHeader("Access-Control-Allow-Methods", "GET, HEAD, POST, PUT, DELETE, OPTIONS");
                response.setHeader("Access-Control-Max-Age", "3600");
                response.setHeader("Access-Control-Allow-Credentials", "true");
                response.setHeader("Access-Control-Allow-Headers", "Accept, Origin, X-Requested-With, Content-Type,Last-Modified,device,token");
                if ("OPTIONS".equals(method)) {//检测是options方法则直接返回200
                    response.setStatus(HttpStatus.OK.value());
                } else {
                    chain.doFilter(req, res);
                }
            }

            public void init(FilterConfig filterConfig) {
            }

            public void destroy() {
            }
        });
    }

第二种方案
1.创建一个类MyCorsRegistration继承自CorsRegistration

public class MyCorsRegistration extends CorsRegistration {

    public MyCorsRegistration(String pathPattern) {
        super(pathPattern);
    }

    @Override
    public CorsConfiguration getCorsConfiguration() {
        return super.getCorsConfiguration();
    }
}

2.然后在WebMvcConfig类中增加一个方法里来处理跨域问题.

    @Bean
    public FilterRegistrationBean filterRegistrationBean() {
        // 对响应头进行CORS授权
        MyCorsRegistration corsRegistration = new MyCorsRegistration("/**");
        corsRegistration.allowedOrigins("*")
                .allowedMethods(HttpMethod.GET.name(), HttpMethod.HEAD.name(), HttpMethod.POST.name(),
                        HttpMethod.PUT.name(), HttpMethod.OPTIONS.name())
                .allowedHeaders("Accept", "Origin", "X-Requested-With", "Content-Type",
                        "Last-Modified", "device", "token")
                .exposedHeaders(HttpHeaders.SET_COOKIE)
                .allowCredentials(true)
                .maxAge(3600);

        // 注册CORS过滤器
        UrlBasedCorsConfigurationSource configurationSource = new UrlBasedCorsConfigurationSource();
        configurationSource.registerCorsConfiguration("/**", corsRegistration.getCorsConfiguration());
        CorsFilter corsFilter = new CorsFilter(configurationSource);
        return new FilterRegistrationBean(corsFilter);
    }

其实第二种方案本质上也是过滤器的机制,看源码可知CorsFilter继承自spring的过滤器OncePerRequestFilter,来看看它的doFilterInternal方法

    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        if (CorsUtils.isCorsRequest(request)) {
            CorsConfiguration corsConfiguration = this.configSource.getCorsConfiguration(request);
            if (corsConfiguration != null) {
                boolean isValid = this.processor.processRequest(corsConfiguration, request, response);
                if (!isValid || CorsUtils.isPreFlightRequest(request)) {
                    return;
                }
            }
        }

        filterChain.doFilter(request, response);
    }

代码大致意思是先判断是否为cors请求再判断是否预检请求,符合条件则直接return了.
我们来看看最后的请求结果,控制台再也没有报错了,浏览器一共发送了两次接口请求,第一次是OPTIONS请求,第二次是POST请求.
OPTIONS请求:


image.png

POST请求:


image.png

从上面第一个图中可以看到这个预检请求主要携带的请求header信息如下(并没有携带我们自定义的header信息,第二个图中可以清楚的看到我们自定义的header信息了):
Access-Control-Request-Headers:device,token 真实请求携带的Header中的信息
Access-Control-Request-Method:POST 我真实请求的方法是什么
Origin:http://localhost:8081 告诉服务器我的域名
然后是服务器端给我们返回的Preflight Response:
Access-Control-Allow-Credentials:true
Access-Control-Allow-Headers:Accept, Origin, X-Requested-With, Content-Type,Last-Modified,device,token
Access-Control-Allow-Methods:GET, HEAD, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Origin:*
Access-Control-Max-Age:3600
值得注意的是上面的Access-Control-Max-Age这个header,它告诉了服务器在多少秒内,不需要再发送预检请求,可以缓存该结果,即只发送真正的请求,不同浏览器有不同的上限.在Firefox中,上限是24h(即86400秒),而在Chrome中则是10min(即600秒).Chrome同时规定了一个默认值5秒.如果值为-1,则表示禁用缓存,每一次请求都需要提供预检请求,即用OPTIONS请求进行检测.它对完全一样的url的缓存设置生效,多一个参数也视为不同url.也就是说,如果设置了10分钟的缓存,在10分钟内,所有请求第一次会产生options请求,第二次以及第二次以后就只发送真正的请求了,这也是一个很有效的性能优化手段(另外注意下在chrome浏览器中如果开启了Disable cache,则表示本地不缓存,会导致每次请求都发一次预检测).

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

推荐阅读更多精彩内容