SpringMVC开启CORS支持

前言

浏览器出于安全考虑,限制了JS发起跨站请求,使用XHR对象发起请求必须遵循同源策略(SOP:Same Origin Policy),跨站请求会被浏览器阻止,这对开发者来说是很痛苦的一件事,尤其是要开发前后端分离的应用时。

在现代化的Web开发中,不同网络环境下的资源数据共享越来越普遍,同源策略可以说是在一定程度上限制了Web API的发展。

简单的说,CORS就是为了AJAX能够安全跨域而生的。至于CORS的安全性研究,本文不做探讨。


目录

  1. CORS浅述

  2. 如何使用?CORS的HTTP头

  3. 初始项目准备

  4. CorsFilter: 过滤器阶段的CORS

  5. CorsInterceptor: 拦截器阶段的CORS

  6. @CrossOrigin:Handler阶段的CORS

  7. 小结

  8. 追求极致的开发体验:整合第三方CORSFilter

  9. 示例代码下载


CORS浅述

名词解释:跨域资源共享(Cross-Origin Resource Sharing)

概念:是一种跨域机制、规范、标准,怎么叫都一样,但是这套标准是针对服务端的,而浏览器端只要支持HTML5即可。

作用:可以让服务端决定哪些请求源可以进来拿数据,所以服务端起主导作用(所以出了事找后台程序猿,无关前端^ ^)

常用场景:

  • 前后端完全分离的应用,比如Hybrid App
  • 开放式只读API,JS能够自由访问,比如地图、天气、时间……

如何使用?CORS的HTTP头

要实现CORS跨域其实非常简单,说白了就是在服务端设置一系列的HTTP头,主要分为请求头和响应头,在请求和响应时加上这些HTTP头即可轻松实现CORS

请求头和响应头信息都是在服务端设置好的,一般在Filter阶段设置,浏览器端不用关心,唯一要设置的地方就是:跨域时是否要携带cookie

  • HTTP请求头:
#请求域
Origin: ”http://localhost:3000“

#这两个属性只出现在预检请求中,即OPTIONS请求
Access-Control-Request-Method: ”POST“
Access-Control-Request-Headers: ”content-type“
  • HTTP响应头:
#允许向该服务器提交请求的URI,*表示全部允许,在SpringMVC中,如果设成*,会自动转成当前请求头中的Origin
Access-Control-Allow-Origin: ”http://localhost:3000“

#允许访问的头信息
Access-Control-Expose-Headers: "Set-Cookie"

#预检请求的缓存时间(秒),即在这个时间段里,对于相同的跨域请求不会再预检了
Access-Control-Max-Age: ”1800”

#允许Cookie跨域,在做登录校验的时候有用
Access-Control-Allow-Credentials: “true”

#允许提交请求的方法,*表示全部允许
Access-Control-Allow-Methods:GET,POST,PUT,DELETE,PATCH

初始项目准备

  • 补充一下,对于简单跨域和非简单跨域,可以这么理解:
  1. 简单跨域就是GET,HEAD和POST请求,但是POST请求的"Content-Type"只能是application/x-www-form-urlencoded, multipart/form-data 或 text/plain
  2. 反之,就是非简单跨域,此跨域有一个预检机制,说直白点,就是会发两次请求,一次OPTIONS请求,一次真正的请求

  • 首先新建一个静态web项目,定义三种类型的请求:简单跨域请求,非简单跨域请求,带Cookie信息的请求(做登录校验)。代码如下:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>跨域demo</title>
    <link rel="stylesheet" href="node_modules/amazeui/dist/css/amazeui.min.css">
</head>

<body class="am-container">
<!--简单跨域-->
<button class="am-btn am-btn-primary" onclick="getUsers(this)">
    简单跨域: 获取用户列表
</button>
<p class="am-text-danger"></p>

<!--非简单跨域-->
<button class="am-btn am-btn-primary" onclick="addUser(this)">
    非简单跨域: 添加用户(JSON请求)
</button>
<input type="text" placeholder="用户名">
<p class="am-text-danger"></p>

<!--检查是否登录-->
<button class="am-btn am-btn-primary am-margin-right" onclick="checkLogin(this)">
    登录校验
</button>
<p class="am-text-danger"></p>

<!--登录-->
<button class="am-btn am-btn-primary" onclick="login(this)">
    登录
</button>
<input type="text" placeholder="用户名">
<p class="am-text-danger"></p>
</body>
<script src="node_modules/jquery/dist/jquery.min.js"></script>
<script src="node_modules/amazeui/dist/js/amazeui.js"></script>
<script>
    function getUsers(btn) {
        var $btn = $(btn);
        $.ajax({
            type: 'get',
            url: 'http://localhost:8080/api/users',
            contentType: "application/json;charset=UTF-8"
        }).then(
                function (obj) {
                    $btn.next('p').html(JSON.stringify(obj));
                },
                function () {
                    $btn.next('p').html('error...');
                }
        )
    }

    function addUser(btn) {
        var $btn = $(btn);
        var name = $btn.next('input').val();
        if (!name) {
            $btn.next('input').next('p').html('用户名不能为空');
            return;
        }
        $.ajax({
            type: 'post',
            url: 'http://localhost:8080/api/users',
            contentType: "application/json;charset=UTF-8",
            data: name,
            dataType: 'json'
        }).then(
                function (obj) {
                    $btn.next('input').next('p').html(JSON.stringify(obj));
                },
                function () {
                    $btn.next('input').next('p').html('error...');
                }
        )
    }

    function checkLogin(btn) {
        var $btn = $(btn);
        $.ajax({
            type: 'get',
            url: 'http://localhost:8080/api/user/login',
            contentType: "application/json;charset=UTF-8",
            xhrFields: {
                withCredentials: true
            }
        }).then(
                function (obj) {
                    $btn.next('p').html(JSON.stringify(obj));
                },
                function () {
                    $btn.next('p').html('error...');
                }
        )
    }

    function login(btn) {
        var $btn = $(btn);
        var name = $btn.next('input').val();
        if (!name) {
            $btn.next('input').next('p').html('用户名不能为空');
            return;
        }
        $.ajax({
            type: 'post',
            url: 'http://localhost:8080/api/user/login',
            contentType: "application/json;charset=UTF-8",
            data: name,
            dataType: 'json',
            xhrFields: {
                withCredentials: true
            }
        }).then(
                function (obj) {
                    $btn.next('input').next('p').html(JSON.stringify(obj));
                },
                function () {
                    $btn.next('input').next('p').html('error...');
                }
        )
    }
</script>
</html>
  • 然后启动web项目(这里推荐一个所见即所得工具:browser-sync)
browser-sync start --server --files "*.html"


  • 接来下,做服务端的事情,新建一个SpringMVC项目,这里推荐一个自动生成Spring种子项目的网站:http://start.spring.io/

    种子项目
    种子项目

  • 项目结构如下:


    项目结构
    项目结构
  • 在pom.xml中引入lombok和guava

<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>19.0</version>
</dependency>

<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.16.8</version>
</dependency>
  • 模拟数据源:UserDB
public class UserDB {

    public static Cache<String, User> userdb = CacheBuilder.newBuilder().expireAfterWrite(1, TimeUnit.DAYS).build();

    static {
        String id1 = UUID.randomUUID().toString();
        String id2 = UUID.randomUUID().toString();
        String id3 = UUID.randomUUID().toString();
        userdb.put(id1, new User(id1, "jear"));
        userdb.put(id2, new User(id2, "tom"));
        userdb.put(id3, new User(id3, "jack"));
    }
}
  • 编写示例控制器:UserController
@RestController
@RequestMapping("/users")
public class UserController {

    @RequestMapping(method = RequestMethod.GET)
    List<User> getList() {
        return Lists.newArrayList(userdb.asMap().values());
    }

    @RequestMapping(method = RequestMethod.POST)
    List<String> add(@RequestBody String name) {
        if (userdb.asMap().values().stream().anyMatch(user -> user.getName().equals(name))) {
            return Lists.newArrayList("添加失败, 用户名'" + name + "'已存在");
        }
        String id = UUID.randomUUID().toString();
        userdb.put(id, new User(id, name));
        return Lists.newArrayList("添加成功: " + userdb.getIfPresent(id));
    }
}
  • 编写示例控制器:UserLoginController
@RestController
@RequestMapping("/user/login")
public class UserLoginController {

    @RequestMapping(method = RequestMethod.GET)
    Object getInfo(HttpSession session) {
        Object object = session.getAttribute("loginer");
        return object == null ? Lists.newArrayList("未登录") : object;
    }

    @RequestMapping(method = RequestMethod.POST)
    List<String> login(HttpSession session, @RequestBody String name) {
        Optional<User> user = userdb.asMap().values().stream().filter(user1 -> user1.getName().equals(name)).findAny();
        if (user.isPresent()) {
            session.setAttribute("loginer", user.get());
            return Lists.newArrayList("登录成功!");
        }
        return Lists.newArrayList("登录失败, 找不到用户名:" + name);
    }
}
  • 最后启动服务端项目
mvn clean package
debug模式启动Application


  • 到这里,主要工作都完成了,打开浏览器,访问静态web项目,打开控制台,发现Ajax请求无法获取数据,这就是同源策略的限制
  • 下面我们一步步来开启服务端的CORS支持

CorsFilter: 过滤器阶段的CORS

@Configuration
public class WebConfig extends WebMvcConfigurerAdapter {

    @Bean
    public FilterRegistrationBean filterRegistrationBean() {
        // 对响应头进行CORS授权
        MyCorsRegistration corsRegistration = new MyCorsRegistration("/**");
        corsRegistration.allowedOrigins(CrossOrigin.DEFAULT_ORIGINS)
                .allowedMethods(HttpMethod.GET.name(), HttpMethod.HEAD.name(), HttpMethod.POST.name(), HttpMethod.PUT.name())
                .allowedHeaders(CrossOrigin.DEFAULT_ALLOWED_HEADERS)
                .exposedHeaders(HttpHeaders.SET_COOKIE)
                .allowCredentials(CrossOrigin.DEFAULT_ALLOW_CREDENTIALS)
                .maxAge(CrossOrigin.DEFAULT_MAX_AGE);

        // 注册CORS过滤器
        UrlBasedCorsConfigurationSource configurationSource = new UrlBasedCorsConfigurationSource();
        configurationSource.registerCorsConfiguration("/**", corsRegistration.getCorsConfiguration());
        CorsFilter corsFilter = new CorsFilter(configurationSource);
        return new FilterRegistrationBean(corsFilter);
    }
}
  • 现在测试一下“简单跨域”和“非简单跨域”,已经可以正常响应了


    浏览器图片
    浏览器图片
  • 再来测试一下 “登录校验” 和 “登录”,看看cookie是否能正常跨域


    浏览器图片
    浏览器图片
  • 如果把服务端的allowCredentials设为false,或者ajax请求中不带{withCredentials: true},那么登录校验永远都是未登录,因为cookie没有在浏览器和服务器之间传递


CorsInterceptor: 拦截器阶段的CORS

既然已经有了Filter级别的CORS,为什么还要CorsInterceptor呢?因为控制粒度不一样!Filter是任意Servlet的前置过滤器,而Inteceptor只对DispatcherServlet下的请求拦截有效,它是请求进入Handler的最后一道防线,如果再设置一层Inteceptor防线,可以增强安全性和可控性。

关于这个阶段的CORS,不得不吐槽几句,Spring把CorsInteceptor写死在了拦截器链上的最后一个,也就是说如果我有自定义的Interceptor,请求一旦被我自己的拦截器拦截下来,则只能通过CorsFilter授权跨域,压根走不到CorsInterceptor,至于为什么,下面会讲到。

所以说CorsInterceptor是专为授权Handler中的跨域而写的。

废话不多说,直接上代码:

@Configuration
public class WebConfig extends WebMvcConfigurerAdapter {

    @Bean
    public FilterRegistrationBean corsFilterRegistrationBean() {
        // 对响应头进行CORS授权
        MyCorsRegistration corsRegistration = new MyCorsRegistration("/**");
        this._configCorsParams(corsRegistration);

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

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        // 配置CorsInterceptor的CORS参数
        this._configCorsParams(registry.addMapping("/**"));
    }

    private void _configCorsParams(CorsRegistration corsRegistration) {
        corsRegistration.allowedOrigins(CrossOrigin.DEFAULT_ORIGINS)
                .allowedMethods(HttpMethod.GET.name(), HttpMethod.HEAD.name(), HttpMethod.POST.name(), HttpMethod.PUT.name())
                .allowedHeaders(CrossOrigin.DEFAULT_ALLOWED_HEADERS)
                .exposedHeaders(HttpHeaders.SET_COOKIE)
                .allowCredentials(CrossOrigin.DEFAULT_ALLOW_CREDENTIALS)
                .maxAge(CrossOrigin.DEFAULT_MAX_AGE);
    }
}
  • 打开浏览器,效果和上面一样

@CrossOrigin:Handler阶段的CORS

如果把前面的代码认真写一遍,应该已经发现这个注解了,这个注解是用在控制器方法上的,其实Spring在这里用的还是CorsInterceptor,做最后一层拦截,这也就解释了为什么CorsInterceptor永远是最后一个执行的拦截器。

这是最小控制粒度了,可以精确到某个请求的跨域控制

// 先把WebConfig中前两阶段的配置注释掉,再到这里加跨域注解
@CrossOrigin(origins = "http://localhost:3000")
@RequestMapping(method = RequestMethod.GET)
List<User> getList() {
    return Lists.newArrayList(userdb.asMap().values());
}
  • 打开浏览器,发现只有第一个请求可以正常跨域


    Handler跨域
    Handler跨域

小结

三个阶段的CORS配置顺序是后面叠加到前面,而不是后面完全覆盖前面的,所以在设计的时候,每个阶段如何精确控制CORS,还需要在实践中慢慢探索……


追求更好的开发体验:整合第三方CORSFilter

  • 对这个类库的使用和分析将在下一篇展开

  • 官网:http://software.dzhuvinov.com/cors-filter.html

  • 喜欢用这个CORSFilter主要是因为它支持CORS配置文件,能够自动读取classpath下的cors.properties,还有file watching的功能


示例代码下载

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

推荐阅读更多精彩内容

  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,585评论 18 139
  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,394评论 25 707
  • 引用自HTTP访问控制(CORS) 当 Web 资源请求由其它域名或端口提供的资源时,会发起跨域 HTTP 请求(...
    有涯逐无涯阅读 2,563评论 0 4
  • 我不是一个爱蹭热点的人,但对于最近热传的“马化腾朋友圈怒怼朱啸虎”事件,我想从不同的视角,聊聊我对这事背后的想法。...
    产品志异阅读 1,036评论 4 3
  • 贾雨村是个什么样的人? 关于贾雨村这个人,书中给了详细的资料! 1.贾雨村的家世 红楼梦第一回形容他的第一个词是“...
    泪花香阅读 19,504评论 4 27