大家好,我是業(yè)余碼農(nóng)。最近在做全棧項目的時候,遇到一個問題。
由于是前后端分離的項目,所以前后端實際上會運行在不同的域名上。如果是在本地開發(fā),前后端也會分別部署在不同的端口。
這個時候,前端直接請求后端接口,就會遇到所謂的跨域問題。
跨域錯誤
同源策略
說到跨域,首先需要解釋下為什么會出現(xiàn)這樣的跨域問題。這其實都源于瀏覽器的同源策略。
同源策略是瀏覽器中的一個重要的安全策略,是Netscape
公司在1995年引入。同源策略的作用就是為了限制不同源之間的交互,從而能夠有效避免XSS
、CSFR
等瀏覽器層面的攻擊。
同源指的是兩個請求接口URL
的協(xié)議(protocol
)、域名(host
)和端口(por
t)一致。
同源策略
比如以下例子:
同源與非同源接口
說到瀏覽器的攻擊手段,XSS
指的是惡意攻擊者往Web
頁面里插入惡意HTML
代碼,利用的是用戶對指定網(wǎng)站的信任。
而CSFR
指的是跨站請求偽造,是攻擊者通過一些技術(shù)手段欺騙用戶的瀏覽器去訪問一個自己曾經(jīng)認證過的網(wǎng)站并執(zhí)行一些操作(如發(fā)郵件,發(fā)消息,甚至財產(chǎn)操作如轉(zhuǎn)賬和購買商品)。
由于瀏覽器曾經(jīng)認證過,所以被訪問的網(wǎng)站會認為是真正的用戶操作而去執(zhí)行。這利用了Web中用戶身份驗證的一個漏洞:簡單的身份驗證只能保證請求是發(fā)自某個用戶的瀏覽器,卻不能保證請求本身是用戶自愿發(fā)出的。這實際上利用的是網(wǎng)站對用戶網(wǎng)頁瀏覽器的信任。
所以根據(jù)瀏覽器的是否同源判定,可以有選擇的限制網(wǎng)站的一些行為。比如非同源的站點會被限制訪問cookie
、localStorage
以及IndexDB
,同時也無法獲取網(wǎng)頁DOM
以及JavaScript
對象,甚至AJAX
的請求也會被攔截。
這樣一來,就能夠有效限制利用瀏覽器以及歷史訪問站點來進行攻擊的目的。
跨域問題
本質(zhì)上瀏覽器不允許跨域請求好像是件好事,因為這樣對于前端來說更安全。人家辛辛苦苦設(shè)計的同源策略,為啥會成為咱們的一個問題呢。
其實這也是沒辦法的事,畢竟前后端分離的項目,迫不得已就是需要進行跨域請求。比如在本地開發(fā)的環(huán)境中,很有可能端口號不一樣。
本地環(huán)境中的跨域問題
在線上環(huán)境中,也同樣會出現(xiàn)這樣的情況。
線上環(huán)境中的跨域問題
解決方案
1 JSONP跨域
上面所提到的跨域問題其實都是因為使用了AJAX
/XMLHttpRequest
/Fetch API
的方式來發(fā)起請求,但是其實在Web頁面上調(diào)用JS文件是不受跨域的影響的。不僅如此,擁有src屬性的標簽都擁有跨域的能力。
于是JSONP就是利用上述特點的跨域解決方案。JSONP的原理就是通過發(fā)送帶有Callback參數(shù)的GET請求,服務(wù)端將接口返回數(shù)據(jù)拼湊到Callback函數(shù)中,返回給瀏覽器,瀏覽器解析執(zhí)行,從而前端拿到Callback函數(shù)返回的數(shù)據(jù)。
JSONP跨域
前端代碼只需要在頁面中插入<script>
標簽,定義好回調(diào)函數(shù),同時通過src
請求后端接口即可:
<script>
var script = document.createElement('script');
script.type = 'text/javascript';
script.src = 'http://www.b.domain.com:8080/main?callback=handleCallback';
document.head.appendChild(script);
// 回調(diào)函數(shù)
function handleCallback(res) {
alert(JSON.stringify(res));
}
</script>
后端則需要在對應(yīng)接口將客戶端發(fā)送的callback
參數(shù)作為函數(shù)名來包裹住JSON
數(shù)據(jù),返回數(shù)據(jù)至客戶端。
handleCallback({"error": 0, "status": “0"})
JSONP
看起來很方便,但是實際上限制很大。由于script
、img
這些帶src
屬性的標簽,在引入外部資源時,使用的都是GET
請求。所以JSONP
也只能使用GET
發(fā)送請求,這也是這種方式已經(jīng)逐漸被淘汰的原因。
2 代理跨域
既然跨域問題是瀏覽器自己的一種保護措施,那么實際上能夠通過在前后端之間加一道代理層來變相進行跨域請求。
代理跨域
Webpack Server代理
在webpack
中可以通過配置proxy
來快速獲得接口代理的能力,同時前端請求的URL
不需要帶域名,代理服務(wù)器會自動自動將請求映射為同域請求。
img
可在前端webpack.config.js
配置代理:
module.exports = {
...
output: {...},
devServer: {
port: 3000,
proxy: {
"/api": {
target: "http://localhost:3001"
}
}
},
plugins: []
};
Nginx反向代理
實現(xiàn)思路其實與webpack
代理一致,無非是通過Nginx
作為跳板機而已。
Nginx反向代理
舉個Nginx
配置的例子,就是把本地端口3000
代理到3001
,這樣在本地就能夠進行跨域調(diào)試了。
server {
listen 3000;
server_name localhost;
location /api {
proxy_pass http://localhost:3001; #反向代理
}
}
Node中間件代理
原理都是類似的,只不過是將代理操作設(shè)置在了后端。若是node
項目的話,可以直接利用http-proxy-middleware
插件進行代理。本質(zhì)上webpack
也是用這個包做代理服務(wù)的,只不過現(xiàn)在把這個放在服務(wù)端。
node中間件代理
一個node
+express
+http-proxy-middleware
的例子:
var express = require('express');
var proxy = require('http-proxy-middleware');
var app = express();
app.use('/', proxy({
// 代理跨域目標接口
target: 'http://localhost:3001',
changeOrigin: true,
// 修改響應(yīng)頭信息,實現(xiàn)跨域并允許帶cookie
onProxyRes: function(proxyRes, req, res) {
res.header('Access-Control-Allow-Origin', 'localhost');
res.header('Access-Control-Allow-Credentials', 'true');
},
}));
app.listen(3000);
3 CORS跨域
CORS(Cross-Origin Resource Sharing
)是指跨域資源共享,它是一個瀏覽器側(cè)的機制,能夠允許服務(wù)器標示除了它自己以外的其它域,這樣瀏覽器就可以進行跨域訪問加載資源。
一般現(xiàn)代瀏覽器都支持CORS
跨域,只有那種古老的瀏覽器,比如IE10
以下的才不支持。
這意味著,實際上瀏覽器雖然會采取同源策略來限制跨域訪問,但是同時又給服務(wù)端提供了一個選擇,即通過CORS
來可選的提供跨域能力。
CORS跨域
比如上圖這個例子,左邊代表的是前端網(wǎng)頁,右邊代表的是服務(wù)器。前端部署在domain-a
域名下,但是有兩個資源需要請求來自不同域名的資源。
當資源來源與前端本身所在的域不一致時,便會發(fā)生跨域請求。此時可通過CORS
來控制是否允許進行跨域資源的請求。
CORS
是瀏覽器提供的能力,而實現(xiàn)CORS
的控制和通信是在服務(wù)端進行。也就是只要服務(wù)端對相應(yīng)域允許CORS
,那么便可進行跨域通信。
簡單請求
瀏覽器根據(jù)請求方法以及HTTP
頭部信息將CORS
請求分成兩類,簡單請求和非簡單請求。
若滿足下列條件,則視為簡單請求:
請求方法屬于GET
、POST
、HEAD
中的一種
HTTP頭部僅包含Accept
、Accept-Language
、Content-Language
、Content-Type
。
其中Content-Type的值僅限于text/plain
multipart/form-data
application/x-www-form-urlencoded
簡單請求
請求中的任意XMLHttpRequestUpload
對象均沒有注冊任何事件監(jiān)聽器;XMLHttpRequestUpload
對象可以使用XMLHttpRequest.upload
屬性訪問。
請求中沒有使用ReadableStream
對象。
簡單請求的流程很簡單:
簡單請求流程
- 瀏覽器發(fā)出
CORS
請求時,在頭部添加Origin
字段(最后一行),表明請求域:比如
GET /resources/public-data/ HTTP/1.1
Host: bar.other
User-Agent: Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10.5; en-US; rv:1.9.1b3pre) Gecko/20081130 Minefield/3.1b3pre
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.7
Connection: keep-alive
Referer: http://foo.example/examples/access-control/simpleXSInvocation.html
Origin: http://foo.exampl
服務(wù)端收到Origin,決定是否同意跨域請求:
- 如果同意請求,服務(wù)端會在返回的響應(yīng)中添加
CORS
相關(guān)的頭部,比如其中Access-Control-Allow-Origin
是必須字段,表示能接受請求的域名,*
表示任意域名。若不同意請求,服務(wù)端會返回一個正常的HTTP
響應(yīng),由于不包含Access-Control-Allow-Origin
,會被瀏覽器發(fā)現(xiàn),從而拋出本文最上面的錯誤。
HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 00:23:53 GMT
Server: Apache/2.0.61
Access-Control-Allow-Origin: *
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Transfer-Encoding: chunked
Content-Type: application/xml
非簡單請求
不滿足簡單請求之外的請求,都是非簡單請求。
非簡單請求的特點是,在發(fā)送正式請求之前,會先發(fā)起一個預檢請求。只要當服務(wù)端同意預檢請求時,才會發(fā)送正式的請求。
比如這里舉一個例子,假設(shè)需要發(fā)送一個請求,該請求包含自定義的頭部字段X-PINGOTHER
,同時Content-Type
為application/xml
。
可以看出這個請求是妥妥的非簡單請求,所以需要走預檢流程。
預檢請求用的是OPTIONS
請求方法,在請求頭部會表明Origin
,同時還需要帶上兩個特殊的頭部字段。
服務(wù)端接收到預檢請求后,會檢查上面這幾個字段,確定是否可以接受跨域請求。如果可以,就會在響應(yīng)的頭部中添加Access-Control-Request-Method
和Access-Control-Request-Headers
字段用來表示可接受的請求方法和請求頭。
瀏覽器接收到了預檢成功的響應(yīng)后,才會開始發(fā)起正式請求,正式請求的過程就跟簡單請求基本一致了。也就是在請求頭中添加Origin
字段,同時服務(wù)端的響應(yīng)也返回相應(yīng)的CORS
必須字段。
-
Access-Control-Request-Method
:用于表明正式請求會用到哪些HTTP
請求方法,比如例子中的POST
;Access-Control-Request-Headers
:用于表明正式請求會用哪些額外發(fā)送的頭部字段,比如例子中的X-PINGOTHER
和Content-Type
。
非簡單請求流程
雖然說CORS
跨域的方案是瀏覽器支持的機制,但是實現(xiàn)確實在服務(wù)端。但是其實工作量并不大,只需要設(shè)置允許跨域的域名、HTTP
頭部以及請求方式等參數(shù)即可。
比如在node
+express
的項目只需要添加以下代碼就可以實現(xiàn)任意域名跨域的目的。
app.all('*', function (req: express.Request, res: express.Response, next: express.NextFunction) {
//設(shè)置允許跨域的域名,*代表允許任意域名跨域
res.header("Access-Control-Allow-Origin", "*");
//允許的header類型
res.header("Access-Control-Allow-Headers", "*");
//跨域允許的請求方式
res.header("Access-Control-Allow-Methods", "DELETE,PUT,POST,GET,OPTIONS");
if (req.method.toLowerCase() === 'options')
res.sendStatus(200) //讓options嘗試請求快速結(jié)束
else
next()
})
總結(jié)
跨域問題是Web
開發(fā)中很常見的問題,解決起來其實也并不復雜。除了上面的方案,也還存在像Iframe
、postMessag
e以及websocket
等方案。
但是總的來說不如上面這三種常用,一個比較正經(jīng)的前后端分離項目更多的還是使用CORS
方案進行跨域。省時省力又省心。