1. 程式人生 > >使用springMvc解決跨域問題

使用springMvc解決跨域問題

前言

瀏覽器出於安全考慮,限制了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

  • 對這個類庫的使用和分析將在下一篇展開

  • 喜歡用這個CORSFilter主要是因為它支援CORS配置檔案,能夠自動讀取classpath下的cors.properties,還有file watching的功能

示例程式碼下載



作者:Jearton
連結:http://www.jianshu.com/p/d05303d34222
來源:簡書
著作權歸作者所有。商業轉載請聯絡作者獲得授權,非商業轉載請註明出處。