使用springMvc解決跨域問題
前言
瀏覽器出於安全考慮,限制了JS發起跨站請求,使用XHR物件發起請求必須遵循同源策略(SOP:Same Origin Policy),跨站請求會被瀏覽器阻止,這對開發者來說是很痛苦的一件事,尤其是要開發前後端分離的應用時。
在現代化的Web開發中,不同網路環境下的資源資料共享越來越普遍,同源策略可以說是在一定程度上限制了Web API的發展。
簡單的說,CORS就是為了AJAX能夠安全跨域而生的。至於CORS的安全性研究,本文不做探討。
目錄
CORS淺述
如何使用?CORS的HTTP頭
初始專案準備
CorsFilter: 過濾器階段的CORS
CorsInterceptor: 攔截器階段的CORS
@CrossOrigin:Handler階段的CORS
小結
追求極致的開發體驗:整合第三方CORSFilter
示例程式碼下載
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
初始專案準備
- 補充一下,對於簡單跨域和非簡單跨域,可以這麼理解:
- 簡單跨域就是GET,HEAD和POST請求,但是POST請求的"Content-Type"只能是application/x-www-form-urlencoded, multipart/form-data 或 text/plain
- 反之,就是非簡單跨域,此跨域有一個預檢機制,說直白點,就是會發兩次請求,一次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跨域
小結
三個階段的CORS配置順序是後面疊加到前面,而不是後面完全覆蓋前面的,所以在設計的時候,每個階段如何精確控制CORS,還需要在實踐中慢慢探索……
追求更好的開發體驗:整合第三方CORSFilter
-
對這個類庫的使用和分析將在下一篇展開
-
喜歡用這個CORSFilter主要是因為它支援CORS配置檔案,能夠自動讀取classpath下的cors.properties,還有file watching的功能
示例程式碼下載
作者:Jearton
連結:http://www.jianshu.com/p/d05303d34222
來源:簡書
著作權歸作者所有。商業轉載請聯絡作者獲得授權,非商業轉載請註明出處。