在最近做的案子裡面遇到了這個頭大的需求,其概要就是要讓不同網域(有可能連主網域都不一樣)的網站可以分享使用同樣的 cookie。這個東西可以的話我還真的不太想自己刻,因為其實作細節還真的有點複雜,可惜我找不到什麼現成的套件可以做到這件事,只好自己弄了。

之所以這個東西很複雜,無非就是因為在現在這個年代裡面,有所謂瀏覽器的 CORS 政策這個東西,這使得我們沒有辦法很簡單地在不同網域的網頁之間進行資料傳遞。當然,就安全性角度來看,這樣的防護當然是一件好事,因為我也不希望別人可以隨便把我的頁面包到他的 iframe 裡頭就可以簡單地讀到我儲存的 cookie(很可能是會是具有高度權限的 access token,這當然不能讓別人讀到),只是這樣一來,我自己要跨網域讀我自己的 cookie 當然也一樣不簡單。

我們先來考慮幾個前提:首先,cookie 總是得存在於某一個網域之上,而且只有位於該網域上的網頁有辦法讀取到 cookie,這是絕對改變不了的(也不應該改)。所以別的網域的網頁若是想要讀到同樣的 cookie,總是得以某種方式呼叫目標網域上的網頁。一種最不花心力的方法是:先轉址到目標網頁上、讀取 cookie 然後再轉址回到 callback 網址之上;這當然很不費工夫,問題是這種作法的使用者體驗會很差,也不利於 SPA 網站的情境。因此,用 iframe 把目標網域的網頁包進來,基本上是免不了的解法。

不過這個時候要注意一個很重要的事情:自從 Chrome 80 版開始,設置在目標網域上的 cookie 必須要設置 SameSite=None 以及 Secure 兩項屬性,才能夠使得這個 cookie 在跨網域的 iframe 中被讀取。如果沒有這樣設定,那麼即使是一模一樣的網頁,也會因為被包在不同網域的網頁的 iframe 之中而導致 cookie 不允許讀取的(這個安全性要求真的很嚴格呢)。所以,這個時候要設定 cookie,應該要用類似下面的寫法:

document.cookie = "key=value; max-age=86400; SameSite=None; Secure";

除此之外,這個目標網域的網頁之中要執行的程式碼相對地都容易寫,所以底下我都只把焦點放在我們當前網域的網頁要怎麼做。

接著,不同網域的頁面要互相溝通,唯一的辦法就是 window.postMessage() 方法。這個方法打過去的時候,對方的 window 物件會觸發 onmessage 事件,然後對方就可以檢查這個事件的 origin 屬性來確定呼叫的網域是它所允許的網域、讀取它所擁有的 cookie、再把結果同樣地使用 postMessage 方法傳回來,觸發我自己的 onmessage 事件,傳回的資料會放在事件的 data 屬性之中,於是我終於就得到了 cookie。

到目前為止還好,但是假如我想要把這一整套操作寫成一個函數,那就得用一點 Promise 的非同步手法了。其概念大概會是這樣:

let resolve; // 要儲存待會 Promise 物件給的 resolve callback
let targetOrigin = "..."; // 目標網域
let targetWindow = myIframe.contentWindow; // iframe 的 window 物件

async function getCookie(key) {
    return await new Promise((res, reject) => {
        resolve = res;
        try {
            targetWindow.postMessage(key, targetOrigin);
        } catch(err) {
            // 會跑到這邊來大抵就是目標的網域錯了
            reject(err);
        }
    });
}

window.addEventListener("message", function(e) {
    // 確定一下來源對不對
    if(e.source == targetWindow && e.origin == targetOrigin) {
        resolve(e.data);
    }
});

如此一來其它程式就可以呼叫單一行 await getCookie(key) 來同步地取得 cookie 了。

然而這還只是最陽春的形式!因為現實中會遇到的狀況比理想中要複雜多了。想像一下如果我自身的網頁一開始就有一個 iframe 讀了目標網域的網頁進來,然後我自己在網頁載入的時候立刻就去呼叫 await getCookie(key),那至少有兩種狀況是我需要考慮到的:

  1. 搞不好目標網域的站台掛掉了,iframe 根本沒有成功載入網頁(更麻煩的是,基於 CORS,沒有事件可以讓我檢查這件事);

  2. 有可能目標網頁載入的速度稍微比我自己慢了一點點,結果我呼叫 getCookie 的時候對方的程式還沒有準備好接收我的 postMessage

為了解決這兩種狀況,上面的程式碼必須再更加進化:

let resolve, reject; // 要儲存待會 Promise 物件給的 callback
let targetOrigin = "..."; // 目標網域
let targetWindow = myIframe.contentWindow; // iframe 的 window 物件

// 產生一個用來確定 iframe 已經準備好的 Promise
let iResolve, iReject;
let iPromise = new Promise((res, rej) => {
    iResolve = res;
    iReject = rej;
});

async function getCookie(key) {
    return await new Promise((res, rej) => {
        resolve = res;
        reject = rej;
        post(key).catch(err => reject(err));
    });
}

async function post(key) {
    await iPromise; // 就是這邊在確定 iframe 準備完成
    targetWindow.postMessage(key, targetOrigin);
}

window.addEventListener("message", function(e) {
    // 確定一下來源對不對
    if(e.source == targetWindow && e.origin == targetOrigin) {
        // 這邊的設計是,目標網頁在載入完畢之後會做一件事:
        // 檢查自己有沒有被包在 iframe 裡面,如果有,傳送一個 true 給 parent
        if(e.data === true) iResolve();
        // 我的設計是,如果對方拒絕存取 cookie,會傳回一個 false
        else if(e.data === false) reject();
        // 成功的情況都會傳回字串或 null 表示找不到指定的 key
        else resolve(e.data);
    }
});

然後我們還需要:

<script src=".../empty.js" onerror="iReject()"></script>

其中 empty.js 是位於目標網站上的一個空的 js 檔案,用來檢查網站是否活著用的。用 <script>onerror 事件來確定站台活著這是一個標準的常見手法,因為此法這並不受到 CORS 的約束。

實際上用在我的案子當中的程式碼當然又比上面再更複雜了一點,因為我不僅會讀取、也會進行 cookie 的寫入,所以溝通模式不是只是傳一個 key 給 targetWindow 那麼單純;不過這邊的重點是要展示 Promise 物件在流程控制上的結構,所以那些細節就省略了。

分享此頁至:
最後修改日期: 2020/05/28

留言

撰寫回覆或留言

發佈留言必須填寫的電子郵件地址不會公開。