前言
瀏覽器出于安全考慮崔赌,限制了JS發(fā)起跨站請(qǐng)求,使用XHR對(duì)象發(fā)起請(qǐng)求必須遵循同源策略(SOP:Same Origin Policy),跨站請(qǐng)求會(huì)被瀏覽器阻止揣苏,這對(duì)開(kāi)發(fā)者來(lái)說(shuō)是很痛苦的一件事,尤其是要開(kāi)發(fā)前后端分離的應(yīng)用時(shí)件舵。
在現(xiàn)代化的Web開(kāi)發(fā)中卸察,不同網(wǎng)絡(luò)環(huán)境下的資源數(shù)據(jù)共享越來(lái)越普遍,同源策略可以說(shuō)是在一定程度上限制了Web API的發(fā)展铅祸。
簡(jiǎn)單的說(shuō)坑质,CORS就是為了AJAX能夠安全跨域而生的合武。至于CORS的安全性研究,本文不做探討涡扼。
目錄
CORS淺述
如何使用稼跳?CORS的HTTP頭
初始項(xiàng)目準(zhǔn)備
CorsFilter: 過(guò)濾器階段的CORS
CorsInterceptor: 攔截器階段的CORS
@CrossOrigin:Handler階段的CORS
小結(jié)
追求極致的開(kāi)發(fā)體驗(yàn):整合第三方CORSFilter
示例代碼下載
CORS淺述
名詞解釋:跨域資源共享(Cross-Origin Resource Sharing)
概念:是一種跨域機(jī)制、規(guī)范吃沪、標(biāo)準(zhǔn)汤善,怎么叫都一樣,但是這套標(biāo)準(zhǔn)是針對(duì)服務(wù)端的票彪,而瀏覽器端只要支持HTML5即可红淡。
作用:可以讓服務(wù)端決定哪些請(qǐng)求源可以進(jìn)來(lái)拿數(shù)據(jù),所以服務(wù)端起主導(dǎo)作用(所以出了事找后臺(tái)程序猿降铸,無(wú)關(guān)前端^ ^)
常用場(chǎng)景:
- 前后端完全分離的應(yīng)用在旱,比如Hybrid App
- 開(kāi)放式只讀API,JS能夠自由訪問(wèn)推掸,比如地圖桶蝎、天氣、時(shí)間……
如何使用谅畅?CORS的HTTP頭
要實(shí)現(xiàn)CORS跨域其實(shí)非常簡(jiǎn)單登渣,說(shuō)白了就是在服務(wù)端設(shè)置一系列的HTTP頭,主要分為請(qǐng)求頭和響應(yīng)頭铃彰,在請(qǐng)求和響應(yīng)時(shí)加上這些HTTP頭即可輕松實(shí)現(xiàn)CORS
請(qǐng)求頭和響應(yīng)頭信息都是在服務(wù)端設(shè)置好的绍豁,一般在Filter階段設(shè)置,瀏覽器端不用關(guān)心牙捉,唯一要設(shè)置的地方就是:跨域時(shí)是否要攜帶cookie
- HTTP請(qǐng)求頭:
#請(qǐng)求域
Origin: ”http://localhost:3000“
#這兩個(gè)屬性只出現(xiàn)在預(yù)檢請(qǐng)求中竹揍,即OPTIONS請(qǐng)求
Access-Control-Request-Method: ”P(pán)OST“
Access-Control-Request-Headers: ”content-type“
- HTTP響應(yīng)頭:
#允許向該服務(wù)器提交請(qǐng)求的URI,*表示全部允許邪铲,在SpringMVC中芬位,如果設(shè)成*,會(huì)自動(dòng)轉(zhuǎn)成當(dāng)前請(qǐng)求頭中的Origin
Access-Control-Allow-Origin: ”http://localhost:3000“
#允許訪問(wèn)的頭信息
Access-Control-Expose-Headers: "Set-Cookie"
#預(yù)檢請(qǐng)求的緩存時(shí)間(秒)带到,即在這個(gè)時(shí)間段里昧碉,對(duì)于相同的跨域請(qǐng)求不會(huì)再預(yù)檢了
Access-Control-Max-Age: ”1800”
#允許Cookie跨域,在做登錄校驗(yàn)的時(shí)候有用
Access-Control-Allow-Credentials: “true”
#允許提交請(qǐng)求的方法揽惹,*表示全部允許
Access-Control-Allow-Methods:GET,POST,PUT,DELETE,PATCH
初始項(xiàng)目準(zhǔn)備
- 補(bǔ)充一下被饿,對(duì)于簡(jiǎn)單跨域和非簡(jiǎn)單跨域,可以這么理解:
- 簡(jiǎn)單跨域就是GET搪搏,HEAD和POST請(qǐng)求狭握,但是POST請(qǐng)求的"Content-Type"只能是application/x-www-form-urlencoded, multipart/form-data 或 text/plain
- 反之,就是非簡(jiǎn)單跨域疯溺,此跨域有一個(gè)預(yù)檢機(jī)制论颅,說(shuō)直白點(diǎn)哎垦,就是會(huì)發(fā)兩次請(qǐng)求,一次OPTIONS請(qǐng)求恃疯,一次真正的請(qǐng)求
- 首先新建一個(gè)靜態(tài)web項(xiàng)目漏设,定義三種類型的請(qǐng)求:簡(jiǎn)單跨域請(qǐng)求,非簡(jiǎn)單跨域請(qǐng)求今妄,帶Cookie信息的請(qǐng)求(做登錄校驗(yàn))郑口。代碼如下:
<!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">
<!--簡(jiǎn)單跨域-->
<button class="am-btn am-btn-primary" onclick="getUsers(this)">
簡(jiǎn)單跨域: 獲取用戶列表
</button>
<p class="am-text-danger"></p>
<!--非簡(jiǎn)單跨域-->
<button class="am-btn am-btn-primary" onclick="addUser(this)">
非簡(jiǎn)單跨域: 添加用戶(JSON請(qǐng)求)
</button>
<input type="text" placeholder="用戶名">
<p class="am-text-danger"></p>
<!--檢查是否登錄-->
<button class="am-btn am-btn-primary am-margin-right" onclick="checkLogin(this)">
登錄校驗(yàn)
</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>
- 然后啟動(dòng)web項(xiàng)目(這里推薦一個(gè)所見(jiàn)即所得工具:browser-sync)
browser-sync start --server --files "*.html"
-
接來(lái)下,做服務(wù)端的事情蛙奖,新建一個(gè)SpringMVC項(xiàng)目潘酗,這里推薦一個(gè)自動(dòng)生成Spring種子項(xiàng)目的網(wǎng)站:http://start.spring.io/
種子項(xiàng)目 -
項(xiàng)目結(jié)構(gòu)如下:
項(xiàng)目結(jié)構(gòu) 在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>
- 模擬數(shù)據(jù)源: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"));
}
}
- 編寫(xiě)示例控制器: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));
}
}
- 編寫(xiě)示例控制器: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);
}
}
- 最后啟動(dòng)服務(wù)端項(xiàng)目
mvn clean package
debug模式啟動(dòng)Application
- 到這里,主要工作都完成了雁仲,打開(kāi)瀏覽器,訪問(wèn)靜態(tài)web項(xiàng)目琐脏,打開(kāi)控制臺(tái)攒砖,發(fā)現(xiàn)Ajax請(qǐng)求無(wú)法獲取數(shù)據(jù),這就是同源策略的限制
- 下面我們一步步來(lái)開(kāi)啟服務(wù)端的CORS支持
CorsFilter: 過(guò)濾器階段的CORS
@Configuration
public class WebConfig extends WebMvcConfigurerAdapter {
@Bean
public FilterRegistrationBean filterRegistrationBean() {
// 對(duì)響應(yīng)頭進(jìn)行CORS授權(quán)
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);
// 注冊(cè)CORS過(guò)濾器
UrlBasedCorsConfigurationSource configurationSource = new UrlBasedCorsConfigurationSource();
configurationSource.registerCorsConfiguration("/**", corsRegistration.getCorsConfiguration());
CorsFilter corsFilter = new CorsFilter(configurationSource);
return new FilterRegistrationBean(corsFilter);
}
}
-
現(xiàn)在測(cè)試一下“簡(jiǎn)單跨域”和“非簡(jiǎn)單跨域”日裙,已經(jīng)可以正常響應(yīng)了
瀏覽器圖片
-
再來(lái)測(cè)試一下 “登錄校驗(yàn)” 和 “登錄”吹艇,看看cookie是否能正常跨域
瀏覽器圖片 如果把服務(wù)端的allowCredentials設(shè)為false昂拂,或者ajax請(qǐng)求中不帶{withCredentials: true},那么登錄校驗(yàn)永遠(yuǎn)都是未登錄,因?yàn)閏ookie沒(méi)有在瀏覽器和服務(wù)器之間傳遞
CorsInterceptor: 攔截器階段的CORS
既然已經(jīng)有了Filter級(jí)別的CORS忘瓦,為什么還要CorsInterceptor呢吱晒?因?yàn)榭刂屏6炔灰粯樱ilter是任意Servlet的前置過(guò)濾器联四,而Inteceptor只對(duì)DispatcherServlet下的請(qǐng)求攔截有效撑碴,它是請(qǐng)求進(jìn)入Handler的最后一道防線,如果再設(shè)置一層Inteceptor防線朝墩,可以增強(qiáng)安全性和可控性醉拓。
關(guān)于這個(gè)階段的CORS,不得不吐槽幾句收苏,Spring把CorsInteceptor寫(xiě)死在了攔截器鏈上的最后一個(gè)亿卤,也就是說(shuō)如果我有自定義的Interceptor,請(qǐng)求一旦被我自己的攔截器攔截下來(lái)鹿霸,則只能通過(guò)CorsFilter授權(quán)跨域排吴,壓根走不到CorsInterceptor,至于為什么杜跷,下面會(huì)講到傍念。
所以說(shuō)CorsInterceptor是專為授權(quán)Handler中的跨域而寫(xiě)的矫夷。
廢話不多說(shuō),直接上代碼:
@Configuration
public class WebConfig extends WebMvcConfigurerAdapter {
@Bean
public FilterRegistrationBean corsFilterRegistrationBean() {
// 對(duì)響應(yīng)頭進(jìn)行CORS授權(quán)
MyCorsRegistration corsRegistration = new MyCorsRegistration("/**");
this._configCorsParams(corsRegistration);
// 注冊(cè)CORS過(guò)濾器
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參數(shù)
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);
}
}
- 打開(kāi)瀏覽器憋槐,效果和上面一樣
@CrossOrigin:Handler階段的CORS
如果把前面的代碼認(rèn)真寫(xiě)一遍双藕,應(yīng)該已經(jīng)發(fā)現(xiàn)這個(gè)注解了,這個(gè)注解是用在控制器方法上的阳仔,其實(shí)Spring在這里用的還是CorsInterceptor忧陪,做最后一層攔截,這也就解釋了為什么CorsInterceptor永遠(yuǎn)是最后一個(gè)執(zhí)行的攔截器近范。
這是最小控制粒度了嘶摊,可以精確到某個(gè)請(qǐng)求的跨域控制
// 先把WebConfig中前兩階段的配置注釋掉,再到這里加跨域注解
@CrossOrigin(origins = "http://localhost:3000")
@RequestMapping(method = RequestMethod.GET)
List<User> getList() {
return Lists.newArrayList(userdb.asMap().values());
}
-
打開(kāi)瀏覽器评矩,發(fā)現(xiàn)只有第一個(gè)請(qǐng)求可以正骋抖眩跨域
Handler跨域
小結(jié)
三個(gè)階段的CORS配置順序是后面疊加到前面,而不是后面完全覆蓋前面的斥杜,所以在設(shè)計(jì)的時(shí)候虱颗,每個(gè)階段如何精確控制CORS,還需要在實(shí)踐中慢慢探索……
追求更好的開(kāi)發(fā)體驗(yàn):整合第三方CORSFilter
對(duì)這個(gè)類庫(kù)的使用和分析將在下一篇展開(kāi)
喜歡用這個(gè)CORSFilter主要是因?yàn)樗С諧ORS配置文件蔗喂,能夠自動(dòng)讀取classpath下的cors.properties忘渔,還有file watching的功能