前言
瀏覽器出于安全考慮,限制了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的功能