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閱讀 230,182評論 6 543
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,489評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,290評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,776評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,510評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,866評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,860評論 3 447
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,036評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,585評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,331評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,536評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,058評論 5 363
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,754評論 3 349
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,154評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,469評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,273評論 3 399
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,505評論 2 379

推薦閱讀更多精彩內容

  • Spring Cloud為開發人員提供了快速構建分布式系統中一些常見模式的工具(例如配置管理,服務發現,斷路器,智...
    卡卡羅2017閱讀 134,826評論 18 139
  • Android 自定義View的各種姿勢1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 172,733評論 25 708
  • 引用自HTTP訪問控制(CORS) 當 Web 資源請求由其它域名或端口提供的資源時,會發起跨域 HTTP 請求(...
    有涯逐無涯閱讀 2,600評論 0 4
  • 我不是一個愛蹭熱點的人,但對于最近熱傳的“馬化騰朋友圈怒懟朱嘯虎”事件,我想從不同的視角,聊聊我對這事背后的想法。...
    產品志異閱讀 1,059評論 4 3
  • 賈雨村是個什么樣的人? 關于賈雨村這個人,書中給了詳細的資料! 1.賈雨村的家世 紅樓夢第一回形容他的第一個詞是“...
    淚花香閱讀 19,685評論 4 27